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 they need to set.
  4. The user adds the record on their DNS provider.
  5. 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 A or CNAME record at your server.
  • First HTTPS request → Caddy asks the API → API checks custom_domains table → 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:

  1. The platform owns a Cloudflare Tunnel. Its UUID is in CLOUDFLARE_TUNNEL_ID.
  2. Each user adds a CNAME on their Cloudflare account: myapp.com → <CLOUDFLARE_TUNNEL_ID>.cfargotunnel.com (orange cloud / proxied).
  3. When a request hits Cloudflare for myapp.com, it traverses the tunnel to your server's Caddy.
  4. Caddy's Caddyfile gets a per-domain block appended automatically (the path is CADDYFILE_PATH, default /etc/caddy/Caddyfile).
  5. 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

POST /domains
{ "projectId": "...", "domain": "myapp.com" }

Returns the DNS record the user must add. Poll:

GET /domains/:id

Status transitions: pending_dnspending_verificationactive (or error).

Removing a domain

DELETE /domains/:id

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.