Custom domains & DNS
Every web app gets 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. This guide is what to tell the user, and in what order.
A custom domain 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.
How the attach flow works
Both attach paths (attach_domain and domains.custom + reconcile_project) run the same validation and surface the same codes, each carrying the exact DNS record to create, so you don't have to know the records up front. The expected, recoverable preconditions come back as a success-shaped result you branch on, not on the error channel:
attach_domainreturns{ ok: false, code, reason, ...fields }: thetxtRecordfordomain_verification_required, theexpected/observedfordns_not_pointed, thehostfor the rest. On success it returns{ ok: true, domainId, host, port, url }.reconcile_projectcollects the same failures as{ host, code, reason }intoplan.domains.blocked[].
The loop is: attach → read the { ok: false, code, ... } result → relay the exact record it carries → the user creates it → retry. The checks are idempotent and run in order (ownership before DNS-reachability).
What the user has to do (the short version)
- Prove ownership: publish the TXT record from the
domain_verification_requiredresult. 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_pointedresult. Must be DNS-only / unproxied. - Re-attach: DeployMill issues a Let's Encrypt cert and the host goes live over HTTPS.
Choosing an attach path
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 and reconcile with 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 failure directly as { ok: false, code, ... } 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 returns { ok: false } with code domain_verification_required carrying the record to publish:
{ "ok": false,
"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:
{ "ok": false,
"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) → most DNS providers support CNAME-flattening viaALIASorANAMErecords. Use one of those pointed atexpected.value. Do not use a bare A record with an IP address, because if the ingress IP ever changes, the domain breaks silently. Standard CNAME at the zone apex is not valid DNS, but every major provider (Cloudflare, Route 53, DNSimple, Namecheap) offers an equivalent flattened record type that follows the hostname instead of pinning an IP.
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 the ingress 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's health block 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. On attach_domain it rides the success-shaped { ok: false, code, ... } payload (the expected, recoverable preconditions), or in plan.domains.blocked[].code for reconcile. Branch on it, don't parse the message. (A genuine infra fault, e.g. the ingress being unreachable, still surfaces on the error channel, not as { ok: false }.)
| Code | Meaning | What to tell the user |
|---|---|---|
custom_domains_not_available | Custom domains aren't included on the org's current plan | Custom domains are a paid-plan feature. Upgrade the org's plan (Free uses the auto subdomain only), then retry |
domain_verification_required | Ownership not yet proven for this org + host | Publish the txtRecord from the result, 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 |
host_taken | The host already has a live route owned by another org (distinct from host_claimed, which is the verification claim) | Pick a different host, or have the owning org detach it first |
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 or another platform-owned/reserved domain (e.g. DeployMill's apex/marketing domain) | That namespace is DeployMill's (the wildcard mints the auto prod + preview hosts; reserved bases are operator-configured); 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. Unlike the rows above, this is a server-config fault, so it surfaces on the MCP error channel, not as { ok: false }. |
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 use an A record with an IP address. If the ingress IP changes, the domain breaks silently. Always use a CNAME (subdomain) or ALIAS/ANAME (apex) pointed at the ingress hostname so it tracks automatically.
- Don't attach a host under the platform wildcard base — or any platform-owned domain — as a "custom" domain. That's DeployMill's namespace (
reserved_hostname, which also covers the platform's reserved apex/marketing domains); 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.