Skip to content

feat: add OVHcloud DNS provider support - #5258

Open
mitc-gjuge wants to merge 3 commits into
Dokploy:canaryfrom
mitc-gjuge:feat/ovh-dns-provider
Open

feat: add OVHcloud DNS provider support#5258
mitc-gjuge wants to merge 3 commits into
Dokploy:canaryfrom
mitc-gjuge:feat/ovh-dns-provider

Conversation

@mitc-gjuge

@mitc-gjuge mitc-gjuge commented Sep 1, 2026

Copy link
Copy Markdown

What is this PR about?

Adds OVHcloud as a supported DNS provider alongside Cloudflare, AWS Route53 and Porkbun. Once connected, Dokploy can list an OVHcloud account's zones and create, update and delete their records, the same flow already available for the other providers.

The implementation follows the existing DnsClient interface in packages/server/src/utils/dns/:

  • ovh.ts implements listZones, listRecords, upsertRecord, updateRecord, deleteRecord and testConnection against the /domain/zone endpoints, on any of the seven regional endpoints (ovh-eu, ovh-ca, ovh-us, kimsufi-eu/ca, soyoustart-eu/ca).
  • A new ovh value was added to the DnsProviderType Postgres enum and an ovhDnsConfigSchema (endpoint, applicationKey, applicationSecret, consumerKey) was added to the discriminated union in db/schema/dns-provider.ts, together with the Drizzle migration for the enum change.
  • The application secret and the consumer key are masked/merged the same way the existing providers' secrets are in services/dns-provider.ts.
  • UI: added an OVHcloud icon, an endpoint selector and the three credential fields in the DNS provider dialog, plus registration in the provider selector. The form points users at api.ovh.com/createToken, which issues all three keys at once.

Three behaviours are specific to OVH and are handled explicitly:

  • Request signing. Every authenticated call carries X-Ovh-Application, X-Ovh-Consumer, X-Ovh-Timestamp and X-Ovh-Signature, the latter being $1$ + sha1(applicationSecret+consumerKey+method+url+body+timestamp). The timestamp comes from the API's own clock via an unauthenticated GET /auth/time, since a host clock a few seconds off would get every call rejected; the measured drift is cached per endpoint for an hour.
  • Zone refresh. OVH only applies zone changes once the zone is explicitly refreshed, so every successful create, update and delete is followed by POST /domain/zone/{zone}/refresh. Without it the API reports the new state while the served zone stays unchanged.
  • Type changes. domain.zone.RecordUpdate carries no fieldType, so changing a record's type replaces the record (DELETE then POST) and returns the new id.

The record listing endpoint returns ids only, so each record is fetched individually with the fan-out capped at 8 concurrent requests.

This PR also fills in the missing Porkbun label in show-dns-providers.tsx, which fell back to displaying the raw enum value (porkbun instead of Porkbun) — one line, in the same map this PR had to touch anyway.

Checklist

Before submitting this PR, please make sure that:

Tested against a real OVHcloud account (24 zones) on a local dev instance: testConnection, listZones, listRecords, record creation, upsert de-duplication, update, type change and delete, all through the Dokploy UI's tRPC endpoints. The test zone was left with exactly its original records.

Every mutation was additionally verified with a direct dig against the zone's authoritative nameserver, not just through the API — the A record resolved, the CNAME resolved after the type change, and the name returned NXDOMAIN after deletion. That is what confirms the /refresh handling actually works end to end.

pnpm -r run typecheck and the DNS test suite pass (74 tests, including 16 new OVH tests covering all six DnsClient methods, the exact signature composition, the server-clock timestamp, the refresh calls, apex handling and the type-change replacement).

Known limitation

OVH still exposes record types that Dokploy's dnsRecordTypes does not include (SPF, DKIM, DMARC, TLSA, LOC…). Such records are listed faithfully and render with the badge's fallback style, but editing one is rejected by the shared zod enum. I chose not to filter them out of the listing, since hiding real records from a user's zone seemed worse than showing a record they cannot edit from here. Widening dnsRecordTypes affects every provider, so it felt out of scope for this PR — happy to follow up separately if you'd like it.

Issues related (if applicable)

Closes #5256

Screenshots (if applicable)

N/A

Note for maintainers

#5255 adds Infomaniak the same way. Both PRs add a 0190_* enum migration and touch the same two providerLabels maps, so whichever merges second will need its migration regenerated and a trivial conflict resolved.

Greptile Summary

Adds OVHcloud as a DNS provider across persistence, credential handling, dashboard configuration, and DNS record operations.

  • Adds seven fixed OVH-family regional endpoints and signed API requests using server-derived timestamps.
  • Implements zone and record listing, mutation-time zone refreshes, and type-change replacement.
  • Adds the database enum migration, provider UI, icon, labels, and OVH-specific tests.

Confidence Score: 3/5

The PR should not merge until failed type replacements preserve the original record and OVH upserts handle all matching records consistently.

The new client can delete a record before a failed replacement is known to succeed, and its single-value upsert can leave stale same-name/type records serving unintended answers.

