Wires Caddy as the ACME client side of our new self-hosted DNS-01 flow. Proves the design end-to-end: caddy-dns/rfc2136 -> our CoreDNS rfc2136 plugin -> zone file write -> git auto-commit -> HE AXFR -> LE validates -> cert issued. Changes: - caddy/Dockerfile: --with github.com/caddy-dns/rfc2136 added alongside the existing caddy-dns/vultr. - caddy/Caddyfile: new test-rfc2136.supported.systems site that uses the new provider. server coredns:53 (docker internal), key from env, propagation_delay 60s + timeout 600s to accommodate HE pull. - docker-compose.yml: ACME_TSIG_SECRET passed to the caddy container (the same secret CoreDNS verifies on the other side of the loop). First cert issued in production: 2026-05-21 ~13:23 UTC. ~5.5 min end-to-end from Caddy starting to cert in hand. Documented in session notes; the cert sits unused in caddy-data/ until/unless something publishes ports 80/443 for that hostname.
104 lines
4.7 KiB
YAML
104 lines
4.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}
|
|
# Used by caddy-dns/rfc2136 for the test-rfc2136 site -- same
|
|
# secret CoreDNS's rfc2136 plugin verifies on the other side.
|
|
- ACME_TSIG_SECRET=${ACME_TSIG_SECRET}
|
|
# Optional: only required for Caddy's DNS-01 cert renewal via Vultr's
|
|
# API. Cert is valid ~90 days; this env var only matters within the
|
|
# final 30d renewal window. Empty default keeps `docker compose up`
|
|
# working without the key in scope. Set it when renewal is imminent,
|
|
# OR migrate Caddy to caddy-dns/rfc2136 (via our plugin) and retire
|
|
# the Vultr dependency entirely.
|
|
- VULTR_API_KEY=${VULTR_API_KEY:-}
|
|
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:
|
|
# Custom build with the rfc2136 plugin baked in. The image tag is
|
|
# CalVer (set in .env COREDNS_IMAGE_TAG) so we can pin specific
|
|
# builds; `docker compose build coredns` produces the locally-tagged
|
|
# image, then up -d picks it up.
|
|
build:
|
|
context: .
|
|
dockerfile: coredns/Dockerfile
|
|
image: coredns-rfc2136:${COREDNS_IMAGE_TAG}
|
|
container_name: coredns
|
|
restart: unless-stopped
|
|
# Run as host's primary user so files the rfc2136 plugin writes to
|
|
# /zones land owned by rpm:rpm on the host. Without this they'd
|
|
# be root-owned, making manual edits / git ops painful.
|
|
#
|
|
# UID/GID come from env (defaulted to dell01's rpm: 1003:1004).
|
|
# Override in .env for hosts where rpm has different ids.
|
|
user: "${COREDNS_UID:-1003}:${COREDNS_GID:-1004}"
|
|
command: ["-conf", "/etc/coredns/Corefile"]
|
|
# The Corefile uses {$ACME_TSIG_SECRET} expansion to read the
|
|
# TSIG secret. Passed in from compose's env (which auto-reads .env).
|
|
environment:
|
|
- ACME_TSIG_SECRET=${ACME_TSIG_SECRET}
|
|
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
|
|
# Read-write because the rfc2136 plugin writes zone files in-place
|
|
# after each accepted UPDATE message (atomic temp-file + rename).
|
|
- ./zones:/zones
|
|
# 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
|
|
# CoreDNS's official image is distroless (no shell, no wget/curl), so
|
|
# the conventional `wget /health` healthcheck silently fails forever
|
|
# and Docker reports the container as unhealthy. The coredns binary
|
|
# itself supports a version flag, which exits 0 only if the binary
|
|
# is runnable — a thin but honest liveness probe. For deeper checks,
|
|
# query :8081/health from outside the container (curl from the host).
|
|
healthcheck:
|
|
test: ["CMD", "/coredns", "-version"]
|
|
interval: 30s
|
|
timeout: 5s
|
|
retries: 3
|
|
start_period: 10s
|