Summary
Openship manages custom domains and issues Let's Encrypt certificates, but has no DNS provider integration. I'd like to add one, starting with Cloudflare, and I'm happy to implement it — I have a design worked out and wanted to check direction with you before opening a PR.
Motivation
Adding a custom domain today means copying the A record and the _openship-challenge TXT record out of the dashboard, creating them by hand at the registrar, then coming back to click Verify. That works, but it's the manual step that most separates the self-hosted experience from Vercel/Netlify.
It also blocks two things:
- Wildcard certificates. The README advertises wildcards, but Let's Encrypt only issues those via the DNS-01 challenge, which needs write access to a DNS zone. Without a DNS integration there's no way to fully automate it.
cloudflare.provider.ts in the tunneling module is currently a stub that throws ProviderNotImplementedError. Its header comment already sketches the intended design (quick tunnel via cloudflared --url, named tunnel via API token), and the named-tunnel flow needs exactly the same credential storage and DNS record writes.
Cloudflare is the most common DNS provider among self-hosters and the API is free, so one integration covers all three.
Proposal
A new apps/api/src/modules/dns/ module structured to mirror modules/tunneling/ — a DnsProvider interface, a registry.ts with the same resolveProvider / listProviders / describeProviders shape, and providers/cloudflare.provider.ts as the first implementation. Adding Route53 or DigitalOcean later would be one file plus one registry line.
The interface stays small: preflight, findZone, listRecords, upsertRecord, deleteRecord. findZone walks the hostname's labels from most to least specific and returns null when nothing matches, since "this domain isn't on Cloudflare" is an expected outcome rather than an error.
Credentials would reuse the pattern backup_destination already established: an org-scoped row with the token encrypted through encryptSecretField() and the existing enc1: AES-256-GCM envelope. No new crypto, and scoped API tokens only (Zone:Read + DNS:Edit), with the legacy Global API Key rejected at connect time.
Three features on top of that layer:
- Automatic records.
addDomain() hooks in right where it currently calls buildRecords() — if a connected provider owns the zone, create the routing record and the challenge TXT, then fire verification in the background the same way verifyDomain() already fires SSL provisioning. removeDomain() cleans up, best-effort.
- DNS-01 for wildcards. An optional
{ challenge } hint on SslProvider.provisionCert (defaulted, so existing implementations keep compiling). Certbot runs with manual auth/cleanup hooks that call back into the API over loopback with a short-lived single-use token, so credentials stay in the API process and never touch a remote deploy target's disk — which matters given resolveSslProvider() may run certbot over SSH.
- Cloudflare Tunnel. Implement the existing stub to the design its own comment describes, reusing the credential store for the named-tunnel flow.
The important property: with no provider connected, every path falls back to exactly today's behavior. Provider failures — token expired, rate limited, Cloudflare down — degrade to the manual flow and never block adding a domain, removing one, or deploying.
A detail worth calling out
Records would be created with proxied: false. The orange-cloud proxy terminates TLS at the edge and intercepts /.well-known/acme-challenge/, which breaks the HTTP-01 challenge the certbot path depends on. DNS-only is the correct default; once DNS-01 lands, proxying becomes safe to opt into.
Relationship to #12
#12 asks for the opposite direction and I think the two are complementary rather than overlapping: it wants Openship to stand back when ingress and TLS are managed externally (skip certbot, don't require the domain to resolve to the SSH address). This proposal is for when the user wants Openship to drive DNS on their behalf. Both are useful, and they'd be selected by different users with different setups — neither forecloses the other.
Questions before I start
- Is a DNS provider abstraction something you want in the codebase, or would you rather keep DNS entirely out of scope?
- Would you prefer this as one PR or split — provider layer first, then DNS-01, then the tunnel?
- Any objection to a new
dns_credential table, or would you rather this live somewhere existing?
Happy to adjust the design to whatever fits your plans. If you'd rather not take this at all, no hard feelings — better to ask first than show up with an unwanted PR.
Summary
Openship manages custom domains and issues Let's Encrypt certificates, but has no DNS provider integration. I'd like to add one, starting with Cloudflare, and I'm happy to implement it — I have a design worked out and wanted to check direction with you before opening a PR.
Motivation
Adding a custom domain today means copying the A record and the
_openship-challengeTXT record out of the dashboard, creating them by hand at the registrar, then coming back to click Verify. That works, but it's the manual step that most separates the self-hosted experience from Vercel/Netlify.It also blocks two things:
cloudflare.provider.tsin the tunneling module is currently a stub that throwsProviderNotImplementedError. Its header comment already sketches the intended design (quick tunnel viacloudflared --url, named tunnel via API token), and the named-tunnel flow needs exactly the same credential storage and DNS record writes.Cloudflare is the most common DNS provider among self-hosters and the API is free, so one integration covers all three.
Proposal
A new
apps/api/src/modules/dns/module structured to mirrormodules/tunneling/— aDnsProviderinterface, aregistry.tswith the sameresolveProvider/listProviders/describeProvidersshape, andproviders/cloudflare.provider.tsas the first implementation. Adding Route53 or DigitalOcean later would be one file plus one registry line.The interface stays small:
preflight,findZone,listRecords,upsertRecord,deleteRecord.findZonewalks the hostname's labels from most to least specific and returnsnullwhen nothing matches, since "this domain isn't on Cloudflare" is an expected outcome rather than an error.Credentials would reuse the pattern
backup_destinationalready established: an org-scoped row with the token encrypted throughencryptSecretField()and the existingenc1:AES-256-GCM envelope. No new crypto, and scoped API tokens only (Zone:Read+DNS:Edit), with the legacy Global API Key rejected at connect time.Three features on top of that layer:
addDomain()hooks in right where it currently callsbuildRecords()— if a connected provider owns the zone, create the routing record and the challenge TXT, then fire verification in the background the same wayverifyDomain()already fires SSL provisioning.removeDomain()cleans up, best-effort.{ challenge }hint onSslProvider.provisionCert(defaulted, so existing implementations keep compiling). Certbot runs with manual auth/cleanup hooks that call back into the API over loopback with a short-lived single-use token, so credentials stay in the API process and never touch a remote deploy target's disk — which matters givenresolveSslProvider()may run certbot over SSH.The important property: with no provider connected, every path falls back to exactly today's behavior. Provider failures — token expired, rate limited, Cloudflare down — degrade to the manual flow and never block adding a domain, removing one, or deploying.
A detail worth calling out
Records would be created with
proxied: false. The orange-cloud proxy terminates TLS at the edge and intercepts/.well-known/acme-challenge/, which breaks the HTTP-01 challenge the certbot path depends on. DNS-only is the correct default; once DNS-01 lands, proxying becomes safe to opt into.Relationship to #12
#12 asks for the opposite direction and I think the two are complementary rather than overlapping: it wants Openship to stand back when ingress and TLS are managed externally (skip certbot, don't require the domain to resolve to the SSH address). This proposal is for when the user wants Openship to drive DNS on their behalf. Both are useful, and they'd be selected by different users with different setups — neither forecloses the other.
Questions before I start
dns_credentialtable, or would you rather this live somewhere existing?Happy to adjust the design to whatever fits your plans. If you'd rather not take this at all, no hard feelings — better to ask first than show up with an unwanted PR.