caddy-docker-proxy: A Practical Reference Guide

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.

iMORPHr · · 8 min read
caddy-docker-proxy: A Practical Reference Guide

What Is caddy-docker-proxy?

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)


How Labels Map to Caddyfile Directives

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

The {{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.


Multiple Site Blocks From One Container

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.


Controlling Directive Order

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.


Global Options

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 / Let’s Encrypt

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.

Customizing TLS Behaviour

# 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

Rate Limit Warning

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.


Complete Setup: Multi-Stack VPS

Step 1: Create the Shared Network

docker network create caddy

This is a one-time operation. All Compose stacks reference this network as external: true.

Step 2: Deploy caddy-docker-proxy

# /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

Step 3: Deploy Application Stacks

Each application has its own Compose file in its own directory. They just need to:

  1. Join the caddy network
  2. Add routing labels
# /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.


Common Patterns

Redirect www to Non-www

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.

Redirect Non-www to www

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"

API With CORS Headers

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"

Path-Based Routing

labels:
  caddy: example.com
  caddy.handle_path: /api/*
  caddy.handle_path.0_reverse_proxy: "{{upstreams 4000}}"

Proxy to a Host Service (Not a Container)

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.

Enable Compression

labels:
  caddy: example.com
  caddy.reverse_proxy: "{{upstreams 4000}}"
  caddy.encode: gzip zstd

Debugging

View the Generated Caddyfile

docker exec caddy cat /config/caddy/Caddyfile.autosave

This shows exactly what caddy-docker-proxy built from container labels, including resolved IP addresses.

Check Caddy Logs

docker logs caddy --tail=50 -f

Verify a Container Is on the Right Network

docker inspect <container> --format '{{json .NetworkSettings.Networks}}' | jq

The container must be on the network specified in CADDY_INGRESS_NETWORKS.

Common Issues

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

Security Considerations

Docker Socket Access

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.

Network Isolation

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

Comparison: Caddy vs caddy-docker-proxy vs Traefik

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.


Further Reading