Reverse Proxy Showdown: Nginx vs Traefik vs Caddy for Homelab Applications

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:

Cons:

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;
    }
}
Watch out: When using Nginx in Docker, always pass the Host header and X-Forwarded headers correctly. Missing these causes redirect loops in apps like Nextcloud and Vaultwarden.

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:

Cons:

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:

Cons:

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