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. Noapt installdependency 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 备案 before HTTP-01 can succeed.
DNS setup
Two records are needed:
| Host | Type | Target | Purpose |
|---|---|---|---|
update.mycorp.dev | A | Caddy host public IPv4 | Update manifest + patch |
update.mycorp.dev | AAAA | Caddy host public IPv6 | (optional) |
cdn.mycorp.dev | A | Caddy host public IPv4 | CDN front for OSS bucket |
For mainland deployments, prefer DNS-01 over HTTP-01: many ISPs in China block inbound port 80 by default, and 备案 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/alidnsplugin uses Aliyun DNS API v2. Provision a sub-account with onlyAliyunDNSFullAccessand rotate its AK/SK quarterly. Store the credentials in the Caddyfile as commented-out placeholders, then inject viaEnvironmentFile=in the systemd unit so they don’t sit in the config file in cleartext. - ICP 备案 reminder — any public domain pointing at a 大陆-hosted IP must complete ICP 备案 before it will resolve reliably for end users. The 备案 process takes 2–4 weeks; start before you wire DNS. Hong Kong / Singapore origin IPs sidestep 备案 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).