Custom Domains for Published Projects¶
When a user publishes a Doable project, it's served at:
<project-slug>.<DOABLE_DOMAIN>— the platform's wildcard subdomain (e.g.my-app.doable.me).- Optionally a custom domain the user owns (e.g.
myapp.com).
This page covers the custom-domain flow.
What it looks like to a user¶
- Publish the project at least once.
- Open Project → Settings → Domain → Add custom domain.
- Enter
myapp.com. Doable shows the DNS record they need to set. - The user adds the record on their DNS provider.
- Doable verifies the record and starts serving the site at the custom domain (with SSL).
How it works under the hood¶
There are two supported modes — pick one when you deploy:
Mode A — Caddy on-demand TLS (simplest)¶
The Doable host's Caddy is configured to on-demand TLS: it asks the API "is this hostname allowed?" before issuing a cert.
{
on_demand_tls {
ask https://api.your-domain/internal/domains/check
}
}
:443 {
bind 127.0.0.1
tls {
on_demand
}
reverse_proxy 127.0.0.1:3000
}
- The user points an
AorCNAMErecord at your server. - First HTTPS request → Caddy asks the API → API checks
custom_domainstable → green-lights or denies. - Caddy issues a Let's Encrypt cert and serves the published site.
This is the path the docs and setup-server.sh follow when the server has a public IP.
Mode B — Cloudflare Tunnel + per-domain CNAME¶
For zero-port deployments where users bring their own Cloudflare account:
- The platform owns a Cloudflare Tunnel. Its UUID is in
CLOUDFLARE_TUNNEL_ID. - Each user adds a CNAME on their Cloudflare account:
myapp.com → <CLOUDFLARE_TUNNEL_ID>.cfargotunnel.com(orange cloud / proxied). - When a request hits Cloudflare for
myapp.com, it traverses the tunnel to your server's Caddy. - Caddy's
Caddyfilegets a per-domain block appended automatically (the path isCADDYFILE_PATH, default/etc/caddy/Caddyfile). - Caddy serves the site (TLS handled by Cloudflare in front).
This is what DOABLE_DOMAIN, CLOUDFLARE_TUNNEL_ID, and CADDYFILE_PATH env vars wire up.
Code map¶
| Concern | File |
|---|---|
| Custom domain CRUD API | services/api/src/routes/custom-domains.ts |
| Verification (DNS + reachability) | services/api/src/integrations/, services/api/src/deploy/ |
| Caddyfile updates | services/api/src/deploy/ |
| DB schema | packages/db/migrations/022_custom_domains.sql |
Adding a domain via the API¶
Returns the DNS record the user must add. Poll:
Status transitions: pending_dns → pending_verification → active (or error).
Removing a domain¶
Removes the entry from the custom_domains table and rewrites the Caddyfile / CNAME map.
DNS checks¶
Verification uses Node's DNS resolver — no external service. The API checks:
- The hostname resolves at all.
- It points to either the platform's IP/CNAME or the Cloudflare Tunnel.
- A test fetch over HTTPS returns the published site's marker file.
Limits¶
- Free workspaces: typically 0 custom domains (configurable via the credits/plans table).
- Paid plans: 1+, depending on tier.
The plan limits live in packages/shared/src/constants.ts.