Custom domains & DNS reference (deploymill)
deploymill gives every web app a production hostname automatically — start_project derives <app>-<org>.<platform-base> and attaches it with TLS, no DNS work from the user. A custom domain is a hostname the user owns (e.g. www.acme.com, acme.com) that you additionally point at the app. That path needs two things from the domain's owner: a one-time ownership proof (a DNS TXT record) and a DNS record pointing the host at deploymill's ingress. deploymill then issues the TLS certificate itself — the user never supplies a cert.
This guide is what to tell the user, and in what order. Both attach paths (attach_domain and domains.custom + reconcile_project) run the same validation and return the same coded errors, each carrying the exact DNS record to create — so you don't have to know the records up front.
What the user has to do (the short version)
- Prove ownership — publish the TXT record from the
domain_verification_requirederror. One-time per org + host. - Point the host at the ingress — a CNAME (subdomain) or A record (apex) to the target from the
dns_not_pointederror. Must be DNS-only / unproxied. - Re-attach — deploymill issues a Let's Encrypt cert and the host goes live over HTTPS.
The loop is: attach → read the coded error → relay the exact record it carries → the user creates it → retry. The checks are idempotent and run in the order above (ownership before DNS-reachability).
The two attach paths
Config-as-code (preferred). Add the host to domains.custom in .deploymill/project.json, commit, and run reconcile_project. A host that fails validation is reported in plan.domains.blocked[] ({ host, code, reason }) and skipped — the rest of the reconcile still applies, so fix the record and re-run. Because the host lives in the file it's re-attached on every reconcile (survives drift). Remove it from the list + prune: true to detach. See deploymill://guides/project-config.
One-off (attach_domain). Call attach_domain({ applicationId, host, port }). Same validation, but it returns the coded error directly instead of collecting into blocked. Good for a quick attach — but a host that is not also in domains.custom will be left as-is by future reconciles, and a host that is in the file gets re-added on the next reconcile regardless.
Step 1 — ownership proof (domain_verification_required)
The first time your org attaches a given host, the attach fails with code domain_verification_required carrying the record to publish:
{ "code": "domain_verification_required",
"host": "www.acme.com",
"txtRecord": {
"type": "TXT",
"name": "_deploymill-challenge.www.acme.com",
"value": "deploymill-domain-verification=<token>"
} }
Tell the user to create exactly that TXT record at their DNS provider, then retry the attach. Notes:
- The token is org-scoped — proving ownership for one org's token does not authorize a different org to attach the same host.
- Once proven, the
(host, org)pair is remembered; later re-attaches (and reconciles) skip this step. - The record can be removed after the attach succeeds — it's only read at verification time.
- It has to be visible to deploymill's resolver before the retry; if it was just published, allow for DNS propagation and retry.
Step 2 — point DNS at the ingress (dns_not_pointed)
Once ownership is proven, the attach checks that the host actually resolves to deploymill's ingress — the Let's Encrypt HTTP-01 challenge fails silently otherwise, so no cert would issue. If it isn't pointed yet, the attach fails with the exact target to use:
{ "code": "dns_not_pointed",
"host": "www.acme.com",
"expected": { "type": "CNAME", "value": "<ingress-host>" },
"observed": ["A 203.0.113.7"] }
Tell the user to create the record pointing at expected.value:
- Subdomain (
www.acme.com,api.acme.com) → a CNAME toexpected.value. - Apex / root (
acme.com) → an apex can't CNAME; use an A record matching the ingress host's IP, or your DNS provider's CNAME-flattening /ALIAS/ANAMErecord type pointed atexpected.value.
The record must be DNS-only / unproxied. deploymill issues the cert via an HTTP-01 challenge that has to reach its origin directly. If the record sits behind the user's own Cloudflare/CDN proxy (the orange cloud in Cloudflare), the proxy intercepts the challenge and no cert issues. Point it straight at the ingress host the error reports, grey-clouded / DNS-only.
skipDnsCheck: true bypasses this pre-check only — use it when a stale resolver is failing the check but you know DNS is correct. It does not bypass ownership proof, and Let's Encrypt issuance still fails later if DNS truly isn't pointed.
Step 3 — the certificate (automatic)
There is no cert step for the user. Once the host is verified and pointed, deploymill registers it with TLS and Traefik issues a per-host Let's Encrypt certificate over HTTP-01 from deploymill's origin. Each custom host gets its own cert under the user's domain's Let's Encrypt budget, so this scales across tenants.
Issuance is not instant — after a successful attach, the cert may take a short while to issue and the DNS change to propagate. Don't report the domain "live" off the attach return alone; confirm by fetching https://<host> (or get_app_health once the prod URL answers). A freshly attached host may briefly serve a cert warning until issuance completes.
Error codes
Every code below is machine-readable on the tool result (or in plan.domains.blocked[].code for reconcile) — branch on it, don't parse the message.
| Code | Meaning | What to tell the user |
|---|---|---|
domain_verification_required | Ownership not yet proven for this org + host | Publish the txtRecord from the error, then retry |
host_claimed | A different org already verified this host | Use a host they control; if they lost access to a prior deploymill account, contact support to release the claim |
dns_not_pointed | Host doesn't resolve to the ingress yet | Create the CNAME/A record at expected.value, unproxied, then retry |
invalid_hostname | Malformed hostname (or a wildcard) | Attach a concrete host like www.example.com (no *) |
reserved_hostname | Host is under the platform's own wildcard domain | That namespace is deploymill's (it mints the auto prod + preview hosts); use a domain the user owns |
dns_not_configured | The server has no custom-domain target set | Operator-side: set CUSTOM_DOMAIN_TARGET. Not user-fixable |
Detaching
- One-off:
detach_domain({ applicationId, host })(ordomainId). Idempotent — returns{ detached: false }if nothing matched. - Config: remove the host from
domains.customand runreconcile_projectwithprune: true. A host left in the file is re-added on the next reconcile, so remove it from the file too.
Detaching does not revoke the ownership proof — re-attaching the same host later for the same org skips re-verification.
What NOT to do
- Don't tell the user to upload or buy a TLS certificate. deploymill issues it; the only user action is DNS.
- Don't leave the record proxied. An orange-clouded Cloudflare record (or any CDN in front) breaks HTTP-01 — the cert never issues. DNS-only.
- Don't CNAME an apex. Most providers reject a CNAME at the zone root; use A / ALIAS / ANAME.
- Don't attach a host under the platform wildcard base as a "custom" domain. That's deploymill's namespace (
reserved_hostname); the auto-derived prod host is already managed for you. - Don't report success off the attach return. Verify HTTPS actually serves the app before telling the user it's done.