Skip to content

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

  1. Publish the project at least once.
  2. Open Project → Settings → Domain → Add custom domain.
  3. Enter myapp.com. Doable shows the DNS record to set.
  4. Add the record on your DNS provider.
  5. 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:

CNAME  myapp.com  ->  <CLOUDFLARE_TUNNEL_ID>.cfargotunnel.com

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):

Type   Name       Value
CNAME  myapp.com  abc123def456.cfargotunnel.com   (proxied / orange cloud)

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):

  1. The hostname resolves and points to the platform's IP or Cloudflare Tunnel CNAME.
  2. 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

  1. A visitor hits myapp.com.
  2. Cloudflare resolves the CNAME and routes the request through the platform's Tunnel to Caddy on 127.0.0.1.
  3. Caddy's on-demand TLS block calls the API's internal check endpoint:
    on_demand_tls {
      ask https://api.your-domain/internal/domains/check
    }
    
  4. The API looks up myapp.com in the custom_domains table and returns green or red.
  5. 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

DELETE /domains/:domainId

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