Files Needing Attention: packages/server/src/utils/dns/ovh.ts

Reviews (1): Last reviewed commit: "feat: add OVHcloud DNS provider support" | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

Adds OVHcloud alongside Cloudflare, AWS Route53 and Porkbun, following the
existing DnsClient interface in packages/server/src/utils/dns/.

- ovh.ts implements listZones, listRecords, upsertRecord, updateRecord,
  deleteRecord and testConnection against the /domain/zone endpoints of the
  OVHcloud API, on any of its seven regional endpoints (ovh-eu/ca/us, kimsufi
  and soyoustart).
- A new `ovh` value was added to the DnsProviderType enum along with an
  ovhDnsConfigSchema (endpoint, applicationKey, applicationSecret, consumerKey)
  in the discriminated union, plus the Drizzle migration for the enum change.
- The application secret and the consumer key are masked/merged like the other
  providers' secrets in services/dns-provider.ts.
- UI: OVHcloud icon, an endpoint selector and the three credential fields in the
  DNS provider dialog, plus registration in the provider selector.

Three OVH-specific behaviours are handled explicitly:

- Requests are signed with `$1$` + sha1(applicationSecret+consumerKey+method+
  url+body+timestamp). The timestamp comes from the API's own clock via an
  unauthenticated GET /auth/time, since a host clock a few seconds off would get
  every call rejected; the measured drift is cached per endpoint for an hour.
- OVH only applies zone changes once the zone is explicitly refreshed, so every
  successful create, update and delete is followed by POST /domain/zone/{zone}
  /refresh.
- The record update payload carries no fieldType, so changing a record's type
  replaces the record (DELETE then POST) and returns the new id.

The record listing endpoint returns ids only, so each record is fetched
individually with the fan-out capped at 8 concurrent requests.

