Caddy vs Traefik vs Nginx Proxy Manager: Reverse Proxies in 2026
Caddy vs Traefik vs Nginx Proxy Manager: Reverse Proxies in 2026
TL;DR
A reverse proxy handles SSL termination, routing, and load balancing in front of your applications. Caddy is the simplest with automatic HTTPS — you write two lines of config, point a domain at your server, and SSL certs are issued and renewed automatically. Traefik is the Kubernetes-native choice — it discovers services from Docker labels, Kubernetes annotations, and cloud APIs dynamically, making it the best choice for container orchestration environments. Nginx Proxy Manager is the GUI option — a web interface that wraps Nginx, ideal for self-hosters who want visual configuration without touching config files. For simple VPS deployments: Caddy. For Docker Swarm/Kubernetes service discovery: Traefik. For GUI-based management: Nginx Proxy Manager.
Key Takeaways
- Caddy handles HTTPS automatically — Let's Encrypt cert issuance and renewal with zero configuration
- Traefik discovers Docker services from labels — no manual proxy config when containers start/stop
- Nginx Proxy Manager GitHub stars: ~23k — the most popular reverse proxy for home labs and small teams
- Caddy GitHub stars: ~60k — the most starred modern reverse proxy project
- Traefik GitHub stars: ~52k — the dominant choice in container environments
- All three support Docker — the difference is how they handle service discovery
- Caddy's Caddyfile is 3-5x shorter than Nginx config for equivalent functionality
Why You Need a Reverse Proxy
Deploying multiple apps to one server requires a reverse proxy to:
- SSL termination — HTTPS on port 443, decrypted and forwarded to apps on :3000, :8080, etc.
- Virtual hosts —
app1.example.com→ port 3000,app2.example.com→ port 8080 - Load balancing — distribute traffic across multiple instances
- Security headers — add HSTS, X-Frame-Options once, before all services
- Rate limiting — protect backend services from abuse
Caddy: Automatic HTTPS for Everyone
Caddy is written in Go and designed around one principle: HTTPS should be automatic. It handles certificate issuance, renewal, OCSP stapling, and HTTP→HTTPS redirects without any configuration.
Installation
# Docker
docker run -d \
--name caddy \
-p 80:80 -p 443:443 \
-v caddy_data:/data \
-v ./Caddyfile:/etc/caddy/Caddyfile \
caddy:latest
# macOS
brew install caddy
# Ubuntu
apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
apt update && apt install caddy
Caddyfile Examples
# Caddyfile — minimal config for HTTPS reverse proxy
# Proxy to local app — Caddy handles cert automatically
app.yourdomain.com {
reverse_proxy localhost:3000
}
# Multiple apps
api.yourdomain.com {
reverse_proxy localhost:8080
}
dashboard.yourdomain.com {
reverse_proxy localhost:8501
}
# Static files + API
yourdomain.com {
# Serve Next.js
reverse_proxy localhost:3000
# API routes to a different service
handle /api/* {
reverse_proxy localhost:4000
}
}
Docker Compose with Caddy
# docker-compose.yml
version: "3.8"
services:
caddy:
image: caddy:latest
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
networks:
- proxy
app:
image: node:22-alpine
working_dir: /app
command: node server.js
expose:
- "3000" # Only expose to internal network
networks:
- proxy
api:
image: node:22-alpine
working_dir: /api
command: node index.js
expose:
- "8080"
networks:
- proxy
volumes:
caddy_data:
caddy_config:
networks:
proxy:
name: proxy_network
# Caddyfile for Docker Compose services
app.yourdomain.com {
reverse_proxy app:3000 # Reference by service name
}
api.yourdomain.com {
reverse_proxy api:8080
}
Advanced Caddyfile Features
# Load balancing
api.yourdomain.com {
reverse_proxy api-1:8080 api-2:8080 api-3:8080 {
lb_policy round_robin
health_uri /health
health_interval 10s
}
}
# Rate limiting (Caddy plugin)
app.yourdomain.com {
rate_limit {
zone static {
key {remote_host}
events 10
window 1m
}
}
reverse_proxy localhost:3000
}
# Authentication via forward auth
protected.yourdomain.com {
forward_auth authservice:4181 {
uri /auth/verify
copy_headers Remote-User Remote-Email
}
reverse_proxy app:3000
}
# WebSocket support (automatic — no special config needed)
ws.yourdomain.com {
reverse_proxy localhost:3001 # WebSocket connections work automatically
}
# Custom headers
api.yourdomain.com {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Frame-Options DENY
X-Content-Type-Options nosniff
-Server # Remove Server header
}
reverse_proxy localhost:8080
}
Caddy API (Dynamic Config)
# Caddy's JSON API allows runtime configuration changes
# No service restart needed
# Add a new host
curl -X POST https://localhost:2019/config/apps/http/servers/srv0/routes \
-H "Content-Type: application/json" \
-d '{
"match": [{"host": ["newapp.yourdomain.com"]}],
"handle": [{
"handler": "reverse_proxy",
"upstreams": [{"dial": "localhost:4000"}]
}]
}'
Traefik: Dynamic Service Discovery
Traefik discovers services automatically from Docker labels, Kubernetes Ingress annotations, and cloud APIs. When a container starts with the right labels, Traefik automatically creates a route — no proxy restart, no manual config.
Docker Compose Setup
# docker-compose.yml with Traefik
version: "3.8"
services:
traefik:
image: traefik:v3.0
command:
# API dashboard (disable in production)
- "--api.insecure=true"
# Docker provider — watch for container events
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
# Entrypoints
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
# Let's Encrypt
- "--certificatesresolvers.myresolver.acme.tlschallenge=true"
- "--certificatesresolvers.myresolver.acme.email=you@yourdomain.com"
- "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json"
# HTTP → HTTPS redirect
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
ports:
- "80:80"
- "443:443"
- "8080:8080" # Dashboard
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./letsencrypt:/letsencrypt"
app:
image: myapp:latest
labels:
- "traefik.enable=true"
- "traefik.http.routers.app.rule=Host(`app.yourdomain.com`)"
- "traefik.http.routers.app.entrypoints=websecure"
- "traefik.http.routers.app.tls.certresolver=myresolver"
- "traefik.http.services.app.loadbalancer.server.port=3000"
api:
image: myapi:latest
labels:
- "traefik.enable=true"
- "traefik.http.routers.api.rule=Host(`api.yourdomain.com`)"
- "traefik.http.routers.api.entrypoints=websecure"
- "traefik.http.routers.api.tls.certresolver=myresolver"
- "traefik.http.services.api.loadbalancer.server.port=8080"
# Middleware
- "traefik.http.routers.api.middlewares=api-ratelimit"
- "traefik.http.middlewares.api-ratelimit.ratelimit.average=100"
- "traefik.http.middlewares.api-ratelimit.ratelimit.burst=50"
Kubernetes Ingress
# k8s/ingress.yml — Traefik Kubernetes IngressRoute
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: app-ingress
namespace: default
spec:
entryPoints:
- websecure
routes:
- match: Host(`app.yourdomain.com`)
kind: Rule
services:
- name: app-service
port: 3000
- match: Host(`api.yourdomain.com`)
kind: Rule
services:
- name: api-service
port: 8080
tls:
certResolver: myresolver
Traefik Middlewares
# Traefik middleware via labels — add security headers, auth, etc.
labels:
- "traefik.http.middlewares.security-headers.headers.stsSeconds=31536000"
- "traefik.http.middlewares.security-headers.headers.stsIncludeSubdomains=true"
- "traefik.http.middlewares.security-headers.headers.frameXdenied=true"
- "traefik.http.middlewares.security-headers.headers.contentTypeNosniff=true"
- "traefik.http.middlewares.basic-auth.basicauth.users=admin:$$apr1$$..."
Nginx Proxy Manager: The Visual Interface
Nginx Proxy Manager (NPM) provides a web dashboard for creating proxy hosts, SSL certificates, and access control — no config files required. Built on top of Nginx under the hood.
Docker Compose Setup
version: "3.8"
services:
npm:
image: jc21/nginx-proxy-manager:latest
ports:
- "80:80"
- "443:443"
- "81:81" # Admin dashboard
volumes:
- ./npm-data:/data
- ./npm-letsencrypt:/etc/letsencrypt
environment:
DISABLE_IPV6: "true"
db:
image: jc21/mariadb-aria:latest
environment:
MYSQL_ROOT_PASSWORD: "npm-db-password"
MYSQL_DATABASE: "npm"
MYSQL_USER: "npm"
MYSQL_PASSWORD: "npm"
volumes:
- ./npm-db:/var/lib/mysql
# Dashboard access: http://your-server:81
# Default login: admin@example.com / changeme
# Change password on first login!
# In the GUI you can:
# 1. Add Proxy Hosts: domain → internal IP:port
# 2. Request SSL certificates (Let's Encrypt, one click)
# 3. Set up access control lists
# 4. Add custom Nginx config snippets
# 5. View Nginx error/access logs
Custom Nginx Config via NPM
# Custom config snippet added via NPM GUI (Advanced tab)
# Goes into server block, gives access to raw Nginx directives
# WebSocket support
location /ws {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_pass http://app_backend:3001;
}
# Cache static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
expires 30d;
add_header Cache-Control "public, no-transform";
}
Feature Comparison
| Feature | Caddy | Traefik | Nginx Proxy Manager |
|---|---|---|---|
| Automatic HTTPS | ✅ Native | ✅ Via ACME | ✅ Let's Encrypt GUI |
| Config interface | Caddyfile / JSON API | Docker labels / YAML | Web GUI |
| Docker service discovery | Manual | ✅ Automatic | Manual |
| Kubernetes native | Partial | ✅ CRDs + Ingress | ❌ |
| Dynamic config (no restart) | ✅ JSON API | ✅ | ❌ |
| WebSocket support | ✅ Automatic | ✅ | ✅ |
| Load balancing | ✅ | ✅ | ✅ Basic |
| Dashboard UI | Plugin | ✅ Built-in | ✅ Full GUI |
| Rate limiting | Plugin | ✅ Built-in | ❌ (raw Nginx) |
| Auth middleware | Plugin | ✅ | ✅ Basic auth |
| Config complexity | Low | Medium | Very Low (GUI) |
| GitHub stars | 60k | 52k | 23k |
| Language | Go | Go | Node.js (wrapper) |
| Memory footprint | ~30 MB | ~50 MB | ~200 MB (Nginx + Node) |
When to Use Each
Choose Caddy if:
- You're deploying to a VPS and want HTTPS to "just work"
- Your config is straightforward and you want to maintain it as code (Caddyfile in git)
- You want a lightweight proxy without Docker label magic
- You need dynamic config updates without restarts via the JSON API
Choose Traefik if:
- You're using Docker Compose, Docker Swarm, or Kubernetes
- Services come and go dynamically (CI/CD deploys, autoscaling)
- You want the proxy config to live in the app's docker-compose.yml via labels
- Kubernetes Ingress resources are your team's standard
Choose Nginx Proxy Manager if:
- You want zero config file editing — GUI-only management
- You're running a home lab or small self-hosted stack
- Non-technical users need to manage proxy routes
- You want a quick visual dashboard and are comfortable with some limitations
Methodology
Data sourced from GitHub repositories (star counts as of February 2026), official documentation, Docker Hub image pull counts, and community reports from r/selfhosted, r/homelab, and the self-hosted Discord. Memory footprint measured from Docker stats on idle containers. Feature availability verified against official documentation.
Related: Coolify vs Caprover vs Dokku for self-hosted PaaS platforms that include built-in proxy management, or Zitadel vs Casdoor vs Authentik for authentication behind your reverse proxy.