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 routingapp.example.com, api.example.com, grafana.example.com all 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 with traefik.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:

  1. Click "Proxy Hosts" then "Add Proxy Host"
  2. Set the domain name: app.example.com
  3. Set the forward hostname: myapp (the Docker container name)
  4. Set the forward port: 3000
  5. Under "SSL", select "Request a new SSL Certificate" and check "Force SSL"
  6. 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
Security tip: For Docker management UIs, consider adding an additional layer of protection like IP whitelisting or basic auth in front of the proxy, even though the platform has its own authentication.

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

  1. Ensure your domain's DNS A record points to your server's public IP
  2. Ensure port 80 is open (required for HTTP challenge)
  3. Check rate limits: Let's Encrypt has a 50 certificates per registered domain per week limit
  4. Check the proxy logs: docker logs traefik or docker 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.