Caddy reverse proxy Docker Compose architecture diagram showing HTTPS ingress and internal bridge routing
Diagram representing Caddy edge reverse proxy terminating TLS traffic and routing requests across Docker container networks.

How Caddy reverse proxy works in Docker Compose

Running a Caddy reverse proxy Docker Compose stack gives apps automatic HTTPS. The setup runs without cron jobs or certbot scripts. It works cleanly. Systems engineers use this stack to route edge traffic.

You can build a safe Compose file in minutes. This guide covers volume rules, network setups, and zero-downtime reloads. Modern microservices need strong encryption. They also keep database ports private.

The proxy server runs as a fast edge router. It resolves container names through internal Docker DNS. You save setup time and avoid cert expiry errors.

Production Docker Compose stack architecture

The edge service acts as the single public entry point on host ports 80 and 443. Downstream apps run on private bridge networks with no host port bindings. This layout stops leaks. Outside traffic cannot bypass proxy rules or security headers.

Below is a working Compose file. Top-level version keys are omitted. Compose rules drop them now. We include the NET_ADMIN capability so Go can optimize UDP socket buffers for fast HTTP/3 links.

services:
  caddy:
    image: caddy:2-alpine
    container_name: caddy-ingress
    restart: unless-stopped
    cap_add:
      - NET_ADMIN
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./caddy:/etc/caddy
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - edge-network

  api:
    image: traefik/whoami:latest
    container_name: api-service
    restart: unless-stopped
    networks:
      - edge-network

volumes:
  caddy_data:
    name: caddy_data
  caddy_config:
    name: caddy_config

networks:
  edge-network:
    name: edge-network
    driver: bridge

Review our analysis on caddy vs nginx reverse proxy to compare memory use across edge web servers.

Persistent volume rules and rate limits

Treating container storage as scratch space causes severe data loss. It is a very common Docker mistake. Caddy stores certificates and keys in the /data path. The official documentation warns that /data is not an ephemeral cache.

If you skip binding /data to a named volume, restarting your container drops all active certs. That wipes your keys. Then the new container asks Let's Encrypt for fresh certs on every host.

Repeating this loop will trigger the limit of 5 duplicate certs per week. The /config volume stores runtime state. It must stay persistent across restarts.

Directory bind mounts versus file mounts

Many guides mount a single file using syntax like ./Caddyfile:/etc/caddy/Caddyfile. In live setups, this single-file mount causes reload bugs due to file inode swaps.

Text editors write to a temp file first. They move that temp file over the old path. This atomic step creates a new inode on the host machine.

Docker file mounts stay pinned to the old inode from boot time. Stale data stays in the container. Edits do not take effect.

Mount the parent host directory into the container to avoid this bug. When Caddy reads /etc/caddy/Caddyfile inside a folder mount, it looks up the file through the directory table. It picks up edits right away.

Production Caddyfile reverse proxy rules

A solid Caddyfile defines global options, shared security headers, and upstream routes. Snippets save time. Using snippet blocks lets you apply clear rules to microservices without repeating lines.

{
    email ops@techwebmagazine.com
    admin localhost:2019
}

(security-headers) {
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "SAMEORIGIN"
        Referrer-Policy "strict-origin-when-cross-origin"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }
}

api.techwebmagazine.com {
    import security-headers
    encode zstd gzip

    reverse_proxy api-service:80 {
        header_up X-Real-IP {remote_host}
    }
}

The proxy supplies standard headers like Host, X-Forwarded-For, and X-Forwarded-Proto on its own. It handles them automatically. You can review our guide on hsts vs https redirect to evaluate browser transport rules. Read our analysis on tls 1.3 vs tls 1.2 for deeper transport tuning.

Multi-project Docker network setups

Your reverse proxy often runs in its own Compose project. Backend APIs and databases live in separate stacks. Containers in different projects cannot resolve peer names over Docker DNS without a shared bridge network.

Create a shared external bridge network on the host using standard CLI commands to link separate stacks safely.

docker network create edge-shared-bridge

Next, configure both Compose files to connect to this shared bridge as an external resource. This setup keeps projects tidy. The proxy routes traffic using container names.

Network Parameter Default Compose Network Shared External Bridge
Lifecycle Scope Tied to a single compose.yaml project Independent host resource across multiple stacks
Cross-Stack Resolution Isolated; containers cannot resolve peers Full DNS name resolution across separate stacks
Port Exposure Requires host port mapping for external access Internal container-to-container communication only
Teardown Risk Destroyed on docker compose down Persists safely when individual stacks restart

In your separate app stack, declare the external bridge inside its Compose file.

services:
  app-backend:
    image: mycompany/webapp:1.4
    container_name: production-backend
    restart: unless-stopped
    networks:
      - edge-shared-bridge

networks:
  edge-shared-bridge:
    external: true

Now the proxy forwards incoming client requests to production-backend:3000 without exposing port 3000 to the public web.

Zero-downtime configuration reloads and checks

Restarting a reverse proxy container drops active connections. It creates brief downtime. The server provides an administrative API to solve this issue. It supports atomic configuration reloads without process restarts.

Before applying a modified file, validate the syntax inside the container. This check catches formatting errors early.

docker compose exec -w /etc/caddy caddy caddy validate

If validation succeeds, execute an atomic configuration reload. The proxy handles this step smoothly.

docker compose exec -w /etc/caddy caddy caddy reload

During an atomic reload, the proxy starts the new listener side-by-side with the old one. If the new config fails to bind, the server rolls back cleanly. It does this without dropping an active client link. Old requests finish safely on the old worker. Meanwhile, new requests use updated routing rules.

You can send a POSIX signal to the container process for automated pipelines. This signal triggers a configuration reload.

docker compose kill -s SIGUSR1 caddy

Query the internal administrative endpoint to inspect active upstreams dynamically. Use the container built-in client for this lookup.

docker compose exec caddy wget -qO- http://localhost:2019/reverse_proxy/upstreams

Read our breakdown of http3 vs http2 to understand QUIC transport performance over UDP sockets for broader protocol comparisons.

Troubleshooting common Docker proxy bugs

Issues usually stem from DNS isolation, firewall rules, or certificate handshakes when debugging edge container deployments. Review this checklist to identify root causes quickly.

Frequently asked questions

Why must the Caddy data volume be persistent in Docker?

The proxy stores web certificates in /data. If you wipe the folder, Caddy requests new certificates on every boot. This mistake burns your Let's Encrypt rate limit.

Why should you mount the config folder instead of one file?

Text editors make a new inode when saving a file. Mounting one file keeps Docker locked to the old inode. Mounting the parent folder fixes this bug.

How do you reload Caddy in Docker without downtime?

Run docker compose exec caddy reload inside the container. You can also send a SIGUSR1 kill signal to the process.

Sources and technical documentation