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 to set. - Add the record on your DNS provider.
- Click Verify (or wait; Doable polls automatically). Once the DNS check passes, the site goes live at the custom domain with SSL.
DNS setup¶
Point your domain at Doable with a CNAME record:
Or, if you're on the direct/Caddy path (no Cloudflare Tunnel), point an A record at the server's IP.
Example DNS table (Cloudflare Tunnel path):
No TXT verification record is required. Doable verifies ownership by resolving the hostname and making a test HTTPS request.
DNS verification flow¶
API endpoints:
| Action | Endpoint |
|---|---|
| Add domain | POST /domains/project/:projectId { "domain": "myapp.com" } |
| Check status | POST /domains/:domainId/verify |
| List domains | GET /domains/project/:projectId |
| Remove domain | DELETE /domains/:domainId |
Status transitions: pending_dns, then pending_verification, then active (or error).
POST /domains/:domainId/verify runs two checks using Node's built-in DNS resolver (no external service):
- The hostname resolves and points to the platform's IP or Cloudflare Tunnel CNAME.
- An HTTPS request to the domain returns the published site's marker.
Cloudflare single-level subdomain rule¶
Free Universal SSL covers one level only
Cloudflare's free Universal SSL certificate covers <zone> and *.<zone>, not deeper paths like *.staging.example.com. A custom domain of myapp.staging.example.com will fail with ERR_SSL_VERSION_OR_CIPHER_MISMATCH in the browser unless you enable Cloudflare Advanced Certificate Manager (paid add-on).
Rule: keep custom domains one level under the zone:
| Works (free SSL) | Fails (needs ACM) |
|---|---|
myapp.example.com |
myapp.prod.example.com |
store.mybrand.io |
store.app.mybrand.io |
This same rule applies to Doable's own hosting infrastructure. For multi-env deployments the convention is a dash, not a dot:
staging-api.doable.me ✓ (one level under doable.me)
api.staging.doable.me ✗ (two levels, ERR_SSL_VERSION_OR_CIPHER_MISMATCH)
deployment/server-setup.sh auto-detects when DOMAIN has more than two labels and emits a warning rather than silently creating a broken wildcard route.
Reconfiguring the platform's own domain
This page is about a user's custom domain attached to a single published project. If you need to change the platform's primary domain (the host you set as DOMAIN at install time, the one that serves /admin, the dashboard, and the *.<DOMAIN> wildcard for all published apps), that is a separate flow with its own re-runnable script. See Reconfigure platform domain for the safe procedure: it rewrites .env, regenerates the Caddyfile, updates the Cloudflare Tunnel routes, and restarts services without re-running the full installer.
How Caddy + Cloudflare Tunnel resolve the request¶
- A visitor hits
myapp.com. - Cloudflare resolves the CNAME and routes the request through the platform's Tunnel to Caddy on
127.0.0.1. - Caddy's on-demand TLS block calls the API's internal check endpoint:
- The API looks up
myapp.comin thecustom_domainstable and returns green or red. - If green, Caddy issues or reuses a Let's Encrypt cert and serves the published project.
TLS renewal¶
- Caddy path: Let's Encrypt certs renew automatically. No action needed.
- Cloudflare Tunnel (proxied) path: Cloudflare terminates TLS at its edge. The cert between Cloudflare and Caddy is managed by Caddy internally. Both auto-renew.
Removing a domain¶
Doable removes the entry from custom_domains and rewrites the Caddyfile / tunnel CNAME map. You should also remove the CNAME (or A) record from your DNS provider; leaving a dangling CNAME is harmless but untidy.
Limits¶
- Free workspaces: 0 custom domains.
- Pro and above: 1+ custom domains, depending on tier.
Plan limits live in packages/shared/src/constants.ts.
Code map¶
| Concern | File |
|---|---|
| Custom domain CRUD + verify API | services/api/src/routes/custom-domains.ts |
| Domain service (DNS check, Caddyfile update) | services/api/src/services/domain-service.ts |
| DB schema | packages/db/migrations/022_custom_domains.sql |