Skip to content

Custom Domains

Attach a public hostname (api.acme.com, acme.com) to a sandbox so traffic to that host reaches the sandbox over HTTPS. TLS is issued automatically via ACME HTTP-01.

Before a custom domain can go ready, three things must be true:

  1. DNS points at the cluster ingress. Issuance uses ACME HTTP-01, so Let's Encrypt must reach the hostname on port 80/443 at your ingress. Until DNS resolves, the domain stays pending_dns. See DNS records.
  2. An ownership TXT record is published. Without it, attach returns 403. See ownership verification.
  3. The sandbox exposes HTTP/HTTPS only on a given PORT). Custom domains bind to the sandbox root (or a target port). A sandbox with a raw tcp:// / tls:// exposure rejects custom domains with 409 - the two can't coexist.

Publish a TXT record proving you control the zone. It is keyed to the hostname (not the sandbox), so provision it once and reuse it across recreates.

Custom hostnameTXT nameTXT value
Subdomain (api.acme.com)_aerol-verify.apiaerol-verify=api.acme.com
Apex (acme.com)_aerol-verifyaerol-verify=acme.com

Some provider UIs want the FQDN (_aerol-verify.api.acme.com) instead of the zone-relative name; the value is unchanged.

Verify with dig TXT _aerol-verify.<host> before attaching.

await sandbox.customDomains.add("api.acme.com", { port: 3333 });

sandbox.customDomains.dns() returns, for each attached hostname, the routing record (CNAME / A / AAAA) and the ownership TXT record. Each record has hostname, type, name, value, and an optional notes. An apex domain on a hostname ingress instead returns three mutually-exclusive routing rows — CNAME, ANAME, and ALIAS — since a bare CNAME is invalid at a zone root and providers expose flattening under different types.

const dns = await sandbox.customDomains.dns();
// dns.target → { hostname: "ingress.example.com", source: "hostname" }
// dns.records → [
// { hostname: "api.acme.com", type: "CNAME", name: "api",
// value: "ingress.example.com",
// notes: "Cloudflare/CDN: set the record to DNS only (gray cloud). …" },
// { hostname: "api.acme.com", type: "TXT", name: "_aerol-verify.api",
// value: "aerol-verify=api.acme.com",
// notes: "Required for domain ownership verification." },
// ]
for (const r of dns.records) {
console.log(`${r.type}\t${r.name}\t${r.value}`);
}

For the example above (a subdomain against a hostname ingress), the routing row to add at your DNS provider is:

TypeNameValue
CNAMEapiingress.example.com

The type depends on target. A hostname against a raw-IP ingress returns one A / AAAA per IP instead of a CNAME. The same response also carries the ownership TXT record from Ownership verification. In Cloudflare, set the CNAME to DNS only (gray cloud) — a proxied record terminates TLS at the CDN and blocks ACME issuance.

For an apex domain (e.g. acme.com) on a hostname ingress, the response returns three rows at @ with the same value. Add only the one your provider supports:

TypeNameValueProvider
CNAME@ingress.example.comCloudflare (CNAME flattening)
ANAME@ingress.example.comdnsmadeeasy, EasyDNS
ALIAS@ingress.example.comRoute 53, DNSimple

Each hostname carries a status. Poll list (or re-read the sandbox) to observe transitions - there is no push notification.

StatusMeaning
pending_dnsRow exists, no TLS handshake seen yet. DNS not pointed, or no client has connected.
issuingFirst HTTPS hit arrived; ACME HTTP-01 challenge in progress.
readyCertificate issued and serving traffic.
failedACME gave up. lastError has the reason (usually wrong DNS or LE rate-limit). Detach + reattach to retry.
const domains = await sandbox.customDomains.list();
await sandbox.customDomains.remove("acme.com");

Detach removes the Caddy route and deletes the row (the cert ages out of storage on renewal). Removing a hostname not attached to this sandbox returns 404.

StatusWhen
400 Bad RequestHostname malformed or includes a port, or target_port outside 1..65535.
403 ForbiddenOwnership TXT missing or wrong at _aerol-verify.<hostname>. See ownership verification.
409 ConflictHostname already attached elsewhere (globally unique), the sandbox has a tcp:///tls:// exposure, the per-sandbox cap is reached, or it's re-added with a different target_port.
412 Precondition FailedCustom domains disabled (SB_ENABLE_CUSTOM_DOMAINS=false) or server in IP mode (SB_DOMAIN unset).
429 Too Many RequestsDaemon-wide ACME issuance budget exhausted. Honour Retry-After.

The hostname-to-sandbox mapping replicates through Raft, so any ingress node accepts traffic for a custom domain regardless of which node owns the sandbox; non-owner nodes pass TLS through to the owner over SNI. A shared certmagic-s3 cert store means only one node runs ACME per hostname - see multi-node cert sharing.