Reverse Proxy Showdown: Nginx vs Traefik vs Caddy for Homelab Applications
I've run all three reverse proxies in production homelabs over the past three years, and each one has genuinely changed how I manage my self-hosted services. The choice between Nginx, Traefik, and Caddy isn't about which is "best"—it's about what fits your workflow, your hardware constraints, and how much automation you want. In this article, I'm sharing my honest take on each one with real Docker Compose configs you can use today.
Why Reverse Proxies Matter in Your Homelab
A reverse proxy sits in front of all your self-hosted services—Nextcloud, Jellyfin, Vaultwarden, Gitea—and handles TLS termination, load balancing, and routing. Instead of exposing each service on a different port (messy, insecure), you run one reverse proxy on ports 80 and 443 and route requests based on hostname.
The key difference between these three tools is how you configure them. Nginx requires static config files. Traefik watches Docker labels and auto-discovers services. Caddy is a middle ground—simple JSON or Caddyfile syntax with plugin support.
Nginx: The Industry Standard (Still)
I started with Nginx because it's everywhere. Every VPS guide mentions it. Every production system runs it. For good reason: Nginx is rock-solid, uses minimal memory (about 10 MB per process), and performs like a sports car.
Pros:
- Tiny memory footprint—I run it comfortably on a 512 MB Hetzner Cloud instance
- Blazingly fast reverse proxying and static file serving
- Huge ecosystem of third-party modules and documentation
- Familiar if you already know it from cloud work
Cons:
- Requires manual config file editing every time you add a service
- No native Docker auto-discovery; you need external tools like
nginx-proxy-genor Docker Events - Reloading config can cause brief downtime if not done carefully
- TLS certificate renewal is manual (you have to integrate Certbot separately)
When I set up Nginx with Docker Compose, here's what a basic homelab config looks like:
version: '3.8'
services:
nginx:
image: nginx:latest
container_name: nginx-proxy
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/nginx/certs:ro
- ./conf.d:/etc/nginx/conf.d:ro
networks:
- homelab
restart: unless-stopped
nextcloud:
image: nextcloud:latest
container_name: nextcloud
environment:
POSTGRES_HOST: db
POSTGRES_DB: nextcloud
POSTGRES_USER: nextcloud
POSTGRES_PASSWORD: securepass123
volumes:
- ./nextcloud-data:/var/www/html
networks:
- homelab
depends_on:
- db
restart: unless-stopped
db:
image: postgres:15-alpine
container_name: postgres
environment:
POSTGRES_DB: nextcloud
POSTGRES_USER: nextcloud
POSTGRES_PASSWORD: securepass123
volumes:
- ./postgres-data:/var/lib/postgresql/data
networks:
- homelab
restart: unless-stopped
networks:
homelab:
driver: bridge
And your Nginx config file (conf.d/nextcloud.conf) would look like:
upstream nextcloud {
server nextcloud:80;
}
server {
listen 80;
server_name files.homelab.local;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name files.homelab.local;
ssl_certificate /etc/nginx/certs/files.homelab.local.crt;
ssl_certificate_key /etc/nginx/certs/files.homelab.local.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
client_max_body_size 512M;
location / {
proxy_pass http://nextcloud;
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 trade-off: every time you add Jellyfin or Vaultwarden, you manually add a new .conf file and run nginx -s reload. It's not painful, but it's not automatic either.
Traefik: The Docker-Native Automation King
Traefik changed my life when Docker became central to my homelab. It watches Docker events, sees container labels, and auto-generates routing rules. No config files to edit. No manual reloads.
Pros:
- Automatic Docker service discovery via labels
- Built-in Let's Encrypt integration—certificates auto-renew
- Dynamic middleware: authentication, rate limiting, compression all via labels
- Beautiful web dashboard showing live routes and certificate status
- Supports multiple backends: Docker, Kubernetes, Consul, Nomad
Cons:
- Higher memory usage (~30–50 MB depending on load)
- Steeper learning curve for the label syntax
- Dashboard is useful but adds a small security surface if exposed
- Configuration complexity grows as you add advanced features
Here's a real Traefik Docker Compose setup I use now:
version: '3.8'
services:
traefik:
image: traefik:v2.10
container_name: traefik
command:
- "--api.insecure=false"
- "--api.dashboard=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
- "--certificatesresolvers.letsencrypt.acme.email=admin@homelab.local"
- "--certificatesresolvers.letsencrypt.acme.storage=/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./acme.json:/acme.json
environment:
- TRAEFIK_DASHBOARD_BASICAUTH=admin:hashedpassword
networks:
- homelab
restart: unless-stopped
nextcloud:
image: nextcloud:latest
container_name: nextcloud
labels:
- "traefik.enable=true"
- "traefik.http.routers.nextcloud.rule=Host(`files.homelab.local`)"
- "traefik.http.routers.nextcloud.entrypoints=websecure"
- "traefik.http.routers.nextcloud.tls.certresolver=letsencrypt"
- "traefik.http.services.nextcloud.loadbalancer.server.port=80"
- "traefik.http.middlewares.nextcloud-headers.headers.customrequestheaders.X-Forwarded-Proto=https"
environment:
POSTGRES_HOST: db
POSTGRES_DB: nextcloud
POSTGRES_USER: nextcloud
POSTGRES_PASSWORD: securepass123
volumes:
- ./nextcloud-data:/var/www/html
networks:
- homelab
depends_on:
- db
restart: unless-stopped
vaultwarden:
image: vaultwarden/server:latest
container_name: vaultwarden
labels:
- "traefik.enable=true"
- "traefik.http.routers.vaultwarden.rule=Host(`vault.homelab.local`)"
- "traefik.http.routers.vaultwarden.entrypoints=websecure"
- "traefik.http.routers.vaultwarden.tls.certresolver=letsencrypt"
- "traefik.http.services.vaultwarden.loadbalancer.server.port=80"
environment:
DOMAIN: https://vault.homelab.local
ADMIN_TOKEN: sometoken
volumes:
- ./vaultwarden-data:/data
networks:
- homelab
restart: unless-stopped
db:
image: postgres:15-alpine
container_name: postgres
environment:
POSTGRES_DB: nextcloud
POSTGRES_USER: nextcloud
POSTGRES_PASSWORD: securepass123
volumes:
- ./postgres-data:/var/lib/postgresql/data
networks:
- homelab
restart: unless-stopped
networks:
homelab:
driver: bridge
Notice: no separate config files. Every route is defined in the service labels. Add Jellyfin? Copy the label block, change the hostname and port. Done.
I prefer Traefik when my homelab is Docker-heavy and I'm comfortable with Compose syntax. The auto-renewal of Let's Encrypt certificates alone saves me hours per year.
Caddy: The Pragmatic Middle Ground
Caddy is what I reach for when I want simplicity without sacrificing modern features. It's newer than Nginx (first released 2015, but mature now), has automatic HTTPS, and a syntax that's genuinely readable.
Pros:
- Automatic Let's Encrypt certificate provisioning and renewal—built-in, no plugins
- Clean, human-readable Caddyfile syntax (easier than Nginx for beginners)
- Lightweight (~15 MB memory use)
- JSON API for dynamic config (no reload needed)
- Active development and responsive maintainers
- Strong community focus on security and best practices
Cons:
- Less ecosystem compared to Nginx (fewer third-party modules)
- Docker integration requires external tools like
caddy-docker-proxyor Compose networking - Not as widely deployed in enterprise environments (hiring/knowledge transfer is harder)
Here's how I'd set up Caddy for a homelab. Caddy's simplicity shines here:
version: '3.8'
services:
caddy:
image: caddy:latest
container_name: caddy
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy-data:/data
- ./caddy-config:/config
networks:
- homelab
environment:
- ACME_AGREE=true
restart: unless-stopped
nextcloud:
image: nextcloud:latest
container_name: nextcloud
environment:
POSTGRES_HOST: db
POSTGRES_DB: nextcloud
POSTGRES_USER: nextcloud
POSTGRES_PASSWORD: securepass123
volumes:
- ./nextcloud-data:/var/www/html
networks:
- homelab
depends_on:
- db
restart: unless-stopped
vaultwarden:
image: vaultwarden/server:latest
container_name: vaultwarden
environment:
DOMAIN: https://vault.homelab.local
ADMIN_TOKEN: sometoken
volumes:
- ./vaultwarden-data:/data
networks:
- homelab
restart: unless-stopped
db:
image: postgres:15-alpine
container_name: postgres
environment:
POSTGRES_DB: nextcloud
POSTGRES_USER: nextcloud
POSTGRES_PASSWORD: securepass123
volumes:
- ./postgres-data:/var/lib/postgresql/data
networks:
- homelab
restart: unless-stopped
networks:
homelab:
driver: bridge
And your Caddyfile:
{
email [email protected]
on_demand_tls {
ask http://localhost:2019/ask
}
}
files.homelab.local {
reverse_proxy nextcloud:80 {
header_up X-Forwarded-Proto https
header