| |

Docker 79 🐳 Containerizing Web Servers and Reverse Proxies: Nginx, Caddy, and Traefik Dynamic Routing

⏱️ Estimated read: 16 min | |

A reverse proxy sits between the public internet and your application containers. It terminates TLS, routes requests by hostname or path, and forwards traffic to the appropriate backend. Containerizing it means the proxy itself becomes a service in the Compose stack, with its configuration mounted or baked into the image, and its lifecycle managed alongside the applications it fronts. The three tools in this chapter represent three philosophies. Nginx is the traditional, configuration-file-driven workhorse: powerful, explicit, and verbose. Caddy is the automatic-HTTPS successor: a single reverse_proxy directive obtains and renews certificates without any ACME configuration . Traefik is the dynamic, label-driven router: it watches the Docker socket, discovers containers as they start and stop, and builds its routing table from labels attached to those containers .

The distinction matters because the configuration model determines the operational model. An Nginx container needs its nginx.conf mounted from the host or baked into a custom image, and every new backend requires editing that file and reloading Nginx . A Caddy container needs a Caddyfile with a site block and a reverse_proxy line, and Caddy handles the certificate lifecycle automatically . A Traefik container needs no per-service configuration at all; the routing rules live as Docker labels on the application containers, and Traefik updates its configuration in real time as those containers start and stop .

Key point: Nginx uses a mounted configuration file and requires explicit routing rules for every backend; Caddy uses a Caddyfile with reverse_proxy and handles HTTPS automatically; Traefik uses Docker labels on the backend containers and discovers routes dynamically without editing the proxy’s own configuration.


Why the configuration model determines the architecture

