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.
Prerequisites
Section titled “Prerequisites”Before a custom domain can go ready, three things must be true:
- 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. - An ownership TXT record is published. Without it,
attachreturns403. See ownership verification. - 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 with409- the two can't coexist.
Ownership verification
Section titled “Ownership verification”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 hostname | TXT name | TXT value |
|---|---|---|
Subdomain (api.acme.com) | _aerol-verify.api | aerol-verify=api.acme.com |
Apex (acme.com) | _aerol-verify | aerol-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.
Attach to a running sandbox
Section titled “Attach to a running sandbox”await sandbox.customDomains.add("api.acme.com", { port: 3333 });sandbox.add_custom_domain("api.acme.com", port=3333)_, err := sandbox.AddCustomDomain(ctx, "api.acme.com", microvm.WithTargetPort(3333))sandbox.add_custom_domain("api.acme.com", Some(AddCustomDomainOptions::with_port(3333)))?;sandbox.addCustomDomain("api.acme.com", 3333);DNS records
Section titled “DNS records”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}`);}dns = sandbox.custom_domain_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 r in dns["records"]: print(r["type"], r["name"], "->", r["value"])dns, err := sandbox.CustomDomainDNS(ctx)if err != nil { log.Fatal(err) }
// 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: … (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 _, r := range dns.Records { fmt.Printf("%s\t%s\t→ %s\n", r.Type, r.Name, r.Value)}let dns = sandbox.custom_domain_dns()?;
// dns.target = IngressTarget { hostname: Some("ingress.example.com"), source: "hostname", .. }// dns.records = [// DnsRecord { hostname: "api.acme.com", record_type: "CNAME", name: "api",// value: "ingress.example.com", notes: Some("Cloudflare/CDN: … (gray cloud). …") },// DnsRecord { hostname: "api.acme.com", record_type: "TXT", name: "_aerol-verify.api",// value: "aerol-verify=api.acme.com", notes: Some("Required for domain ownership verification.") },// ]
// NB: the JSON `type` field is exposed as `record_type` in Rust.for r in &dns.records { println!("{}\t{}\t→ {}", r.record_type, r.name, r.value);}CustomDomainDnsRecords dns = sandbox.customDomainDns();
// dns.target = IngressTarget{hostname="ingress.example.com", source="hostname"}// dns.records = [// DnsRecord{hostname="api.acme.com", type="CNAME", name="api",// value="ingress.example.com", notes="Cloudflare/CDN: … (gray cloud). …"},// DnsRecord{hostname="api.acme.com", type="TXT", name="_aerol-verify.api",// value="aerol-verify=api.acme.com", notes="Required for domain ownership verification."},// ]
for (DnsRecord r : dns.records) { System.out.println(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:
| Type | Name | Value |
|---|---|---|
CNAME | api | ingress.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:
| Type | Name | Value | Provider |
|---|---|---|---|
CNAME | @ | ingress.example.com | Cloudflare (CNAME flattening) |
ANAME | @ | ingress.example.com | dnsmadeeasy, EasyDNS |
ALIAS | @ | ingress.example.com | Route 53, DNSimple |
Status lifecycle
Section titled “Status lifecycle”Each hostname carries a status. Poll list (or re-read the sandbox) to observe
transitions - there is no push notification.
| Status | Meaning |
|---|---|
pending_dns | Row exists, no TLS handshake seen yet. DNS not pointed, or no client has connected. |
issuing | First HTTPS hit arrived; ACME HTTP-01 challenge in progress. |
ready | Certificate issued and serving traffic. |
failed | ACME 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");domains = sandbox.list_custom_domains()sandbox.remove_custom_domain("acme.com")domains, _ := sandbox.ListCustomDomains(ctx)err := sandbox.RemoveCustomDomain(ctx, "acme.com")let domains = sandbox.list_custom_domains()?;sandbox.remove_custom_domain("acme.com")?;List<CustomDomain> domains = sandbox.listCustomDomains();sandbox.removeCustomDomain("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.
Errors
Section titled “Errors”| Status | When |
|---|---|
400 Bad Request | Hostname malformed or includes a port, or target_port outside 1..65535. |
403 Forbidden | Ownership TXT missing or wrong at _aerol-verify.<hostname>. See ownership verification. |
409 Conflict | Hostname 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 Failed | Custom domains disabled (SB_ENABLE_CUSTOM_DOMAINS=false) or server in IP mode (SB_DOMAIN unset). |
429 Too Many Requests | Daemon-wide ACME issuance budget exhausted. Honour Retry-After. |
Cluster mode
Section titled “Cluster mode”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.