Custom domains
Attach myapp.com to a channel. TLS is automatic.
The flow
Every channel ships at <channel>-<app>-<user>.xhostd.com
out of the box. To put a channel on a domain you own, attach the hostname,
create two DNS records at your registrar, and ask xhost to verify. HTTPS
starts working on the first request — a certificate is minted on
demand the first time a browser connects.
From inside Claude:
> put my prod channel on myapp.com
# Claude calls:
# add_custom_domain(app_name, "prod", "myapp.com") # returns DNS instructions
# verify_custom_domain(app_name, "prod", "myapp.com") # once records are live
The first call returns a pending domain and a short instruction
block telling you exactly which records to create. The second call validates
DNS and flips the status to verified; from that point on,
https://myapp.com serves your channel.
DNS records you create at your registrar
Two records, always: one TXT that proves you own the domain, and one routing record that points traffic at xhost.
Subdomain (app.mycompany.com)
TXT _xhost.app.mycompany.com xhost-verify-<token from add_custom_domain>
CNAME app.mycompany.com <your channel's canonical hostname>
Apex / naked domain (mycompany.com)
Most DNS providers don't allow CNAME at the zone apex. Use an A record
pointing at xhost's edge IP instead (the add_custom_domain
response includes the address).
TXT _xhost.mycompany.com xhost-verify-<token>
A mycompany.com <platform IP from add_custom_domain>
Verification
verify_custom_domain is idempotent and safe to retry. DNS
propagation usually takes a few minutes after you save the records, so the
first call after adding them often still returns pending with
one of these reasons:
-
txt_nxdomain— the TXT record isn't visible yet. Wait a minute and retry. -
txt_token_mismatch— the TXT value doesn't match the token. Double-check you pasted the fullxhost-verify-...string. -
dns_not_pointing— the A or CNAME doesn't resolve to xhost. Common cause: the CNAME points at the wrong hostname, or a Cloudflare "proxy" toggle is intercepting the record. -
domain_nxdomain— the domain itself isn't resolving. Usually a typo or a brand-new registration that hasn't propagated. -
txt_lookup_failed/dns_lookup_failed— transient resolver hiccup. Retry.
Once a domain is verified, only the four definitive failures
(the first four above) ever flip it back to pending —
transient resolver errors are recorded as last_error but never
downgrade a working domain.
HTTPS
You don't issue or renew certificates. The first HTTPS request after verification triggers Caddy to mint a Let's Encrypt cert on demand; subsequent requests reuse the cached cert. Renewal happens automatically.
A consequence of on-demand TLS: the very first request after verification can take a few extra seconds while the certificate is issued. After that it's normal HTTPS — no warm-up needed.
OAuth works on custom domains
Google sign-in works on every verified custom domain
with no extra config — /xhost-auth/* is served on every
channel host. The identity cookie is scoped to the host you visited (its
aud claim is that exact hostname), so signing in on
myapp.com doesn't sign you in on the channel's
*.xhostd.com hostname (or any other custom domain attached to
the same channel). Verify the cookie against that domain's hostname as the
expected aud.
Limits
- Up to 5 custom domains per channel.
-
Domains are globally unique across xhost. Attempting
to attach a domain another channel already owns returns
domain_taken. -
IP addresses, the platform's own domains,
localhost, and*.local/*.internalare rejected. -
Wildcard custom domains (
*.myapp.com) are not supported.
Detaching
> remove myapp.com from prod
# Claude calls: remove_custom_domain(app_name, "prod", "myapp.com")
The route is removed immediately and certificate renewals stop — the cached cert will expire on its own. No registrar action required (you can leave or remove the DNS records on your side; they have no effect once the domain is detached).