The static-versus-dynamic problem. Nginx’s configuration is static. The nginx.conf file lists the upstream servers, the server names, and the location blocks, and Nginx reads it at startup. Adding a new backend means editing the file and reloading Nginx, which is a deliberate operation. This is not a flaw—it is a design for a proxy that fronts a fixed set of services and needs deterministic, auditable configuration . Traefik’s configuration is dynamic. The proxy’s own configuration file enables the Docker provider, and the routing rules come from labels on the containers. When a container starts with traefik.http.routers.myapp.rule=Host(\app.example.com`)`, Traefik reads the label, creates the router, and begins forwarding traffic without a reload .

The certificate problem. Nginx does not obtain certificates. It reads the certificate and key files from disk, and the operator is responsible for obtaining them, renewing them, and reloading Nginx when they change . Caddy obtains certificates automatically from Let’s Encrypt or ZeroSSL through the ACME protocol, and it renews them before they expire. A Caddyfile with a single site block and a reverse_proxy directive is enough; Caddy handles the HTTP-01 or TLS-ALPN-01 challenge, stores the certificate, and serves it . Traefik obtains certificates through its own ACME resolver, configured in the static configuration, and the router labels reference the resolver with tls.certresolver=letsencrypt .

The discovery problem. Nginx does not know about Docker containers. It knows about hostnames and IP addresses, and it forwards to whatever address the configuration says. The backend address can be a container name if the proxy and the backend are on the same Docker network, because Docker’s embedded DNS resolves the container name to its IP . But the proxy does not discover the backend; the operator writes the address. Traefik discovers the backend because it watches the Docker socket. The container’s labels tell Traefik the hostname, the port, and the entrypoint, and Traefik builds the route from those labels . Caddy with the caddy-docker-proxy plugin does the same thing: the labels on the container become the site blocks and the reverse_proxy directives in the generated Caddyfile .

The security problem. Traefik needs access to the Docker socket to discover containers. The socket is a root-level interface, and mounting it into the Traefik container gives Traefik full control over Docker. The recommended mitigation is a Docker socket proxy that limits Traefik to the read-only endpoints it needs . Nginx does not need the socket, because it does not discover containers. Caddy without the Docker proxy plugin does not need the socket either. The dynamic model buys convenience at the cost of a privileged interface.


a. Nginx: the mounted configuration and the explicit route

The Nginx container needs two mounts: the configuration file and the certificate directory. The configuration file is mounted read-only into /etc/nginx/conf.d/default.conf, and the certificates are mounted read-only into a directory the configuration references .

# The Nginx reverse proxy service
services:
  nginx:
    # Use the Alpine image; it is small and sufficient for a proxy
    image: nginx:1.27-alpine
    container_name: nginx-proxy
    # The ports are the public entry points
    # 80 is HTTP, 443 is HTTPS
    ports:
      - "80:80"
      - "443:443"
    # The configuration file is mounted read-only
    # The :ro prevents the container from modifying it
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
      # The certificates are mounted read-only
      - ./certs:/etc/nginx/tls:ro
    # The proxy depends on the backend services
    # The depends_on does not wait for readiness; it only orders startup
    depends_on:
      - app
    # The restart policy keeps the proxy running after a crash
    restart: unless-stopped
    # The proxy must be on the same network as the backend
    networks:
      - web

  app:
    # The backend application
    image: myapp:latest
    # The backend does not expose a host port
    # Only the proxy is reachable from outside
    expose:
      - "8080"
    networks:
      - web

networks:
  web:

The nginx.conf file is where the routing lives. A minimal configuration that forwards a hostname to the backend and terminates TLS looks like this:

# The upstream block names the backend
# The server address is the container name and its internal port
# Docker's DNS resolves "app" to the container's IP
upstream app_backend {
    server app:8080;
}

# The HTTP server redirects to HTTPS
server {
    listen 80;
    server_name app.example.com;
    # The 301 is a permanent redirect
    return 301 https://$host$request_uri;
}

# The HTTPS server terminates TLS and proxies to the backend
server {
    listen 443 ssl;
    server_name app.example.com;

    # The certificate and key are the mounted files
    ssl_certificate /etc/nginx/tls/app.crt;
    ssl_certificate_key /etc/nginx/tls/app.key;

    # The location block matches all paths
    location / {
        # The proxy_pass forwards to the upstream
        proxy_pass http://app_backend;
        # The headers preserve the client's original information
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

The proxy_set_header lines are not optional. Without Host, the backend receives the container’s internal hostname instead of the public domain. Without X-Forwarded-For, the backend cannot see the client’s IP. Without X-Forwarded-Proto, the backend cannot tell whether the original request was HTTP or HTTPS . These headers are how the proxy communicates the request context to the application.

The certificate management is the operational burden. The app.crt and app.key files must exist in the certs directory, must be valid, and must be renewed before they expire. Nginx does not renew them. A cron job or a Certbot container must obtain the certificates and reload Nginx, which is a separate automation layer that the Caddy and Traefik models eliminate.


b. Caddy: the automatic HTTPS and the one-line proxy

Caddy’s configuration is a Caddyfile with a site address and a directive. The site address is the hostname, and the reverse_proxy directive points to the backend. Caddy obtains the certificate automatically .

# The Caddy reverse proxy service
services:
  caddy:
    # The official Caddy image
    image: caddy:2
    container_name: caddy
    # The ports are the same as Nginx
    ports:
      - "80:80"
      - "443:443"
    # The Caddyfile is mounted read-only
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      # The data volume stores the certificates
      # Caddy must persist this volume or it will re-obtain certificates on restart
      - caddy_data:/data
    depends_on:
      - app
    restart: unless-stopped
    networks:
      - web

  app:
    image: myapp:latest
    expose:
      - "8080"
    networks:
      - web

volumes:
  caddy_data:

networks:
  web:

The Caddyfile is four lines:

# The site address is the public hostname
# Caddy obtains the certificate automatically
app.example.com {
    # The reverse_proxy directive forwards to the backend
    # "app" is the container name, "8080" is the internal port
    # Caddy sets the Host and X-Forwarded-* headers automatically
    reverse_proxy app:8080
}

The reverse_proxy directive sets the Host and X-Forwarded-* headers automatically, so the backend receives the same context that the Nginx configuration sets explicitly . The caddy_data volume is the only persistence requirement, and it stores the certificates and the ACME account keys. Without the volume, Caddy re-obtains the certificates on every container restart, which hits the Let’s Encrypt rate limits.

The caddy-docker-proxy plugin extends the model from “one Caddyfile” to “labels on containers.” The plugin watches the Docker socket and generates the Caddyfile from the labels, so the application container defines its own route . This is the same dynamic model as Traefik, implemented as a Caddy plugin rather than as the core proxy. The caddy label creates a site block, and the caddy.reverse_proxy label creates the proxy directive .


c. Traefik: the Docker provider and the label-driven route

Traefik’s configuration is split into two parts: the static configuration, which enables the Docker provider and the entrypoints, and the dynamic configuration, which comes from the labels on the containers. The static configuration is mounted as a file or passed as command-line arguments.

# The Traefik reverse proxy service
services:
  traefik:
    # The official Traefik image
    image: traefik:v3.6
    container_name: traefik
    # The ports are the entrypoints
    ports:
      - "80:80"
      - "443:443"
      # The dashboard port is optional and should be secured in production
      - "8080:8080"
    # The Docker socket is mounted read-only
    # The :ro prevents Traefik from writing to the socket
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      # The static configuration is mounted
      - ./traefik.yml:/etc/traefik/traefik.yml:ro
    restart: unless-stopped
    networks:
      - web

  app:
    image: myapp:latest
    # The labels define the route
    # Traefik reads them when the container starts
    labels:
      # Enable Traefik for this container
      # Required when exposedByDefault is false
      - "traefik.enable=true"
      # The router rule matches the hostname
      - "traefik.http.routers.app.rule=Host(`app.example.com`)"
      # The entrypoint is the HTTPS entrypoint
      - "traefik.http.routers.app.entrypoints=websecure"
      # The TLS certificate resolver
      - "traefik.http.routers.app.tls.certresolver=letsencrypt"
      # The port Traefik forwards to
      # The container does not need to expose the port on the host
      - "traefik.http.services.app.loadbalancer.server.port=8080"
    # The container is on the same network as Traefik
    # This is how Traefik reaches the container
    networks:
      - web

networks:
  web:

The traefik.yml static configuration enables the Docker provider and the ACME resolver:

# The entrypoints are the ports Traefik listens on
entryPoints:
  web:
    address: ":80"
  websecure:
    address: ":443"

# The Docker provider watches the socket
providers:
  docker:
    # The socket endpoint
    endpoint: "unix:///var/run/docker.sock"
    # Do not expose containers by default
    # Each container must opt in with traefik.enable=true
    exposedByDefault: false
    # The network Traefik uses to reach containers
    network: "web"

# The ACME resolver obtains certificates
certificatesResolvers:
  letsencrypt:
    acme:
      # The email for the ACME account
      email: admin@example.com
      # The storage file for the certificates
      storage: /etc/traefik/acme.json
      # The HTTP challenge
      httpChallenge:
        entryPoint: web

The traefik.enable=true label is the opt-in. With exposedByDefault: false, a container without the label is ignored, which means a container that is not meant to be public cannot be exposed by accident . The traefik.http.services.app.loadbalancer.server.port=8080 label tells Traefik which internal port to use. Traefik can detect the port from the container’s EXPOSE directive, but the label is the explicit and reliable option when the container exposes multiple ports or no ports .

The dynamic model means that adding a second backend is a label change on that backend, not a change to Traefik’s configuration. The new container starts, Traefik reads its labels, creates the router and the service, and begins routing. No reload, no restart, no configuration file edit.


Complete Example Session

The following session traces the deployment of all three proxies in front of the same backend, and the verification that each one routes correctly.

# ============================================
# PART 1: THE NGINX STACK
# ============================================
# docker-compose.nginx.yml
services:
  nginx:
    image: nginx:1.27-alpine
    ports: ["80:80", "443:443"]
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./certs:/etc/nginx/tls:ro
    depends_on: [app]
    networks: [web]
  app:
    image: myapp:latest
    expose: ["8080"]
    networks: [web]
networks:
  web:
# ============================================
# PART 2: VERIFY NGINX
# ============================================
# Start the stack
docker compose -f docker-compose.nginx.yml up -d

# Verify the HTTP redirect
curl -I http://app.example.com
# Expect: 301 Moved Permanently, Location: https://app.example.com

# Verify the HTTPS proxy
curl -I https://app.example.com
# Expect: 200 OK with the backend's headers
# ============================================
# PART 3: THE CADDY STACK
# ============================================
# docker-compose.caddy.yml
services:
  caddy:
    image: caddy:2
    ports: ["80:80", "443:443"]
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
    depends_on: [app]
    networks: [web]
  app:
    image: myapp:latest
    expose: ["8080"]
    networks: [web]
volumes:
  caddy_data:
networks:
  web:
# ============================================
# PART 4: VERIFY CADDY
# ============================================
# Start the stack
docker compose -f docker-compose.caddy.yml up -d

# Check the Caddy logs for certificate issuance
docker logs caddy
# Expect: "certificate obtained successfully" for app.example.com

# Verify the proxy
curl -I https://app.example.com
# Expect: 200 OK
# ============================================
# PART 5: THE TRAEFIK STACK
# ============================================
# docker-compose.traefik.yml
services:
  traefik:
    image: traefik:v3.6
    ports: ["80:80", "443:443"]
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik.yml:/etc/traefik/traefik.yml:ro
    networks: [web]
  app:
    image: myapp:latest
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.app.rule=Host(`app.example.com`)"
      - "traefik.http.routers.app.entrypoints=websecure"
      - "traefik.http.routers.app.tls.certresolver=letsencrypt"
      - "traefik.http.services.app.loadbalancer.server.port=8080"
    networks: [web]
networks:
  web:
# ============================================
# PART 6: VERIFY TRAEFIK
# ============================================
# Start the stack
docker compose -f docker-compose.traefik.yml up -d

# Check the Traefik dashboard (if enabled)
# The dashboard shows the router and the service created from the labels

# Verify the proxy
curl -I https://app.example.com
# Expect: 200 OK

The six parts cover the Nginx stack and verification, the Caddy stack and verification, and the Traefik stack and verification. What the session demonstrates is that all three proxies front the same backend, but the configuration mechanism is different: a mounted file for Nginx, a Caddyfile for Caddy, and labels for Traefik.


Quick Reference

Configuration Models

ProxyConfigurationRoute LocationCertificate
Nginxnginx.confProxy config fileManual (mounted)
CaddyCaddyfileProxy config fileAutomatic
TraefikStatic + labelsBackend container labelsAutomatic (resolver)

Key Directives and Labels

ProxyDirective/LabelPurpose
Nginxproxy_passForward to backend
Nginxproxy_set_headerPreserve client context
Caddyreverse_proxyForward to backend
Traefiktraefik.http.routers.<name>.ruleHostname or path rule
Traefiktraefik.http.services.<name>.loadbalancer.server.portBackend port

Socket Requirements

ProxyDocker SocketWhy
NginxNoStatic config
Caddy (core)NoCaddyfile
Caddy (docker-proxy)YesLabel discovery
TraefikYesContainer discovery

Certificate Handling

ProxyObtained ByRenewed ByStored In
NginxOperator (Certbot)OperatorMounted files
CaddyCaddyCaddycaddy_data volume
TraefikTraefik ACMETraefikacme.json

Best Practices

✅ Do This:

# Nginx: mount the config and certs read-only
volumes:
  - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
  - ./certs:/etc/nginx/tls:ro

# Caddy: persist the data volume for certificates
volumes:
  - caddy_data:/data

# Traefik: opt in with the enable label
labels:
  - "traefik.enable=true"
  - "traefik.http.routers.app.rule=Host(`app.example.com`)"

❌ Don’t Do This:

# Nginx: expose the backend directly
ports:
  - "8080:8080" # bypasses the proxy

# Caddy: omit the data volume
# Certificates are re-obtained on every restart

# Traefik: expose the socket writable
volumes:
  - /var/run/docker.sock:/var/run/docker.sock # no :ro

Common Pitfalls

PitfallWhy It HappensFix
Backend unreachableDifferent Docker networksPut both on the same network
Certificate renewal failsNginx not reloadedAutomate reload after renewal
Caddy re-obtains certificatesNo caddy_data volumePersist the volume
Traefik ignores a containerexposedByDefault: falseAdd traefik.enable=true
Traefik routes to wrong portMultiple exposed portsAdd the loadbalancer.server.port label

Real-World Examples

1. Nginx proxy_pass

location / {
    proxy_pass http://app:8080;
}

2. Nginx forwarded headers

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

3. Caddy reverse proxy

app.example.com {
    reverse_proxy app:8080
}

4. Caddy Docker proxy label

labels:
  - "caddy=app.example.com"
  - "caddy.reverse_proxy={{upstreams 8080}}"

5. Traefik router rule

- "traefik.http.routers.app.rule=Host(`app.example.com`)"

6. Traefik service port

- "traefik.http.services.app.loadbalancer.server.port=8080"

7. Traefik TLS resolver

- "traefik.http.routers.app.tls.certresolver=letsencrypt"

8. Traefik opt-in

- "traefik.enable=true"

9. Nginx HTTPS server

listen 443 ssl;
ssl_certificate /etc/nginx/tls/app.crt;

10. Shared network

networks:
  web:

Visual

Nginx Static Configuration

┌──────────────────────────────────────────────┐
│  nginx.conf (mounted)                        │
│  upstream app { server app:8080; }           │
│  server { listen 443; proxy_pass app; }      │
│         │                                    │
│         ▼                                    │
│  Nginx reads the file at startup             │
│  Adding a backend = editing the file         │
└──────────────────────────────────────────────┘

Caddy Automatic HTTPS

┌──────────────────────────────────────────────┐
│  Caddyfile                                   │
│  app.example.com { reverse_proxy app:8080 }  │
│         │                                    │
│         ▼                                    │
│  Caddy obtains certificate from Let's Encrypt│
│  Caddy renews before expiry                  │
│  Caddy stores in caddy_data volume           │
└──────────────────────────────────────────────┘

Traefik Label Discovery

┌──────────────────────────────────────────────┐
│  app container starts                        │
│         │                                    │
│         ▼                                    │
│  Traefik reads labels from the container      │
│  traefik.enable=true                         │
│  traefik.http.routers.app.rule=Host(...)     │
│         │                                    │
│         ▼                                    │
│  Traefik creates router and service          │
│  No config file edit, no reload              │
└──────────────────────────────────────────────┘

Summary

ItemValue
Nginx configMounted nginx.conf
Nginx TLSManual certificate files
Caddy configMounted Caddyfile
Caddy TLSAutomatic (ACME)
Traefik configStatic + Docker labels
Traefik TLSACME resolver
Traefik discoveryDocker socket
Backend addressContainer name on shared network

Key takeaways:

  • Nginx is static and explicit. The configuration file lists every route, and every route change is a file edit and a reload. The certificate and key are mounted files, and the operator is responsible for obtaining and renewing them .
  • Caddy is automatic. The Caddyfile is a site block with a reverse_proxy directive, and Caddy obtains and renews the certificate without any ACME configuration. The caddy_data volume is the only persistence requirement .
  • Traefik is dynamic. The proxy’s own configuration enables the Docker provider and the entrypoints, and the routing rules come from labels on the backend containers. Adding a backend is a label change on that backend, not a change to the proxy .
  • The shared Docker network is the connectivity mechanism. All three proxies reach the backend by its container name on the same network. Without the shared network, the proxy cannot resolve the backend’s address .
  • The Docker socket is a privileged interface. Traefik and the Caddy Docker proxy need it to discover containers, and mounting it read-only is the minimum mitigation. Nginx does not need it .

Remember: The choice between Nginx, Caddy, and Traefik is a choice about how much of the routing you want to write down in advance. Nginx makes you write every route and manage every certificate, which is the right choice when the routes are fixed and the audit trail matters. Caddy writes the certificate management for you, which is the right choice when the routes are fixed but the TLS lifecycle is the burden. Traefik writes the routing from the containers themselves, which is the right choice when the set of backends changes and the proxy should follow. The proxy is the entry point, and the configuration model determines whether that entry point is a document you maintain or a discovery mechanism you trust.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!