feat: add OVHcloud DNS provider support - #5258
Conversation
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>
| 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) }, | ||
| }, | ||
| ); |
There was a problem hiding this comment.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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) }; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>
|
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>. |
There was a problem hiding this comment.
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:321—testConnectionpackages/server/src/utils/dns/ovh.ts:214—listZones
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.
There was a problem hiding this comment.
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, thenGET|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/zoneright, which lists your zones. AGET /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>
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
DnsClientinterface inpackages/server/src/utils/dns/:ovh.tsimplementslistZones,listRecords,upsertRecord,updateRecord,deleteRecordandtestConnectionagainst the/domain/zoneendpoints, on any of the seven regional endpoints (ovh-eu, ovh-ca, ovh-us, kimsufi-eu/ca, soyoustart-eu/ca).ovhvalue was added to theDnsProviderTypePostgres enum and anovhDnsConfigSchema(endpoint,applicationKey,applicationSecret,consumerKey) was added to the discriminated union indb/schema/dns-provider.ts, together with the Drizzle migration for the enum change.services/dns-provider.ts.api.ovh.com/createToken, which issues all three keys at once.Three behaviours are specific to OVH and are handled explicitly:
X-Ovh-Application,X-Ovh-Consumer,X-Ovh-TimestampandX-Ovh-Signature, the latter being$1$+sha1(applicationSecret+consumerKey+method+url+body+timestamp). The timestamp comes from the API's own clock via an unauthenticatedGET /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.POST /domain/zone/{zone}/refresh. Without it the API reports the new state while the served zone stays unchanged.domain.zone.RecordUpdatecarries nofieldType, 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 (porkbuninstead ofPorkbun) — one line, in the same map this PR had to touch anyway.Checklist
Before submitting this PR, please make sure that:
canarybranch.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
digagainst the zone's authoritative nameserver, not just through the API — the A record resolved, the CNAME resolved after the type change, and the name returnedNXDOMAINafter deletion. That is what confirms the/refreshhandling actually works end to end.pnpm -r run typecheckand the DNS test suite pass (74 tests, including 16 new OVH tests covering all sixDnsClientmethods, 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
dnsRecordTypesdoes 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. WideningdnsRecordTypesaffects 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 twoproviderLabelsmaps, 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.
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