Reverse Proxy Setup with Traefik: Auto-SSL and Load Balancing

Reverse Proxy Setup with Traefik: Auto-SSL and Load Balancing

We earn commissions when you shop through the links on this page, at no additional cost to you. Learn more.

Traefik has become my go-to reverse proxy for self-hosted infrastructure because it handles SSL certificate renewal automatically and integrates seamlessly with Docker containers. If you're running multiple services on a VPS—whether that's Nextcloud, Jellyfin, a custom app, or anything else—you need a smart reverse proxy that talks to Docker directly, renews certificates without downtime, and distributes traffic intelligently. I've moved three homelabs to Traefik in the past year, and I'm going to show you exactly how to set it up from scratch.

Why Traefik over Nginx or Caddy?

I use Traefik because it's Docker-native. It reads container labels, discovers services automatically, and updates routing rules without reloading. Nginx is powerful but requires manual config updates. Caddy handles SSL beautifully too, but Traefik's dynamic routing with Docker labels feels like less boilerplate once you know the pattern.

The killer feature for me is middleware support—I can plug in authentication, rate limiting, compression, and custom headers with a few labels. When I add a new service, I don't touch Traefik's core config; I just add labels to the container.

Tip: You can run Traefik on a modest VPS. A $40/year RackNerd instance with 2 cores and 2GB RAM handles 10–15 self-hosted services without strain. Check their NewYear deals for budget VPS options.

Architecture: How Traefik Routes Traffic

Traefik sits between your users and your services. When a request hits your domain, Traefik intercepts it, checks the routing rules (stored in Docker labels or config files), and forwards it to the right container. It also manages SSL certificates from Let's Encrypt, renewing them 30 days before expiry.

The flow looks like: User → Traefik (port 80/443) → Your container (port 3000, 5000, etc.) → Response back to user. Traefik also load-balances if you have multiple instances of the same service.

Step 1: Install Traefik with Docker Compose

I prefer Docker Compose because it's reproducible and version-controlled. Here's a production-ready setup:

mkdir -p ~/traefik && cd ~/traefik
mkdir -p acme letsencrypt

Create the Docker Compose file:

