coredns/docker-compose.yml
Ryan Malloy c1afe77b27 coredns: production Let's Encrypt cert via Caddy sidecar (DNS-01 + Vultr)
Replaces the self-signed dev cert flow with a real LE prod cert for
dns.l.supported.systems, issued and auto-renewed by a Caddy sidecar
using DNS-01 challenge against the Vultr API.

Components:
- caddy/Dockerfile builds Caddy 2.10.0 with caddy-dns/vultr plugin
  via xcaddy. GOTOOLCHAIN=auto so xcaddy can fetch newer Go on demand
  when plugin versions advance their minimum Go.
- caddy/Caddyfile uses DNS-01 with explicit public resolvers (1.1.1.1,
  9.9.9.9) for the propagation check. Without that, Docker's embedded
  DNS leaks the container into the host's split-horizon LAN DNS, which
  returns LAN IPs for ns1.vultr.com and the propagation check fails.
- docker-compose: caddy service shares ./caddy-data with coredns via a
  read-only subpath mount that excludes /acme (account private key).
- Healthcheck doubles as a symlinker: maintains stable cert.pem /
  key.pem names at /data/caddy/ and chmods cert files + their dirs to
  be readable by CoreDNS's nonroot user. Flips to "healthy" only once
  the symlinks dereference (i.e. cert exists), gating CoreDNS start
  via depends_on: service_healthy.
- Corefile unchanged — same /etc/coredns/certs/cert.pem path; only the
  bind-mount source switches from ./certs to ./caddy-data/caddy.
- New Makefile target: tls-up orchestrates the bring-up sequence.

Cert is valid until Aug 12 2026. Verified end-to-end:
  dig @127.0.0.1 -p 8853 +tls +tls-hostname=dns.l.supported.systems ...
  dig @127.0.0.1 -p 8443 +https +tls-hostname=dns.l.supported.systems ...
2026-05-14 01:34:57 -06:00

69 lines
2.7 KiB
YAML

services:
# Caddy runs as a dedicated ACME client + cert renewer. It provisions
# a Let's Encrypt cert for ${CADDY_HOSTNAME} via DNS-01 (Vultr API)
# and persists it to ./caddy-data. CoreDNS reads from that same path
# read-only. The container's HTTP/HTTPS ports are NOT published — we
# only care about the cert files on disk.
caddy:
build: ./caddy
container_name: coredns-caddy
restart: unless-stopped
environment:
- CADDY_HOSTNAME=${CADDY_HOSTNAME}
- ACME_EMAIL=${ACME_EMAIL}
- VULTR_API_KEY=${VULTR_API_KEY:?VULTR_API_KEY must be exported in your shell}
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy-data:/data
- ./caddy-config:/config
healthcheck:
# Two-jobs-in-one: (1) maintain stable filenames (cert.pem / key.pem)
# as symlinks into Caddy's hostname-keyed storage, so the Corefile
# doesn't have to encode the hostname. (2) Flip to "healthy" once
# the symlink dereferences successfully (i.e. Caddy has issued).
# Relative symlink targets so paths work the same from host or
# from any container mounting this directory.
test:
- "CMD-SHELL"
- >
ln -sf certificates/acme-v02.api.letsencrypt.org-directory/${CADDY_HOSTNAME}/${CADDY_HOSTNAME}.crt /data/caddy/cert.pem &&
ln -sf certificates/acme-v02.api.letsencrypt.org-directory/${CADDY_HOSTNAME}/${CADDY_HOSTNAME}.key /data/caddy/key.pem &&
chmod 755 /data/caddy &&
chmod -R a+rX /data/caddy/certificates 2>/dev/null;
test -e /data/caddy/cert.pem
interval: 10s
timeout: 3s
retries: 60 # ~10 min ceiling for initial issuance
start_period: 5s
coredns:
image: ${COREDNS_IMAGE}
container_name: coredns
restart: unless-stopped
command: ["-conf", "/etc/coredns/Corefile"]
depends_on:
caddy:
condition: service_healthy
ports:
- "${DNS_PORT}:53/udp"
- "${DNS_PORT}:53/tcp"
- "${DOT_PORT}:853/tcp"
- "${DOH_PORT}:443/tcp"
- "${METRICS_PORT}:9153/tcp"
- "${HEALTH_PORT}:8080/tcp"
volumes:
- ./Corefile:/etc/coredns/Corefile:ro
- ./zones-prepared:/zones:ro
# Subpath mount of Caddy's data dir. The healthcheck maintains
# cert.pem / key.pem symlinks at the top of this tree, so CoreDNS
# sees stable filenames regardless of hostname. The /accounts dir
# (ACME registration private key) is sibling to /caddy and is NOT
# exposed to CoreDNS — only /caddy is mounted.
- ./caddy-data/caddy:/etc/coredns/certs:ro
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s