Docker 79 🐳 Containerizing Web Servers and Reverse Proxies: Nginx, Caddy, and Traefik Dynamic Routing
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
| Proxy | Configuration | Route Location | Certificate |
|---|---|---|---|
| Nginx | nginx.conf | Proxy config file | Manual (mounted) |
| Caddy | Caddyfile | Proxy config file | Automatic |
| Traefik | Static + labels | Backend container labels | Automatic (resolver) |
Key Directives and Labels
| Proxy | Directive/Label | Purpose |
|---|---|---|
| Nginx | proxy_pass | Forward to backend |
| Nginx | proxy_set_header | Preserve client context |
| Caddy | reverse_proxy | Forward to backend |
| Traefik | traefik.http.routers.<name>.rule | Hostname or path rule |
| Traefik | traefik.http.services.<name>.loadbalancer.server.port | Backend port |
Socket Requirements
| Proxy | Docker Socket | Why |
|---|---|---|
| Nginx | No | Static config |
| Caddy (core) | No | Caddyfile |
| Caddy (docker-proxy) | Yes | Label discovery |
| Traefik | Yes | Container discovery |
Certificate Handling
| Proxy | Obtained By | Renewed By | Stored In |
|---|---|---|---|
| Nginx | Operator (Certbot) | Operator | Mounted files |
| Caddy | Caddy | Caddy | caddy_data volume |
| Traefik | Traefik ACME | Traefik | acme.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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Backend unreachable | Different Docker networks | Put both on the same network |
| Certificate renewal fails | Nginx not reloaded | Automate reload after renewal |
| Caddy re-obtains certificates | No caddy_data volume | Persist the volume |
| Traefik ignores a container | exposedByDefault: false | Add traefik.enable=true |
| Traefik routes to wrong port | Multiple exposed ports | Add 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
| Item | Value |
|---|---|
| Nginx config | Mounted nginx.conf |
| Nginx TLS | Manual certificate files |
| Caddy config | Mounted Caddyfile |
| Caddy TLS | Automatic (ACME) |
| Traefik config | Static + Docker labels |
| Traefik TLS | ACME resolver |
| Traefik discovery | Docker socket |
| Backend address | Container 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
Caddyfileis a site block with areverse_proxydirective, and Caddy obtains and renews the certificate without any ACME configuration. Thecaddy_datavolume 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.
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!