Also fills in the missing Porkbun label in show-dns-providers.tsx, which fell
back to displaying the raw enum value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread packages/server/src/utils/dns/ovh.ts Outdated
Comment on lines +248 to +259
if (existing.fieldType !== record.type) {
await ovhFetch(config, `/domain/zone/${zone}/record/${recordId}`, {
method: "DELETE",
});
const created = await ovhFetch<OvhRecord>(
config,
`/domain/zone/${zone}/record`,
{
method: "POST",
body: { fieldType: record.type, ...recordBody(record, zoneId) },
},
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Type replacement loses records

When the replacement POST is rejected, times out, or encounters a network failure after the DELETE succeeds, the original DNS record has already been removed and no rollback restores it, causing an unsuccessful type-change request to lose the existing record.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, fixed in 80a6baf.

I kept the delete-first order on purpose: OVH rejects a CNAME that would sit alongside other data on the same name, so creating before deleting would break the most common type change (A → CNAME). Instead, the original record is now restored from the copy already fetched before the delete, and the original error is rethrown so the user still sees why the change failed.

If the restore itself fails, the error names the record that has to be recreated by hand rather than failing silently. Both paths are covered by tests.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up: I've now verified this rollback against the live OVH API rather than only with mocks.

On an unused zone, with a dedicated test record A rollback-test.<zone> -> 1.2.3.4, I requested a type change to AAAA while keeping the IPv4 target, so the replacement POST fails on OVH's own validation:

OVH: request to POST /domain/zone/<zone>/record failed: AAAA field for <zone> is invalid

The original error surfaces (not the restore's), and the record comes back with its type, name, TTL and target intact. dig against the zone's authoritative nameserver resolves it again, which also confirms the /refresh inside the restore path works. Deleting the test record afterwards left the zone with exactly its original records.

One nuance worth flagging: the restore recreates the record, so OVH assigns it a new id (5432212328 -> 5432212342 in my run). The record is intact, but a client holding the old id has a stale reference until it refetches. Recreating it under the same id isn't possible through this API, and losing the record seemed clearly worse than changing its id — happy to hear if you'd prefer it handled differently.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This live verification addresses the concern. The original record is restored with the original error preserved, and the refresh in the rollback path is confirmed end to end. The new OVH ID is an unavoidable consequence of delete-and-recreate—OVH does not allow restoring a record under its old ID—and preserving the record is the correct trade-off. Since a successful replacement already returns the new ID and the failed path surfaces an error, no further change is needed for this comment.

Comment on lines +217 to +224
const existingId = existing[0];
if (existingId !== undefined) {
await ovhFetch(config, `/domain/zone/${zone}/record/${existingId}`, {
method: "PUT",
body: recordBody(record, record.zoneId),
});
await refreshZone(config, record.zoneId);
return { id: String(existingId) };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Upsert leaves stale records

If an OVH zone already contains multiple records with the same subdomain and field type, this branch updates only existing[0] and leaves the other matching values active, causing DNS responses to continue returning values that differ from the single value entered in Dokploy.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather leave this one as is, and I think it's a deliberate trade-off rather than an oversight.

Updating only the first match is the established behaviour of the existing clients, not something specific to OVH: cloudflare.ts takes existing[0] after querying by type and name, and porkbun.ts takes existing.records[0]. Making OVH collapse the whole RRset instead would give it different semantics from the other providers.

More importantly, the alternative is destructive. Multi-value RRsets are legitimate and common — round-robin A records, or several MX records at the apex (I hit exactly that while testing this PR, on a zone with three MX records). Deleting the siblings because the user edited one value would silently remove records they never asked to touch, which seems clearly worse than leaving them in place.

Dokploy already models this distinction: dns-record-panel.tsx gates multi-value editing behind supportsMultipleValues, currently true only for Route53, whose API genuinely addresses RRsets as a unit. OVH addresses records individually, like Cloudflare and Porkbun, so it belongs on that side of the line.

Happy to revisit if the maintainers would rather have consistent RRset semantics across providers, but that feels like a change to the shared behaviour rather than something this PR should introduce for one provider.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — this is a deliberate and defensible trade-off, not an OVH-specific correctness bug. Since OVH addresses individual records and the other individual-record clients update only the first match, collapsing the remaining records would both diverge from established provider semantics and risk deleting legitimate multi-value RRsets. I'll withdraw this finding; no change is needed here.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

Changing a record's type deletes the record then recreates it with the new type,
because OVH's update payload carries no fieldType. If the creation failed the
name was left with nothing and no rollback.

The delete still has to come first, since OVH rejects a CNAME that would sit
alongside other data on the same name. So on a failed creation the original
record is put back from the copy already fetched before the delete, and the
original error is rethrown. If the restore fails too, the error names the record
that has to be recreated by hand.

Reported by Greptile on Dokploy#5258.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nabeelio

nabeelio commented Sep 1, 2026

Copy link
Copy Markdown

Oh this is cool! Maybe I can add Bunny if I get a chance :)

Create the three keys at once on api.ovh.com/createToken
with the <code>GET</code>, <code>POST</code>,{" "}
<code>PUT</code> and <code>DELETE</code> rights on{" "}
<code>/domain/zone/*</code>.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you check whether this scope hint is enough on its own?

/domain/zone/* may not cover GET /domain/zone, which the client calls directly in two places:

  • packages/server/src/utils/dns/ovh.ts:321testConnection
  • packages/server/src/utils/dns/ovh.ts:214listZones

What makes me suspect it is OVH's own client: add_recursive_rules in consumer_key.py deliberately emits two rules, one for the root call and one for the subtree, and the docstring flags this as the common mistake:

It will take care of granting the root call AND sub-calls for you.
Which is commonly forgotten...

There's a quick way to settle it without creating anything: the consumer key is opaque, and its rules live server-side on the credential, so one call against the key you already used tells us which rules it actually has.

I don't have an account to tests this.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You were right, and it was worse than a documentation gap — fixed in f83097b.

I could test it: I created a consumer key carrying exactly one rule, GET /domain/zone/*, and queried /auth/currentCredential to confirm that was really all it had. Then:

GET /domain/zone                  -> 403  This call has not been granted
GET /domain/zone/                 -> 200  (24 zones)
GET /domain/zone/{zone}/record    -> 200

So OVH matches rights per exact path. The wildcard covers the subtree but not the bare listing, exactly as add_recursive_rules implies — and the trailing-slash form doesn't cover it either.

The reason my own testing never caught this is that the key I first used happened to carry a root rule. When I asked the account owner to create a fresh token by following the form's hint literally, testConnection failed immediately with This call has not been granted. A token created from what the form told users to do could not list zones at all.

Two changes:

  • The hint now lists the five rights verbatim (GET /domain/zone, then GET|POST|PUT|DELETE /domain/zone/*) and says why the first one has to stand alone.
  • A 403 on that call no longer echoes OVH's message, which points nowhere. It now reads: the credentials are missing the GET /domain/zone right, which lists your zones. A GET /domain/zone/* rule does not cover it — add the rule without the wildcard as well. Covered by a test.

Re-verified end to end with a key carrying exactly the five rights the corrected hint asks for: connection, 24 zones, record listing, create, upsert de-duplication, update, type change and delete, each mutation confirmed with dig against the zone's authoritative nameserver, and the test zone left with its original records.

Thanks for catching it from the client's source — that was a good call.

OVH matches access rules per exact path: a `GET /domain/zone/*` rule grants the
subtree but not the bare `GET /domain/zone` that listZones and testConnection
call. Verified against a live account with a consumer key carrying that single
wildcard rule:

    GET /domain/zone                     -> 403 This call has not been granted
    GET /domain/zone/                    -> 200
    GET /domain/zone/{zone}/record       -> 200

The form only asked for rights on `/domain/zone/*`, so a token created by
following it could not list zones at all, and the failure surfaced as a bare
"This call has not been granted" that points nowhere.

The hint now lists the five rights verbatim, and a token missing the root one
gets an error that names it instead of echoing OVH's message.

Reported by @narcisonunez on Dokploy#5258.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Add DNS provider support for OVHcloud

3 participants