Machine-translated draft — terminology + flow still being reviewed.

Custom Domain Setup for Self-Hosted Keel

How a self-hosted Keel operator wires a custom domain in front of their Update server and CDN bucket. Uses Caddy + Let’s Encrypt for automatic HTTPS.

Why Caddy

  • Automatic ACME — Caddy provisions and renews Let’s Encrypt certs with zero configuration; nginx needs certbot + cron + reload glue.
  • Single binary — drop one file in /usr/local/bin, point it at a Caddyfile, done. No apt install dependency tree.
  • HTTPS-first — Caddy redirects HTTP → HTTPS by default; nginx requires opting in to every safe default.
  • DNS-01 first-class — Caddy ships providers for Aliyun DNS, which matters in mainland China where port 80 is frequently blocked or requires ICP filing before HTTP-01 can succeed.

DNS setup

Two records are needed:

HostTypeTargetPurpose
update.mycorp.devACaddy host public IPv4Update manifest + patch
update.mycorp.devAAAACaddy host public IPv6(optional)
cdn.mycorp.devACaddy host public IPv4CDN front for OSS bucket

For mainland deployments, prefer DNS-01 over HTTP-01: many ISPs in China block inbound port 80 by default, and ICP filing is required before public HTTP-01 will reliably succeed. DNS-01 sidesteps both.

Caddyfile

{
    email ops@mycorp.dev
    # DNS-01 (recommended in mainland China to avoid HTTP-01 port blocks)
    # acme_dns alidns YOUR_AK_ID YOUR_AK_SECRET
}

update.mycorp.dev {
    reverse_proxy 127.0.0.1:8090
    request_body { max_size 128MB }
    log {
        output file /var/log/caddy/update.log
    }
}

cdn.mycorp.dev {
    reverse_proxy keel-bundles.oss-cn-hangzhou.aliyuncs.com {
        header_up Host {upstream_hostport}
    }
}

The request_body max_size 128MB is required for bundle uploads; default is 10MB and large RN bundles will 413 without it. The header_up Host rewrite is required for OSS — Aliyun OSS rejects requests whose Host header doesn’t match a bucket it can route.

systemd unit

Save as /etc/systemd/system/caddy.service:

[Unit]
Description=Caddy reverse proxy
After=network-online.target
Wants=network-online.target

[Service]
User=caddy
Group=caddy
ExecStart=/usr/local/bin/caddy run --config /etc/caddy/Caddyfile
ExecReload=/usr/local/bin/caddy reload --config /etc/caddy/Caddyfile
Restart=on-failure
LimitNOFILE=1048576
AmbientCapabilities=CAP_NET_BIND_SERVICE

[Install]
WantedBy=multi-user.target

Then systemctl enable --now caddy.

HTTPS verification

After DNS propagates and Caddy starts:

$ curl -I https://update.mycorp.dev/health
HTTP/2 200
content-type: application/json
server: Caddy
strict-transport-security: max-age=31536000

If you see HTTP/1.1 421 Misdirected Request, DNS hasn’t propagated yet to the Caddy host. If you see a self-signed cert warning, ACME is still in progress — wait 30s and retry; Caddy logs the ACME state at journalctl -u caddy -f.

Cert renewal

Caddy renews automatically when certs are within 30 days of expiry. Cert expiry monitoring loops back to mortar/docs/monitoring-ai-employee.md — configure an SLS metric on Caddy’s storage directory cert mtime, and the AI employee will both classify a stalled renewal as known-incident and trigger the documented caddy reload remediation from the runbook.

China-specific

  • Aliyun DNS API helper for DNS-01 — Caddy’s caddy-dns/alidns plugin uses Aliyun DNS API v2. Provision a sub-account with only AliyunDNSFullAccess and rotate its AK/SK quarterly. Store the credentials in the Caddyfile as commented-out placeholders, then inject via EnvironmentFile= in the systemd unit so they don’t sit in the config file in cleartext.
  • ICP filing reminder — any public domain pointing at a mainland-hosted IP must complete ICP filing before it will resolve reliably for end users. The filing process takes 2–4 weeks; start before you wire DNS. Hong Kong / Singapore origin IPs sidestep ICP filing but add ~80ms latency from mainland clients — for the Update server that’s acceptable, for the CDN front it isn’t (use an Aliyun CDN distribution instead).