▸ Why caddy-docker-proxy
A homelab running one app is easy: one Caddyfile, one reverse-proxy block. It stops being easy at app two. A single shared /opt/caddy/Caddyfile managed by one repo's CI means every other app's deploy pipeline has to SCP in, append its own block, and avoid clobbering everyone else's — fragile append logic, no clean way to remove an entry, and a merge conflict waiting to happen the day two deploys race.
docker-compose.yml — never a file another repo also touches.Before / after
| Static Caddyfile | caddy-docker-proxy |
|---|---|
| One file, N repos writing to it | N repos, each owning only their own labels |
| CI must SCP + append + restart Caddy | CI just recreates containers; Caddy notices |
| Removing a route means editing someone else's block | Removing a route means deleting your own labels |
▸ Architecture
┌─────────────────────────┐ │ caddy-docker-proxy │ │ (listens 80/443) │ │ reads container labels │ │ + fallback Caddyfile │ └────────┬────────────────┘ │ proxy network ┌──────────────┼──────────────────┐ │ │ │ app-a:4000 app-b-1/2:4000 (future apps)
- One container (
caddy-proxy) mounts the Docker socket, watches for label changes, and rewrites its routing table live — no restart needed when an app's labels change. - Every app container joins a shared, externally-created
proxyDocker network so Caddy can reach it by service name. - A bind-mounted fallback Caddyfile still exists for routes that aren't backed by any container (a bare landing page at the root domain, say).
▸ Custom image
The upstream caddy-docker-proxy image doesn't bundle a DNS-01 challenge provider, so a custom build via xcaddy is needed to add one (Cloudflare, in this example).
ARG CADDY_VERSION=2.11.2 FROM caddy:${CADDY_VERSION}-builder AS builder RUN xcaddy build ${CADDY_VERSION} \ --with github.com/lucaslorentz/caddy-docker-proxy/plugin/v2 \ --with github.com/caddy-dns/cloudflare FROM caddy:${CADDY_VERSION}-alpine COPY --from=builder /usr/bin/caddy /usr/bin/caddy CMD ["caddy", "docker-proxy"]
▸ Standalone compose service
Caddy lives as its own standalone compose stack on the Docker host — independent of any single app repo — so it survives every app's deploys untouched.
services: caddy-proxy: container_name: caddy-proxy build: . restart: unless-stopped ports: - "80:80" - "443:443" environment: CF_API_TOKEN: "${CF_API_TOKEN}" CADDY_DOCKER_CADDYFILE_PATH: "/etc/caddy/Caddyfile" CADDY_INGRESS_NETWORKS: "proxy" DOCKER_API_VERSION: "1.44" volumes: - /var/run/docker.sock:/var/run/docker.sock - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config networks: - proxy volumes: caddy_data: caddy_config: networks: proxy: external: true
CADDY_INGRESS_NETWORKS tells Caddy which Docker network to actually route traffic through (it may be attached to several); CADDY_DOCKER_CADDYFILE_PATH points it at the fallback file below.▸ Fallback Caddyfile
For routes with no backing container — a bare root-domain response, say:
example.com { tls { dns cloudflare {env.CF_API_TOKEN} } respond "Hello from the homelab!" }
▸ Basic labels (one hostname, one upstream)
The simplest case — a single-instance service, one hostname:
grafana: image: grafana/grafana:latest labels: caddy: grafana.example.com caddy.tls.dns: cloudflare {env.CF_API_TOKEN} caddy.reverse_proxy: "{{upstreams 3000}}" networks: - default - proxy
{{upstreams 3000}} is a caddy-docker-proxy template function — it resolves to this container's own address on port 3000, so you never hardcode an IP.▸ Multi-hostname labels on one container
A single service can expose more than one public hostname — a chat app and its companion API on different domains, both backed by the same container. Number the label groups (caddy_0, caddy_1, …) to define multiple independent routes:
app: labels: caddy_0: app.example.com caddy_0.tls.dns: cloudflare {env.CF_API_TOKEN} caddy_0.reverse_proxy: "{{upstreams 4000}}" caddy_1: api.example.com caddy_1.tls.dns: cloudflare {env.CF_API_TOKEN} caddy_1.reverse_proxy: "{{upstreams 4000}}"
_0/_1 numbering, a second bare caddy: label on the same container would just overwrite the first — labels are a flat key-value map, so caddy-docker-proxy needs the numbered-group convention to know these are two distinct route blocks rather than one being clobbered by the other.▸ Multi-upstream + health checks
Run two replicas of the same app, give them the same hostname label, and caddy-docker-proxy automatically load-balances across both — the basis of a zero-downtime rolling restart (take one down, Caddy routes everything to the other, bring it back, repeat for the second):
app-1: labels: caddy_0: app.example.com caddy_0.tls.dns: cloudflare {env.CF_API_TOKEN} caddy_0.reverse_proxy: "{{upstreams 4000}}" caddy_0.reverse_proxy.health_uri: /health caddy_0.reverse_proxy.health_interval: 5s caddy_0.reverse_proxy.health_timeout: 3s caddy_0.reverse_proxy.fail_duration: 15s caddy_0.reverse_proxy.health_headers.X-Forwarded-Proto: https app-2: labels: caddy_0: app.example.com caddy_0.reverse_proxy: "{{upstreams 4000}}"
▸ The force_ssl trap
health_headers.X-Forwarded-Proto: https, health checks silently take down every upstream. If the app framework enforces HTTPS (Phoenix's force_ssl, Rails' force_ssl, etc.), Caddy's health-check probe — a plain HTTP request with no forwarded-proto header — gets redirected (301). Caddy treats a 301 as "unhealthy," marks every upstream down, and starts returning 503 to all real traffic, not just the health check.The fix is one label: tell the app (via the forwarded header) that the health check's request, though technically HTTP, arrived over a proxy that terminates TLS — so it shouldn't force a redirect.
caddy_0.reverse_proxy.health_headers.X-Forwarded-Proto: https
This is the single most common way a caddy-docker-proxy deploy looks fine in every log line except one: every request 503s, and the only difference from a working deploy is this header.
▸ docker restart vs. caddy reload
docker restart caddy-proxy # correct — forces fresh DNS resolution caddy reload # wrong here — keeps stale upstream IPs if config text is unchanged
Caddy's own reload only re-parses config that actually changed. When an upstream container is recreated with a new internal IP but the same hostname label, the config text looks identical to Caddy — so a reload doesn't re-resolve the DNS, and traffic keeps hitting the dead container's old IP until the process fully restarts.
caddy reload step "just to be safe."▸ Never use import in the Caddyfile
/opt/caddy/app-extra) and referencing it with an import directive works when testing Caddy locally — but inside this container, nothing outside the single bind-mounted path exists. Caddy fails to find the imported file and crash-loops on startup.Keep the fallback Caddyfile a single, complete, self-contained file. Everything else belongs in container labels, not a second config file.
▸ Never dump the Caddyfile to CI logs
{env.CF_API_TOKEN} is a reference, but any debug step that cats the rendered config or echoes the resolved environment will print the real token into CI logs.# fine — targeted, no secret leaks ssh host "grep reverse_proxy /root/caddy/Caddyfile" # dangerous — dumps everything, including the token if it's ever inlined ssh host "cat /root/caddy/Caddyfile"
Use targeted grep for whatever you're actually debugging (a specific hostname, a specific directive) instead of dumping the whole file.
▸ What disappears from app CI
Before caddy-docker-proxy, every app's deploy pipeline needed its own Caddyfile-management step: SCP the file down, run some append/render logic, SCP it back up, then docker restart caddy. After the migration, an app's deploy is just:
docker compose pull docker compose up -d --force-recreate --no-build --remove-orphans
caddy-docker-proxy notices the recreated container and its labels on its own — no proxy-specific step in the app's pipeline at all. One less thing that can race with another app's deploy.
▸ Zero-downtime cutover procedure
Migrating an existing static-Caddyfile setup to caddy-docker-proxy without dropping production traffic, in seven phases:
1–2. Setup & build
# confirm the new proxy's infra files are already deployed to the host, then: ssh host "cd /root/caddy && docker compose build"
3. Bring it up on test ports first
# temporarily remap 80/443 -> 8080/8443 in docker-compose.yml, then: ssh host "cd /root/caddy && docker compose up -d" # wait ~90s for container discovery + ACME certificate issuance
Verify every route on the test port before touching production
curl -sk -o /dev/null -w '%{http_code}' \ --resolve 'app.example.com:8443:127.0.0.1' \ 'https://app.example.com:8443/health' # repeat per hostname — 200/302 = pass, anything else = fix before proceeding
4. Cutover gate — a confirmation prompt, not a rubber stamp
5. Production cutover
ssh host "docker stop caddy-old" ssh host "cd /root/caddy && docker compose down && sed -i 's/8080:80/80:80/;s/8443:443/443:443/' docker-compose.yml && docker compose up -d" # wait ~10s for TLS certificate issuance on the real ports
6. Verify production, for real this time
curl -sf -o /dev/null -w '%{http_code}' "https://app.example.com/health" # no --resolve trick needed now — this is the real public hostname
docker compose down && docker start caddy-old restores the previous static Caddyfile setup in seconds.7. Cleanup
ssh host "docker rm caddy-old" ssh host "rm -f /opt/caddy/Caddyfile"
▸ Troubleshooting checklist
Route not appearing at all
docker logs caddy-proxy --tail 50 # confirm the hostname label and reverse_proxy label both parsed without errors
Confirm the app container actually has the labels
docker inspect --format '{{json .Config.Labels}}' app-container | jq
Confirm the container is on the right network
docker network inspect proxy | jq '.[0].Containers'
Every request 503s
Check the force_ssl trap first — it's the single most common cause. Confirm with:
docker inspect --format '{{json .Config.Labels}}' app-container | jq '."caddy_0.reverse_proxy.health_headers.X-Forwarded-Proto"'
Route works on test ports but not production
Almost always a DNS record still pointing at the old static-Caddyfile container's IP, or a firewall rule scoped to the old container name. Re-check the cutover DNS/firewall assumptions before assuming Caddy itself is broken.