version: '3.8'
services:
  traefik:
    image: traefik:v3.0
    container_name: traefik
    restart: always
    security_opt:
      - no-new-privileges:true
    networks:
      - traefik
    ports:
      - "80:80"
      - "443:443"
    environment:
      - [email protected]
      - CF_API_KEY=your-cloudflare-api-key
    volumes:
      - /etc/localtime:/etc/localtime:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik.yml:/traefik.yml:ro
      - ./acme.json:/acme.json
      - ./config.yml:/config.yml:ro
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.traefik.rule=Host(\`traefik.yourdomain.com\`)"
      - "traefik.http.routers.traefik.entrypoints=websecure"
      - "traefik.http.routers.traefik.service=api@internal"
      - "traefik.http.routers.traefik.tls.certresolver=letsencrypt"
      - "traefik.http.services.traefik.loadbalancer.server.port=8080"

networks:
  traefik:
    driver: bridge
Watch out: The `acme.json` file stores certificate data. Never commit it to git or share it. Set permissions to `600` and keep it safe. If compromised, attackers can impersonate your domains.

Now create `traefik.yml` (the static config):

global:
  checkNewVersion: false
  sendAnonymousUsage: false

api:
  insecure: true
  dashboard: true

entryPoints:
  web:
    address: ":80"
    http:
      redirections:
        entrypoint:
          regex: "^http://(.*)$"
          replacement: "https://$1"
          permanent: true
  websecure:
    address: ":443"
    http:
      tls:
        certResolver: letsencrypt
        domains:
          - main: yourdomain.com
            sans:
              - "*.yourdomain.com"

certificatesResolvers:
  letsencrypt:
    acme:
      email: [email protected]
      storage: acme.json
      httpChallenge:
        entrypoint: web
      dnsChallenge:
        provider: cloudflare
        delayBeforeCheck: 0
        resolvers:
          - "1.1.1.1:53"
          - "8.8.8.8:53"

providers:
  docker:
    endpoint: "unix:///var/run/docker.sock"
    exposedByDefault: false
    network: traefik
  file:
    filename: config.yml
    watch: true

log:
  level: INFO
  filePath: /var/log/traefik/traefik.log

accessLog:
  filePath: /var/log/traefik/access.log

Create an empty `config.yml` for dynamic routes (we'll add services later):

touch config.yml && chmod 644 config.yml

Fix permissions on the certificate store:

touch acme.json && chmod 600 acme.json

Start Traefik:

docker-compose up -d

Check logs to confirm it started without errors:

docker-compose logs -f traefik | head -50

Step 2: Set Up DNS and Point Your Domain

Point your domain (and wildcard `*.yourdomain.com`) to your VPS IP address via your DNS provider. I use Cloudflare because Traefik integrates with it for wildcard certificate issuance via DNS challenge—no port forwarding needed for cert validation.

If you use the HTTP challenge instead (as shown in the config above), port 80 must be accessible from the internet. The DNS challenge is more flexible if you're behind a firewall.

Step 3: Deploy Your First Service with Traefik Labels

Let's add a Nextcloud instance as an example. Create a separate `docker-compose.yml` in a `nextcloud` directory:

version: '3.8'
services:
  nextcloud-db:
    image: postgres:15
    container_name: nextcloud-db
    restart: always
    environment:
      POSTGRES_DB: nextcloud
      POSTGRES_USER: nextcloud
      POSTGRES_PASSWORD: secure_password_here
    volumes:
      - ./db-data:/var/lib/postgresql/data
    networks:
      - traefik

  nextcloud:
    image: nextcloud:latest
    container_name: nextcloud
    restart: always
    depends_on:
      - nextcloud-db
    environment:
      POSTGRES_DB: nextcloud
      POSTGRES_USER: nextcloud
      POSTGRES_PASSWORD: secure_password_here
      POSTGRES_HOST: nextcloud-db
      NEXTCLOUD_ADMIN_USER: admin
      NEXTCLOUD_ADMIN_PASSWORD: admin_pass_123
      NEXTCLOUD_TRUSTED_DOMAINS: "nextcloud.yourdomain.com"
    volumes:
      - ./nextcloud-data:/var/www/html
    networks:
      - traefik
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.nextcloud.rule=Host(\`nextcloud.yourdomain.com\`)"
      - "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-redirect.redirectregex.regex=^https://(.*)/.well-known/(card|cal)dav"
      - "traefik.http.middlewares.nextcloud-redirect.redirectregex.replacement=https://$$1/remote.php/dav"
      - "traefik.http.routers.nextcloud.middlewares=nextcloud-redirect"

networks:
  traefik:
    external: true

Deploy it:

cd ~/nextcloud && docker-compose up -d

Traefik will auto-discover the service, see the labels, and create a route. Within 30 seconds, visit `https://nextcloud.yourdomain.com` and you'll have a working HTTPS service with an auto-renewed certificate.

Load Balancing Multiple Instances

If you want to run two instances of the same service and load-balance between them, declare them with the same router rule but different service names, then use a loadbalancer middleware:

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=letsencrypt"
  - "traefik.http.services.app.loadbalancer.server.port=3000"
  - "traefik.http.services.app.loadbalancer.balancer.stickiness.cookie=true"

The stickiness cookie ensures the same user sticks to the same backend instance, useful for session-sensitive apps.

Adding Middleware: Basic Auth and Rate Limiting

Traefik's middleware is powerful. Let me add basic authentication to a sensitive service:

labels:
  - "traefik.enable=true"
  - "traefik.http.routers.admin.rule=Host(\`admin.yourdomain.com\`)"
  - "traefik.http.routers.admin.entrypoints=websecure"
  - "traefik.http.routers.admin.tls.certresolver=letsencrypt"
  - "traefik.http.middlewares.admin-auth.basicauth.users=admin:$$apr1$$pFvANFy2$$jd1R8kqtQEKQIhU5mlRPh."
  - "traefik.http.routers.admin.middlewares=admin-auth"
  - "traefik.http.services.admin.loadbalancer.server.port=3000"

Generate the htpasswd hash:

echo $(htpasswd -nB admin) | sed -e s/\\$/\\$\\$/g

For rate limiting:

labels:
  - "traefik.http.middlewares.ratelimit.ratelimit.average=100"
  - "traefik.http.middlewares.ratelimit.ratelimit.burst=200"
  - "traefik.http.routers.myapp.middlewares=ratelimit"

SSL Certificate Renewal and Monitoring

Traefik renews certificates automatically 30 days before expiry. Check the status of your certificates:

cat acme.json | jq '.letsencrypt.Certificates[].domain'

Monitor the acme.json file for activity:

tail -f traefik.log | grep -i certificate

If renewal fails, you'll see errors in the logs. Common causes: DNS not propagated, port 80 blocked, or rate limit hit. Double-check your DNS records and ensure firewall rules allow inbound traffic on ports 80 and 443.

Production Tips

I always enable the Traefik dashboard for monitoring, but behind authentication:

labels:
  - "traefik.enable=true"
  - "traefik.http.routers.dashboard.rule=Host(\`traefik.yourdomain.com\`) && (PathPrefix(\`/api\`) || PathPrefix(\`/dashboard\`))"
  - "traefik.http.routers.dashboard.service=api@internal"
  - "traefik.http.routers.dashboard.entrypoints=websecure"
  - "traefik.http.routers.dashboard.tls.certresolver=letsencrypt"
  - "traefik