We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
Everything you need to know to set up label-based reverse proxying with caddy-docker-proxy on a Docker Compose VPS — syntax, patterns, debugging, and security.
caddy-docker-proxy is a custom build of Caddy that includes a module for watching Docker container metadata. Instead of writing a Caddyfile, you define routing rules as Docker labels on your application containers. Caddy watches the Docker socket, builds a Caddyfile in memory from those labels, and reloads itself with zero downtime whenever containers start or stop.
It’s like Traefik’s label-based routing, but with Caddy’s simplicity and automatic HTTPS.
Docker Hub image: lucaslorentz/caddy-docker-proxy
Current stable version: 2.12 (bundles Caddy 2.11.2)
Recommended tag: 2.12-alpine (pinned minor, Alpine base for shell access)
The translation from Docker labels to Caddyfile is mechanical. Dots in label keys create nested blocks:
| Docker Label | Generated Caddyfile |
|---|---|
caddy: example.com |
example.com { |
caddy.reverse_proxy: "{{upstreams 4000}}" |
reverse_proxy <ip>:4000 |
caddy.encode: gzip zstd |
encode gzip zstd |
caddy.header: Strict-Transport-Security "max-age=31536000" |
header Strict-Transport-Security "max-age=31536000" |
caddy.tls: internal |
tls internal |
caddy.log: (empty value) |
log |
{{upstreams}} Template
{{upstreams}} is a Go template that resolves to the container’s IP address on the ingress network. You can specify a port:
caddy.reverse_proxy: "{{upstreams 4000}}" # proxy to port 4000
caddy.reverse_proxy: "{{upstreams}}" # proxy to port 80 (default)
caddy.reverse_proxy: "{{upstreams https 443}}" # proxy with HTTPS scheme
You never hardcode container IPs — {{upstreams}} handles it.
YAML doesn’t allow duplicate keys. This silently breaks:
# BROKEN — second "caddy" key overwrites the first
labels:
caddy: example.com
caddy: www.example.com
Use numeric suffixes _0, _1, _2, etc. to create separate, isolated site blocks:
labels:
caddy_0: example.com
caddy_0.reverse_proxy: "{{upstreams 4000}}"
caddy_1: www.example.com
caddy_1.redir: "https://example.com{uri} permanent"
This generates:
example.com {
reverse_proxy 172.20.0.3:4000
}
www.example.com {
redir https://example.com{uri} permanent
}
Each numbered prefix creates a completely independent block. Labels under caddy_0 never leak into caddy_1.
Within a site block, caddy-docker-proxy sorts directives alphabetically. If order matters (e.g., handle before handle_path), use numeric prefixes 0_, 1_ on the label keys:
labels:
caddy: example.com
caddy.0_handle_path: /api/*
caddy.0_handle_path.0_reverse_proxy: "{{upstreams 4000}}"
caddy.1_handle: /*
caddy.1_handle.0_reverse_proxy: "{{upstreams 3000}}"
The numeric prefix controls ordering; it’s stripped from the generated Caddyfile.
Set global Caddy options (like the Let’s Encrypt email) on any container — typically the caddy-docker-proxy container itself:
labels:
caddy.email: ops@example.com
This generates the global options block:
{
email ops@example.com
}
TLS is automatic — just like standard Caddy. Any domain that appears in a caddy: label gets a certificate from Let’s Encrypt (or ZeroSSL) without any configuration.
# Use Let's Encrypt staging (for testing — avoids rate limits)
labels:
caddy: example.com
caddy.tls.ca: https://acme-staging-v02.api.letsencrypt.org/directory
# Self-signed internal certificate (for private services)
labels:
caddy: internal.example.com
caddy.tls: internal
# Force a specific email for this domain
labels:
caddy: example.com
caddy.tls: you@example.com
Let’s Encrypt allows 5 duplicate certificates per domain per week. During initial setup, use the staging CA to avoid burning through this limit. Remove the caddy.tls.ca label once verified.
docker network create caddy
This is a one-time operation. All Compose stacks reference this network as external: true.
# /srv/caddy/docker-compose.yml
services:
caddy:
image: lucaslorentz/caddy-docker-proxy:2.12-alpine
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443/tcp"
- "443:443/udp"
environment:
- CADDY_INGRESS_NETWORKS=caddy
networks:
- caddy
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- caddy_data:/data
- caddy_config:/config
networks:
caddy:
external: true
volumes:
caddy_data:
caddy_config:
cd /srv/caddy
docker compose up -d
Each application has its own Compose file in its own directory. They just need to:
caddy network # /srv/myapp/docker-compose.yml
services:
web:
image: myapp:latest
networks:
- caddy
labels:
caddy: myapp.example.com
caddy.reverse_proxy: "{{upstreams 3000}}"
networks:
caddy:
external: true
caddy-docker-proxy detects the new container and configures routing automatically. No restart of Caddy needed.
labels:
caddy_0: example.com
caddy_0.reverse_proxy: "{{upstreams 4000}}"
caddy_1: www.example.com
caddy_1.redir: "https://example.com{uri} permanent"
Both domains get TLS certificates automatically.
labels:
caddy_0: www.example.com
caddy_0.reverse_proxy: "{{upstreams 4000}}"
caddy_1: example.com
caddy_1.redir: "https://www.example.com{uri} permanent"
labels:
caddy: api.example.com
caddy.reverse_proxy: "{{upstreams 4000}}"
caddy.header.Access-Control-Allow-Origin: "*"
caddy.header.Access-Control-Allow-Methods: "GET, POST, PUT, DELETE, OPTIONS"
caddy.header.Access-Control-Allow-Headers: "Authorization, Content-Type"
labels:
caddy: example.com
caddy.handle_path: /api/*
caddy.handle_path.0_reverse_proxy: "{{upstreams 4000}}"
If a service runs directly on the host (not in Docker), use extra_hosts on the caddy container:
services:
caddy:
extra_hosts:
- "host.docker.internal:host-gateway"
Then reference it in labels on another container, or configure it as a standalone snippet.
labels:
caddy: example.com
caddy.reverse_proxy: "{{upstreams 4000}}"
caddy.encode: gzip zstd
docker exec caddy cat /config/caddy/Caddyfile.autosave
This shows exactly what caddy-docker-proxy built from container labels, including resolved IP addresses.
docker logs caddy --tail=50 -f
docker inspect <container> --format '{{json .NetworkSettings.Networks}}' | jq
The container must be on the network specified in CADDY_INGRESS_NETWORKS.
| Symptom | Cause | Fix |
|---|---|---|
| 503 Service Unavailable | Container not on the caddy network |
Add networks: [caddy] to the service |
| No TLS / cert errors | DNS not pointing to VPS IP | Fix DNS A record, wait for propagation |
| Only last domain works |
Duplicate caddy: keys in YAML |
Use caddy_0, caddy_1 suffixes |
| Directives in wrong order | Alphabetical sorting |
Use 0_, 1_ numeric prefixes |
| Caddy can’t start | Port 80/443 already in use | Stop the old reverse proxy first |
| Rate limited by Let’s Encrypt |
Lost caddy_data volume |
Use staging CA during testing |
caddy-docker-proxy needs access to /var/run/docker.sock. This gives it read access to all container metadata on the host. This is the same trade-off that Traefik makes.
Mitigation: use a Docker socket proxy like tecnativa/docker-socket-proxy to limit what caddy can see. For a single-tenant VPS, direct socket access is generally acceptable.
Only containers that need public-facing routing should join the caddy network. Databases, caches, and internal workers should stay on their own isolated networks:
services:
web:
networks:
- caddy # public-facing
- internal # private
db:
networks:
- internal # NOT on caddy — no external exposure
networks:
caddy:
external: true
internal:
driver: bridge
| Feature | Caddy (Caddyfile) | caddy-docker-proxy | Traefik |
|---|---|---|---|
| Config format | Caddyfile | Docker labels | Docker labels |
| Auto-HTTPS | Yes | Yes | Yes (via resolver config) |
| HTTP/3 | Yes | Yes | Yes |
| Learning curve | Low | Low (if you know Caddy) | Medium |
| Auto-discovery | No | Yes | Yes |
| API for config | Yes (admin API) | Yes (via labels) | Yes (dashboard + API) |
| Performance | Excellent | Excellent | Excellent |
| Ecosystem | Large | Caddy modules | Middleware plugins |
| Config debugging | Read the file |
Caddyfile.autosave |
Dashboard UI |
caddy-docker-proxy sits in a sweet spot: Caddy’s simplicity with Traefik’s auto-discovery. If you’re already comfortable with Caddy, the transition is natural.