Traefik vs Nginx vs Caddy: Choosing a Reverse Proxy for Docker Hosts
Compare automatic service discovery, configuration models, and HTTPS handling in Traefik, Nginx, and Caddy for containerized deployments.
Choosing a reverse proxy is one of the first architectural decisions you make when containerizing services. The three dominant choices are Traefik (automatic Docker discovery), Nginx (the reliable classic), and Caddy (HTTPS-first simplicity). Each solves the same problem — routing external traffic to containerized services — but in fundamentally different ways. Understanding the tradeoffs will save you months of operational friction.
This guide walks through how each proxy works, shows real docker-compose examples for all three, compares their middleware capabilities, and helps you pick the right one for your team’s constraints.
The Three Models: Discovery, Config, and Automation
Nginx is the incumbent. It reads a configuration file from disk. You maintain nginx.conf or compose multiple .conf files in /etc/nginx/conf.d/, and reload the server when you change them. It’s a static, pull-based model: you tell Nginx about your services, then update the config when services change.
Traefik inverts this. It watches Docker events in real time. You declare routing rules as labels on each container, and Traefik automatically updates its internal routing table when services start, stop, or restart. No config file parsing, no manual reloads. This is a dynamic, push-based model: services declare themselves, and Traefik reacts.
Caddy splits the difference. It reads a configuration file like Nginx, but it’s simpler — a few lines instead of complex nested blocks — and it automatically provisions HTTPS certificates without you having to think about Let’s Encrypt. It’s easier to learn than Nginx, but still requires you to define routes upfront.
Traefik: Docker Native, No Config Files
Traefik shines on a single Docker host where services are born and die frequently. You declare routing rules as container labels, and Traefik discovers them via the Docker socket.
# docker-compose.yml with Traefik
version: "3.9"
services:
traefik:
image: traefik:v2.11
container_name: traefik
command:
- "--api.insecure=false"
- "--api.dashboard=true"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.web.address=:80"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--certificatesresolvers.cloudflare.acme.dnschallenge.provider=cloudflare"
- "--certificatesresolvers.cloudflare.acme.dnschallenge.resolvers=1.1.1.1:53"
- "[email protected]"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./letsencrypt:/letsencrypt
environment:
- CLOUDFLARE_DNS_API_TOKEN=your-cloudflare-token
api:
image: myapp:latest
container_name: api
expose:
- 3000
labels:
- "traefik.enable=true"
- "traefik.http.routers.api.rule=Host(`api.example.com`)"
- "traefik.http.routers.api.entrypoints=websecure"
- "traefik.http.routers.api.tls.certresolver=cloudflare"
- "traefik.http.services.api.loadbalancer.server.port=3000"
- "traefik.http.middlewares.api-auth.basicauth.users=admin:$$apr1$$..."
web:
image: nginx:latest
container_name: web
expose:
- 80
labels:
- "traefik.enable=true"
- "traefik.http.routers.web.rule=Host(`example.com`) || Host(`www.example.com`)"
- "traefik.http.routers.web.entrypoints=websecure"
- "traefik.http.routers.web.tls.certresolver=cloudflare"
- "traefik.http.services.web.loadbalancer.server.port=80"
Key advantages:
- No reload required — services register themselves on startup
- Middleware as labels — basic auth, rate limiting, header manipulation all declared per-service
- Built-in metrics — integrates with Prometheus for dashboards
- DNS-01 Let’s Encrypt — works behind firewalls (no need to expose HTTP-01)
- Dashboard — real-time routing overview at
https://traefik.example.com
Trade-offs:
- Docker socket access — a compromised Traefik container can start/stop other services
- Requires learning label syntax — not intuitive for ops teams familiar with Nginx
- Stateful — TLS certificates stored in a volume that must survive container restarts
Nginx: The Proven Classic
Nginx remains the workhorse for a reason: it’s fast, well-understood, and battle-tested on millions of servers. The tradeoff is operational friction: any service change requires a config update and reload.
# docker-compose.yml with Nginx
version: "3.9"
services:
nginx:
image: nginx:latest
container_name: nginx
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./conf.d:/etc/nginx/conf.d:ro
- ./letsencrypt:/etc/letsencrypt:ro
networks:
- app
api:
image: myapp:latest
container_name: api
expose:
- 3000
networks:
- app
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
web:
image: nginx:latest
container_name: web
expose:
- 80
networks:
- app
networks:
app:
driver: bridge
# nginx.conf snippet
upstream api_backend {
least_conn;
server api:3000;
server api:3000; # for redundancy if running multiple replicas
keepalive 32;
}
upstream web_backend {
server web:80;
}
server {
listen 80;
server_name api.example.com;
location / {
proxy_pass http://api_backend;
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 https;
}
}
server {
listen 80;
server_name example.com www.example.com;
location / {
proxy_pass http://web_backend;
proxy_set_header Host $host;
}
}
Key advantages:
- No vendor lock-in — same config syntax everywhere
- Minimal resource overhead — runs on embedded Linux, IoT devices
- Explicit configuration — every route is visible in one place
- Mature ecosystem — thousands of tutorials, modules, and best practices
- Load balancing algorithms — least_conn, round_robin, random, etc.
Trade-offs:
- Manual config management — every service addition requires an Nginx config update
- Reload downtime — brief moment of unavailability during
nginx -s reload - HTTPS complexity — you manage Let’s Encrypt renewal separately (certbot)
- Debugging — syntax errors prevent the whole proxy from starting
Caddy: HTTPS-First Simplicity
Caddy is newer and deliberately simpler. Its strength is zero-configuration HTTPS: you declare a domain, Caddy automatically provisions and renews TLS certificates from Let’s Encrypt without any manual steps.
# docker-compose.yml with Caddy
version: "3.9"
services:
caddy:
image: caddy:latest
container_name: caddy
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
networks:
- app
api:
image: myapp:latest
container_name: api
expose:
- 3000
networks:
- app
web:
image: nginx:latest
container_name: web
expose:
- 80
networks:
- app
networks:
app:
driver: bridge
volumes:
caddy_data:
caddy_config:
# Caddyfile (human-readable, no JSON/YAML)
api.example.com {
reverse_proxy api:3000
}
example.com, www.example.com {
reverse_proxy web:80
# Request rate limiting
rate_limit {
match /api/*
10r/s
}
# Basic authentication
basicauth /admin/* {
admin JDJhJDExJHZHUFF...
}
}
# Redirect HTTP to HTTPS (automatic)
Key advantages:
- Zero-config HTTPS — declare a domain, HTTPS just works
- Caddyfile is readable — easier than nginx.conf for simple setups
- Dynamic configuration — reload without downtime in recent versions
- Built-in file serving — static files, SPAs, documentation
- Middleware is simple — rate limiting, auth, compression with one line
Trade-offs:
- Smaller ecosystem — fewer plugins and integrations vs Nginx
- Memory overhead — slightly heavier than Nginx for the same job
- Less familiar — fewer engineers know Caddy deeply
- JSON API required for advanced routing — Caddyfile has limits
HTTPS Provisioning: DNS-01 vs HTTP-01
All three proxies integrate with Let’s Encrypt, but they approach certificate renewal differently.
HTTP-01 challenge: Traefik, Nginx, and Caddy can all do this. Let’s Encrypt sends an HTTP request to example.com/.well-known/acme-challenge/<token> and verifies you control the domain by having the proxy respond with the token.
Requirement: the domain must be publicly accessible on port 80. This works for most internet-facing services, but fails if your domain is behind a firewall, a VPN, or internal DNS.
DNS-01 challenge: Traefik and Caddy support this (Nginx doesn’t natively — you’d use certbot with a separate hook). Let’s Encrypt doesn’t connect to your server at all; instead, you prove domain ownership by adding a TXT record to your DNS.
Requirement: your DNS provider has an API and you provide credentials to the proxy. Works anywhere — firewalls, private networks, internal domains. This is why Traefik is popular on private infrastructure.
# Traefik DNS-01 with Cloudflare
- "--certificatesresolvers.cloudflare.acme.dnschallenge.provider=cloudflare"
- "[email protected]"
# Then set CLOUDFLARE_DNS_API_TOKEN environment variable
# Caddy DNS-01 with Cloudflare
tls {
dns cloudflare <api-token>
}
Middleware: Features Common and Unique
All three proxies support the essential middleware:
| Feature | Traefik | Nginx | Caddy |
|---|---|---|---|
| Basic Auth | Labels | auth_basic | basicauth |
| Rate Limiting | RateLimit | limit_req | rate_limit |
| Request/Response Headers | Headers | add_header / proxy_set_header | header |
| Compression (gzip) | Compress | gzip on | encode gzip |
| TLS/HTTPS | Automatic | Manual cert mgmt | Automatic Let’s Encrypt |
| Redirects | Redirects | return 301 | redir |
| Request Path Rewrite | ReplacePathRegex | rewrite | uri manipulation |
Example: rate limiting on Traefik with labels.
labels:
- "traefik.http.middlewares.limit.ratelimit.average=100"
- "traefik.http.middlewares.limit.ratelimit.burst=200"
- "traefik.http.routers.api.middlewares=limit"
Observability: Logs, Metrics, Dashboards
Traefik exports Prometheus metrics natively and has a built-in dashboard. Query metrics like traefik_router_requests_total and traefik_service_request_duration_seconds in Prometheus.
Nginx requires sidecar exporters (nginx-prometheus-exporter) to generate metrics. Access logs are in plaintext or JSON; you ship them to Loki or ELK via Promtail or Filebeat.
Caddy exports metrics if you enable the /metrics endpoint. Like Nginx, you’ll want Promtail or a log forwarder to centralize access logs.
# Traefik access logs (JSON format, easy to parse)
docker logs traefik | jq '.request_Host' | sort | uniq -c
# Nginx access logs (configurable format)
docker logs nginx | awk '{print $1}' | sort | uniq -c # IP counts
# Caddy access logs
docker logs caddy | grep "http\." | jq '.status' | sort | uniq -c
How to Choose
Choose Traefik if:
- You’re running many services on one Docker host and services change frequently
- You want zero-configuration HTTPS on private/internal domains (DNS-01)
- You’re comfortable with the Docker socket security model
- Your team is willing to learn label-based configuration
Choose Nginx if:
- You have a stable set of services that rarely change
- You need maximum performance and minimal resource use
- Your team is already fluent in Nginx
- You prefer explicit, versioned configuration over dynamic discovery
- You’re running on multiple hosts and need consistency (same Nginx config across machines)
Choose Caddy if:
- You want simplicity and readability above all else
- HTTPS-first architecture matters (zero-config Let’s Encrypt)
- Your team prefers a minimal learning curve
- You’re OK with slightly higher memory overhead for operational ease
- You’re building a greenfield project, not maintaining legacy Nginx configs
Migration Path
You can run all three on the same host if needed. Traefik on port 443 for dynamic services, Nginx on 8080 for static sites, Caddy on 9443 for internal tools. Use an external load balancer (HAProxy, cloud LB) to route traffic to the appropriate proxy.
But in practice: pick one, get proficient with it, and revisit the choice only if you hit its explicit limits. Switching proxies mid-flight is tedious and error-prone.
Key Takeaways
- Traefik is Docker-native — automatic discovery via labels, best for frequent service changes
- Nginx is the proven classic — explicit config, best for stable services and consistent deployments
- Caddy prioritizes simplicity — readable Caddyfile, zero-config HTTPS, good for small teams
- DNS-01 challenges work behind firewalls — Traefik and Caddy support them; Nginx requires certbot
- Middleware feature parity is high — basic auth, rate limiting, headers are standard everywhere
- Nginx has the largest ecosystem — most tutorials, modules, and community support
- Caddy is the easiest to learn — smallest config burden, best for operators new to reverse proxies