How to Set Up a Reverse Proxy for Docker Containers (Traefik, Nginx, Caddy)
You have Docker containers running web services on various ports: 3000, 8080, 5000, 9090. Users shouldn't need to remember port numbers. They should access app.yourdomain.com, not yourdomain.com:3000. That's what a reverse proxy does: it sits in front of your containers, routes traffic based on domain names (or paths), and handles SSL/TLS so every service gets HTTPS.
This guide covers the three most popular reverse proxy options for Docker: Traefik, Nginx (with Nginx Proxy Manager), and Caddy. We'll set up each one with Docker Compose, configure automatic SSL certificates, and show how to add new services.
Why You Need a Reverse Proxy
- SSL/TLS termination — automatic HTTPS with Let's Encrypt certificates
- Domain-based routing —
app.example.com,api.example.com,grafana.example.comall on one server - Single entry point — only ports 80 and 443 exposed, everything else internal
- Load balancing — distribute traffic across multiple container instances
- Security headers — HSTS, CSP, X-Frame-Options applied uniformly
- Rate limiting — protect services from abuse
Quick Comparison: Which Should You Choose?
| Feature | Traefik | Nginx Proxy Manager | Caddy |
|---|---|---|---|
| Docker auto-discovery | Yes (native) | No (manual config) | No (manual config) |
| Configuration style | Labels on containers | Web UI | Caddyfile (text) |
| Automatic HTTPS | Yes | Yes | Yes (simplest) |
| Web Dashboard | Yes | Yes (full management) | No |
| Learning Curve | Moderate | Low | Low |
| Performance | Excellent | Excellent | Very Good |
| RAM Usage | ~50-100 MB | ~150-300 MB | ~30-50 MB |
| Best For | Dynamic environments | Non-technical users | Simple, clean configs |
TL;DR: Use Traefik if you want auto-discovery (containers register themselves). Use Caddy if you want the simplest configuration syntax. Use Nginx Proxy Manager if you want a web UI for managing proxy rules.
Option 1: Traefik
Traefik is the most Docker-native reverse proxy. It watches the Docker socket for container events and automatically creates routing rules based on labels you add to your containers. When you start a new container with the right labels, Traefik picks it up and routes traffic to it. No config file edits, no restarts.
Step 1: Create the Traefik Docker Compose File
# docker-compose.yml (Traefik)
version: "3.8"
services:
traefik:
image: traefik:v3.0
container_name: traefik
restart: unless-stopped
command:
- "--api.dashboard=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
- "--certificatesresolvers.letsencrypt.acme.email=your-email@example.com"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
# Redirect all HTTP to HTTPS
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt:/letsencrypt
networks:
- proxy
labels:
# Dashboard
- "traefik.enable=true"
- "traefik.http.routers.dashboard.rule=Host(`traefik.example.com`)"
- "traefik.http.routers.dashboard.service=api@internal"
- "traefik.http.routers.dashboard.tls.certresolver=letsencrypt"
- "traefik.http.routers.dashboard.middlewares=auth"
- "traefik.http.middlewares.auth.basicauth.users=admin:$$apr1$$xyz..."
volumes:
letsencrypt:
networks:
proxy:
name: proxy
Key configuration points:
exposedbydefault=false— containers aren't proxied unless they explicitly opt in withtraefik.enable=true- HTTP automatically redirects to HTTPS
- Let's Encrypt certificates are stored in a volume for persistence
- The dashboard is protected with basic auth
Step 2: Add a Service Behind Traefik
# In your application's docker-compose.yml
services:
myapp:
image: myapp:1.2.3
restart: unless-stopped
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.myapp.rule=Host(`app.example.com`)"
- "traefik.http.routers.myapp.tls.certresolver=letsencrypt"
- "traefik.http.services.myapp.loadbalancer.server.port=3000"
networks:
proxy:
external: true
That's it. Start the container and Traefik automatically provisions a Let's Encrypt certificate for app.example.com and routes traffic to port 3000 of your container.
Adding Middleware (Security Headers, Rate Limiting)
labels:
- "traefik.enable=true"
- "traefik.http.routers.myapp.rule=Host(`app.example.com`)"
- "traefik.http.routers.myapp.tls.certresolver=letsencrypt"
- "traefik.http.routers.myapp.middlewares=security-headers,rate-limit"
# Security headers
- "traefik.http.middlewares.security-headers.headers.stsSeconds=31536000"
- "traefik.http.middlewares.security-headers.headers.stsIncludeSubdomains=true"
- "traefik.http.middlewares.security-headers.headers.contentTypeNosniff=true"
- "traefik.http.middlewares.security-headers.headers.browserXssFilter=true"
- "traefik.http.middlewares.security-headers.headers.frameDeny=true"
# Rate limiting
- "traefik.http.middlewares.rate-limit.ratelimit.average=100"
- "traefik.http.middlewares.rate-limit.ratelimit.burst=50"
Option 2: Caddy
Caddy has the simplest configuration of any reverse proxy. Its Caddyfile syntax is so concise that the entire config for a service is typically 2-3 lines. Caddy handles HTTPS automatically: just put a domain name in the config and Caddy provisions and renews the certificate.
Step 1: Create the Caddy Docker Compose File
# docker-compose.yml (Caddy)
version: "3.8"
services:
caddy:
image: caddy:2-alpine
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3 support
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- proxy
volumes:
caddy_data:
caddy_config:
networks:
proxy:
name: proxy
Step 2: Create the Caddyfile
# Caddyfile
# Global options
{
email [email protected]
}
# Application
app.example.com {
reverse_proxy myapp:3000
}
# API
api.example.com {
reverse_proxy api-server:8080
}
# Grafana
grafana.example.com {
reverse_proxy grafana:3000
}
# Docker management
docker.example.com {
reverse_proxy usulnet:8080
}
That's the entire configuration. Each domain block is 3 lines. Caddy automatically:
- Provisions Let's Encrypt certificates for each domain
- Redirects HTTP to HTTPS
- Enables HTTP/2 (and HTTP/3 if the UDP port is exposed)
- Renews certificates before expiration
Adding Security Headers and Rate Limiting
app.example.com {
reverse_proxy myapp:3000
# Security headers
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
-Server # Remove Server header
}
# Rate limiting
rate_limit {
zone dynamic_zone {
key {remote_host}
events 100
window 1m
}
}
}
Adding Services
To add a new service, add the container to the proxy network and add a block to the Caddyfile:
# In the application's docker-compose.yml
services:
newapp:
image: newapp:latest
networks:
- proxy
networks:
proxy:
external: true
# Add to Caddyfile
newapp.example.com {
reverse_proxy newapp:8080
}
Then reload Caddy:
# Reload without downtime
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
Option 3: Nginx Proxy Manager
Nginx Proxy Manager (NPM) is Nginx wrapped in a web UI. It's the best choice if you want to manage proxy rules through a point-and-click interface rather than editing configuration files.
Step 1: Deploy Nginx Proxy Manager
# docker-compose.yml (Nginx Proxy Manager)
version: "3.8"
services:
npm:
image: jc21/nginx-proxy-manager:latest
container_name: nginx-proxy-manager
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "81:81" # Admin UI
volumes:
- npm_data:/data
- npm_letsencrypt:/etc/letsencrypt
networks:
- proxy
volumes:
npm_data:
npm_letsencrypt:
networks:
proxy:
name: proxy
Step 2: Access the Admin UI
After starting the container, access the admin UI at http://your-server-ip:81. Default credentials:
Email: [email protected]
Password: changeme
Change these immediately after first login.
Step 3: Add Proxy Hosts Through the UI
In the admin UI:
- Click "Proxy Hosts" then "Add Proxy Host"
- Set the domain name:
app.example.com - Set the forward hostname:
myapp(the Docker container name) - Set the forward port:
3000 - Under "SSL", select "Request a new SSL Certificate" and check "Force SSL"
- Save
The UI approach is intuitive but doesn't scale well for infrastructure-as-code workflows. Every change requires clicking through the UI rather than editing a config file.
Using Nginx Proxy Manager with Docker Compose Services
Your application containers need to be on the same Docker network as NPM:
services:
myapp:
image: myapp:1.2.3
networks:
- proxy # Same network as NPM
networks:
proxy:
external: true
SSL/TLS Best Practices
All three proxies support Let's Encrypt out of the box, but here are additional considerations for production:
DNS Challenge for Wildcard Certificates
HTTP challenge (the default) requires port 80 to be accessible. For wildcard certificates (*.example.com) or servers behind firewalls, use DNS challenge:
Traefik with Cloudflare DNS:
services:
traefik:
image: traefik:v3.0
environment:
- [email protected]
- CF_DNS_API_TOKEN=your-cloudflare-api-token
command:
- "--certificatesresolvers.letsencrypt.acme.dnschallenge=true"
- "--certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare"
Caddy with Cloudflare DNS:
# Requires custom Caddy build with Cloudflare module
# Use: caddy-docker-proxy or build with xcaddy
*.example.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
@app host app.example.com
handle @app {
reverse_proxy myapp:3000
}
}
TLS Version and Cipher Configuration
Ensure you're only allowing TLS 1.2 and 1.3:
# Traefik - in static config
[entryPoints.websecure.transport.respondingTimeouts]
readTimeout = "30s"
[tls.options.default]
minVersion = "VersionTLS12"
# Caddy - in Caddyfile
{
servers {
protocols h1 h2 h3
}
}
# Nginx - in nginx.conf
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
Putting Your Docker Management Platform Behind the Proxy
Your Docker management platform (like usulnet) should always be behind a reverse proxy with HTTPS. Here's how for each proxy:
# Traefik (labels on usulnet container)
labels:
- "traefik.enable=true"
- "traefik.http.routers.usulnet.rule=Host(`docker.example.com`)"
- "traefik.http.routers.usulnet.tls.certresolver=letsencrypt"
- "traefik.http.services.usulnet.loadbalancer.server.port=8080"
# Caddy (in Caddyfile)
docker.example.com {
reverse_proxy usulnet:8080
}
# Nginx Proxy Manager (through the web UI)
# Domain: docker.example.com
# Forward: usulnet:8080
# SSL: Force SSL, HTTP/2 enabled
Common Issues and Troubleshooting
Container Not Reachable
The most common issue is network misconfiguration. The proxy and your service must be on the same Docker network:
# Check what networks a container is on
docker inspect myapp --format '{{json .NetworkSettings.Networks}}' | jq
# Verify the proxy network exists
docker network ls | grep proxy
# Connect a running container to the proxy network
docker network connect proxy myapp
Certificate Not Provisioning
- Ensure your domain's DNS A record points to your server's public IP
- Ensure port 80 is open (required for HTTP challenge)
- Check rate limits: Let's Encrypt has a 50 certificates per registered domain per week limit
- Check the proxy logs:
docker logs traefikordocker logs caddy
WebSocket Connections Failing
If your application uses WebSockets, make sure the proxy is configured to handle the upgrade:
# Caddy handles WebSockets automatically - no extra config needed
# Nginx needs explicit WebSocket headers
location / {
proxy_pass http://myapp:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# Traefik handles WebSockets automatically with HTTP routers
Recommendation
For most Docker setups, here's our recommendation:
- Traefik if you frequently add and remove containers and want zero-touch routing configuration. The label-based approach is powerful once you learn the syntax.
- Caddy if you want the simplest possible configuration and don't mind editing a file when adding services. Best bang for your buck in terms of simplicity vs. capability.
- Nginx Proxy Manager if you're managing the proxy for a team that isn't comfortable editing config files, or if you're new to reverse proxies and want a visual interface.
All three will serve you well. The most important thing is to have a reverse proxy at all, as running Docker services directly on public ports without TLS is a security risk.