Caddy Docker Proxy cheatsheet

Label-driven reverse proxy for a multi-app homelab — no shared Caddyfile, zero-downtime cutover — Rose Pine theme

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.

The fix: caddy-docker-proxy reads reverse-proxy config from Docker container labels instead of a static file. Each app repo only ever edits its own docker-compose.yml — never a file another repo also touches.

Before / after

Static Caddyfilecaddy-docker-proxy
One file, N repos writing to itN repos, each owning only their own labels
CI must SCP + append + restart CaddyCI just recreates containers; Caddy notices
Removing a route means editing someone else's blockRemoving 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 proxy Docker 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"]
Swap the DNS plugin for whatever provider actually hosts your zone — caddy-dns has modules for most of the popular ones. The rest of this guide is provider-agnostic once the image is built.

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
Two env vars do the real work: 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!"
}
Bind-mounted, read-only. This file is mounted at container start — edit the host copy, the container sees it immediately (Caddy hot-reloads on file change). It coexists with every label-driven route; caddy-docker-proxy merges both sources.

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}}"
Without the _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}}"
Only one replica needs the full health-check block. caddy-docker-proxy merges all containers sharing a hostname into one upstream pool — the TLS/health config only needs to be declared once; the second replica just needs the matching hostname label to join the pool.

The force_ssl trap

Without 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.

This mostly matters when scripting deploys by hand. caddy-docker-proxy's own label-watching loop already handles the common recreate-a-container case correctly — this gotcha is for anyone tempted to add a manual caddy reload step "just to be safe."

Never use import in the Caddyfile

Only the one bind-mounted file is visible inside the container. Writing a second config file alongside the Caddyfile on the host (say, /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

The Caddyfile holds your DNS-challenge API token ({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

Everything above ran without touching production traffic. This is the one manual gate: a script that requires typing a literal confirmation word before proceeding is the difference between "verified on test ports" and "verified in production" — don't automate this step away.

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
If this step fails, roll back immediately: 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.