Docs
Custom domains
The records to create, how verification works and when the certificate is issued.
A service that has ingress (the ingress notes) answers on https://<slug>-<short org id>.svc.<platform domain>. A custom domain lets it answer on a hostname you own as well, for example api.customer.com, with a certificate Keel issues and renews for you. The request reaches the same tunnel as the platform hostname; the service sees the name the client used in Host and in X-Keel-Custom-Domain.
Custom domains are on the Business plan (10 per organization) and on Enterprise (unlimited). Starter has none.
A static site (Static sites) takes a custom domain the same way: the target is the site instead of a service, the CNAME points at the site's platform hostname, and the relay serves the live deployment on the custom name. There is no upstream, so no X-Keel-Custom-Domain header is added anywhere; the response is the one the platform hostname would give. The plan limit counts service and site domains together.
How it works
- You claim a hostname for a service. Keel answers with two DNS records: a
TXTrecord that proves you control the name, and aCNAME(or, for an apex name,A/AAAArecords) that routes it to the service. - You create the records at your DNS host.
- Keel checks the records (
keel domains verify, the dashboard's Verify now, or the verifier that runs every minute). Once both halves resolve the domain isactive. - The first HTTPS request for an active domain makes the edge ask Let's Encrypt for a certificate. Later requests use the cached certificate; renewal is automatic.
Verification passes when the TXT record carries your token and either the CNAME points at the platform hostname or the name resolves to the same addresses as the platform hostname (this is how ALIAS, ANAME, flattened CNAMEs and plain A records all work). Wildcards are not supported: claim each hostname on its own. One hostname belongs to one organization; a second organization claiming it is refused with 409 hostname_taken, unless the existing claim is stale (see below).
Statuses:
| Status | Meaning |
|---|---|
pending_verification |
Claimed; no record has been seen yet. Rechecked every minute at first, backing off to once an hour. |
verifying |
One half is in place (the TXT or the routing record), the other is still missing. lastError says which. |
active |
Serving. Rechecked every six hours. |
failed |
Was active, and three consecutive six-hourly checks found the records gone. It stops serving until the records are back; the next successful check makes it active again. Nothing is deleted. |
Stale claims
A claim that never verifies must not block the real owner of a name forever. A new claim from another organization supersedes the existing claim for the same hostname when that claim is not active and is older than 24 hours, or when it is failed without ever having been active. An active claim is never superseded, however old. The superseded row is deleted in the same transaction as the new claim, so the hostname never has two owners and the loser's next check simply finds nothing to check. The API logs the takeover with both organization ids, both domain ids and the superseded status (custom domain claim superseded another organization's claim, field audit: custom_domain.claim_superseded) so it can be traced. A claim from the same organization is always refused with 409 hostname_taken; remove the old one first.
A domain also stops serving, whatever its status says, when its service is paused or archived, when the service has no ingress any more, or when the organization's subscription lapses. Removing the domain stops it immediately.
Claiming a domain
CLI, from the project directory or with --agent:
keel domains add api.customer.com --agent support-bot
keel domains add www.customer.com --site marketing
Without --agent or --site, a keel.yaml with a site: block targets that site and any other keel.yaml targets its agent.
The command prints the records to create:
TYPE NAME VALUE
TXT _keel-verify.api.customer.com keel-domain-verification=3f9c...
CNAME api.customer.com support-bot-0a1b2c3d.svc.keel.example
A api.customer.com 203.0.113.10
The A row is the apex alternative; ignore it for a subdomain. keel domains list shows every domain with its status, keel domains verify <hostname> runs the check now (exit 0 when active, 1 when not yet), keel domains remove <hostname> --yes removes it. The dashboard has the same under Domains on the service page, with copy buttons on each record.
API: POST /v1/agents/{id}/domains with { "hostname": "api.customer.com" }, GET /v1/agents/{id}/domains, GET /v1/domains, GET /v1/domains/{id}, POST /v1/domains/{id}/verify, DELETE /v1/domains/{id} (HTTP API).
DNS host recipes
Replace api.customer.com with your hostname, <token> with the value from the add command, and <platform hostname> with the CNAME value it printed.
Cloudflare
DNS, Records, Add record:
- Type
TXT, Name_keel-verify.api(Cloudflare appends the zone), Contentkeel-domain-verification=<token>. - Type
CNAME, Nameapi, Target<platform hostname>, Proxy status DNS only (grey cloud).
Keep the record DNS only. With the proxy on, Cloudflare terminates TLS with its own certificate and forwards to Keel over an origin connection that Keel cannot issue a certificate for; verification also fails because the name resolves to Cloudflare's addresses. For an apex (customer.com) create the CNAME with Name @; Cloudflare flattens it, which is fine.
Vercel DNS
vercel dns add customer.com _keel-verify.api TXT "keel-domain-verification=<token>" and vercel dns add customer.com api CNAME <platform hostname>. For the apex use vercel dns add customer.com @ ALIAS <platform hostname>.
Route 53
In the hosted zone, Create record:
- Record name
_keel-verify.api, typeTXT, value"keel-domain-verification=<token>"(with the quotes). - Record name
api, typeCNAME, value<platform hostname>.
For the apex, Route 53 has no ALIAS to an external name: create A (and AAAA if given) records with the address rows from the add command.
Apex domains elsewhere
A CNAME is not allowed at the apex of a zone. If your DNS host offers ALIAS, ANAME or CNAME flattening, use it with <platform hostname>. Otherwise create the A/AAAA records the add command lists (the platform ingress addresses); they change only when the platform moves, which is announced ahead of time. The TXT record is the same in every case.
Checking from your machine
dig +short TXT _keel-verify.api.customer.com
dig +short CNAME api.customer.com
dig +short A api.customer.com
Keel resolves through public resolvers (1.1.1.1, 8.8.8.8, 9.9.9.9), so a record that your own resolver caches from before the change can take up to its TTL to appear. If keel domains verify says verification record missing the TXT is wrong or not propagated; routing record missing means the CNAME/A half.
What the service receives
| Header | Value |
|---|---|
Host |
the custom hostname the client used |
X-Keel-Custom-Domain |
the custom hostname, normalised to lower case; absent on platform hostnames, never trusted from the client |
X-Keel-Ingress-Id |
the ingress id, as for the platform hostname |
X-Forwarded-For, X-Forwarded-Proto |
as for the platform hostname |
Rate limits, body caps and timeouts are the ones in the ingress notes; a custom domain shares the per-service bucket with the platform hostname.
Certificates and the edge
On the single host Caddy issues certificates on demand: the first TLS handshake for a name asks the relay (GET /internal/tls/ask?domain=<name>), which answers 200 only for an active custom domain that is currently serving, and Caddy then completes an ACME order for that one name. Let's Encrypt limits apply per registered domain: 50 new certificates per week, 5 duplicate certificates per week, 300 new orders per account per three hours, and 5 failed validations per account per hostname per hour. A customer who adds and removes the same hostname repeatedly can hit the duplicate limit; wait an hour and verify again. The ask endpoint refuses anything that arrived through a proxy, never answers for platform hostnames, and is rate limited (INGRESS_TLS_ASK_RATE_LIMIT_PER_MINUTE, 300 by default), so a scan with random server names cannot turn into a flood of ACME orders.
On Fargate the ALB cannot issue certificates on demand; see docs/adr/0002-custom-domain-tls.md for the plan.