PROJECTIntermediate

Building a Production-Ready Reverse Proxy with Traefik v3

Deploy Traefik v3 as a Docker-native reverse proxy with automatic Let's Encrypt TLS, label-based routing, and security middleware — no more port juggling...

Dylan H.

Projects

April 29, 2026
10 min read
3-5 hours
Building a Production-Ready Reverse Proxy with Traefik v3

Tools & Technologies

DockerDocker ComposeTraefik v3Let's Encrypthtpasswd

Overview

Every self-hosted stack eventually runs into the same problem: you have dozens of services on dozens of ports, no TLS, and a browser full of "your connection is not private" warnings. Traefik solves this elegantly. It acts as the single entry point for all HTTP/HTTPS traffic, automatically routes requests to the correct container based on Docker labels, and handles certificate provisioning and renewal via Let's Encrypt — entirely without manual configuration.

This project walks through deploying Traefik v3 in a Docker Compose stack. By the end you'll have:

  • A single Traefik container handling all inbound traffic on ports 80 and 443
  • Automatic HTTPS with Let's Encrypt certificates for every service
  • HTTP → HTTPS redirect enforced globally
  • Security headers middleware applied site-wide
  • A password-protected Traefik dashboard
  • Label-based routing so adding a new service takes three lines of YAML

Unlike nginx or HAProxy, Traefik requires zero manual reloads — it watches the Docker socket and reconfigures itself in real time when containers start or stop.


Architecture

Internet
   │
   │  :80 / :443
   ▼
┌──────────────────────────────────┐
│           Traefik v3             │
│  ┌──────────────────────────┐    │
│  │  EntryPoints             │    │
│  │  web (80) → websecure    │    │
│  │  websecure (443) → ACME  │    │
│  └──────────────────────────┘    │
│  ┌──────────────────────────┐    │
│  │  Providers               │    │
│  │  Docker (label scrape)   │    │
│  │  File (static middleware)│    │
│  └──────────────────────────┘    │
│  ┌──────────────────────────┐    │
│  │  Certificate Resolver    │    │
│  │  ACME HTTP-01 / DNS-01   │    │
│  └──────────────────────────┘    │
└──────────────┬───────────────────┘
               │
     ┌─────────┼──────────┐
     ▼         ▼          ▼
  app:8080  api:3000  grafana:3000

Traefik reads Docker labels on each container to determine:

  • Rule — which hostname routes to that container (Host(\app.example.com`)`)
  • Service — which port to forward to
  • Middleware — which transformations to apply (HTTPS redirect, headers, auth)
  • TLS — which certificate resolver to use

All configuration lives in two places: traefik.yml (static config, requires restart) and Docker labels (dynamic config, hot-reloaded).


Prerequisites

  • A Linux host running Docker Engine 24+ and Docker Compose v2
  • A registered domain name with DNS pointed at your host's public IP
  • Ports 80 and 443 open on your firewall/router
  • An email address for Let's Encrypt registration

For a homelab behind NAT, configure port-forwarding on your router and use a Dynamic DNS service (Cloudflare, DuckDNS) if you don't have a static IP.


Step 1 — Directory Structure

Create the Traefik project directory:

mkdir -p ~/traefik/{config,certs,logs}
cd ~/traefik

You'll end up with:

traefik/
├── docker-compose.yml
├── config/
│   ├── traefik.yml          # Static configuration
│   └── middleware.yml       # Reusable middleware definitions
├── certs/
│   └── acme.json            # Let's Encrypt certificate store
└── logs/
    └── access.log

Step 2 — Static Configuration

Create config/traefik.yml. This is the only file that requires a Traefik restart to apply:

# config/traefik.yml
global:
  checkNewVersion: true
  sendAnonymousUsage: false
 
api:
  dashboard: true
  # insecure: false  — dashboard is exposed via a secure router below
 
log:
  level: INFO
  filePath: /logs/traefik.log
 
accessLog:
  filePath: /logs/access.log
  bufferingSize: 100
 
entryPoints:
  web:
    address: ":80"
    http:
      redirections:
        entryPoint:
          to: websecure
          scheme: https
          permanent: true
 
  websecure:
    address: ":443"
    http:
      tls:
        certResolver: letsencrypt
 
providers:
  docker:
    endpoint: "unix:///var/run/docker.sock"
    exposedByDefault: false   # must opt-in with traefik.enable=true
    network: proxy
  file:
    filename: /config/middleware.yml
    watch: true
 
certificatesResolvers:
  letsencrypt:
    acme:
      email: you@example.com      # replace with your email
      storage: /certs/acme.json
      httpChallenge:
        entryPoint: web

Key decisions:

  • exposedByDefault: false — Traefik ignores containers that don't have traefik.enable=true. This prevents accidental exposure.
  • The web entrypoint issues a permanent redirect to websecure, enforcing HTTPS globally.
  • httpChallenge works for public-facing hosts. For homelab hosts that aren't reachable from the internet, switch to dnsChallenge using your DNS provider's API.

Step 3 — Middleware Definitions

Create config/middleware.yml for reusable middleware. These are referenced by Docker labels:

# config/middleware.yml
http:
  middlewares:
    # Strips www from requests
    strip-www:
      redirectRegex:
        regex: "^https://www\\.(.*)"
        replacement: "https://${1}"
        permanent: true
 
    # Security headers — applied to all services
    secure-headers:
      headers:
        sslRedirect: true
        forceSTSHeader: true
        stsSeconds: 31536000
        stsIncludeSubdomains: true
        stsPreload: true
        frameDeny: true
        contentTypeNosniff: true
        browserXssFilter: true
        referrerPolicy: "same-origin"
        permissionsPolicy: "camera=(), microphone=(), geolocation=()"
        customResponseHeaders:
          X-Robots-Tag: "none,noarchive,nosnippet,notranslate,noimageindex"
          server: ""
 
    # Rate limiting — 100 req/s average, 200 burst
    rate-limit:
      rateLimit:
        average: 100
        burst: 200
 
    # Basic auth for dashboard (generate with: htpasswd -nB admin)
    dashboard-auth:
      basicAuth:
        users:
          - "admin:$2y$05$..."   # replace with htpasswd output

Generate the bcrypt password hash:

# Install apache2-utils if needed
sudo apt install apache2-utils -y
 
# Generate hash — you'll be prompted for a password
htpasswd -nB admin
# Output: admin:$2y$05$abc123...

Paste the full user:hash string into dashboard-auth.users.


Step 4 — Certificate Storage

Create the acme.json file and restrict its permissions. Let's Encrypt will refuse to write if this file is world-readable:

touch ~/traefik/certs/acme.json
chmod 600 ~/traefik/certs/acme.json

Step 5 — Docker Compose

Create docker-compose.yml:

networks:
  proxy:
    external: true
 
services:
  traefik:
    image: traefik:v3.3
    container_name: traefik
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
    networks:
      - proxy
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /etc/localtime:/etc/localtime:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./config/traefik.yml:/config/traefik.yml:ro
      - ./config/middleware.yml:/config/middleware.yml:ro
      - ./certs/acme.json:/certs/acme.json
      - ./logs:/logs
    labels:
      - "traefik.enable=true"
      # Dashboard router
      - "traefik.http.routers.dashboard.rule=Host(`traefik.example.com`)"
      - "traefik.http.routers.dashboard.entrypoints=websecure"
      - "traefik.http.routers.dashboard.tls.certresolver=letsencrypt"
      - "traefik.http.routers.dashboard.service=api@internal"
      - "traefik.http.routers.dashboard.middlewares=dashboard-auth@file,secure-headers@file"

Create the external proxy network before starting:

docker network create proxy

Start Traefik:

docker compose up -d
docker compose logs -f traefik

Watch the logs for msg="Starting provider" and msg="Configuration loaded". After 30–60 seconds you should see certificate acquisition messages if DNS is resolving correctly.


Step 6 — Add Your First Service

Any container on the proxy network with the right labels is automatically discovered. Here's a simple example using Whoami (a request inspector):

# whoami/docker-compose.yml
networks:
  proxy:
    external: true
 
services:
  whoami:
    image: traefik/whoami:latest
    container_name: whoami
    restart: unless-stopped
    networks:
      - proxy
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.whoami.rule=Host(`whoami.example.com`)"
      - "traefik.http.routers.whoami.entrypoints=websecure"
      - "traefik.http.routers.whoami.tls.certresolver=letsencrypt"
      - "traefik.http.routers.whoami.middlewares=secure-headers@file,rate-limit@file"
      - "traefik.http.services.whoami.loadbalancer.server.port=80"
cd whoami && docker compose up -d

Traefik detects the new container immediately — no restart required. Visit https://whoami.example.com and you'll see the request headers including X-Forwarded-For and X-Real-Ip injected by Traefik.

For a service that listens on a non-standard port, set:

- "traefik.http.services.myapp.loadbalancer.server.port=8080"

Step 7 — Real-World Service Example (Portainer)

networks:
  proxy:
    external: true
 
services:
  portainer:
    image: portainer/portainer-ce:latest
    container_name: portainer
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
    networks:
      - proxy
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - portainer_data:/data
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.portainer.rule=Host(`portainer.example.com`)"
      - "traefik.http.routers.portainer.entrypoints=websecure"
      - "traefik.http.routers.portainer.tls.certresolver=letsencrypt"
      - "traefik.http.services.portainer.loadbalancer.server.port=9000"
      - "traefik.http.routers.portainer.middlewares=secure-headers@file"
 
volumes:
  portainer_data:

Step 8 — DNS Challenge for Internal Services (Optional)

If you want TLS certificates for services that are not publicly reachable (e.g., internal homelab hosts behind a LAN), switch to DNS-01 challenge. Example for Cloudflare:

# In traefik.yml — add alongside or replace httpChallenge
certificatesResolvers:
  letsencrypt:
    acme:
      email: you@example.com
      storage: /certs/acme.json
      dnsChallenge:
        provider: cloudflare
        resolvers:
          - "1.1.1.1:53"
          - "8.8.8.8:53"

Add the Cloudflare API token to your environment or a .env file:

CF_DNS_API_TOKEN=your_cloudflare_api_token

Mount it into the Traefik container:

environment:
  - CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN}

Traefik supports 30+ DNS providers (Route53, DigitalOcean, Namecheap, etc.) via lego.


Testing

Verify TLS Certificate

# Check certificate issuer and expiry
openssl s_client -connect whoami.example.com:443 -servername whoami.example.com \
  </dev/null 2>/dev/null | openssl x509 -noout -issuer -dates

Expected output:

issuer=C=US, O=Let's Encrypt, CN=R11
notBefore=Apr 29 00:00:00 2026 GMT
notAfter=Jul 28 00:00:00 2026 GMT

Verify HTTP → HTTPS Redirect

curl -I http://whoami.example.com
# HTTP/1.1 301 Moved Permanently
# Location: https://whoami.example.com/

Verify Security Headers

curl -sI https://whoami.example.com | grep -E "Strict|X-Frame|X-Content|Referrer"
# Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
# X-Frame-Options: DENY
# X-Content-Type-Options: nosniff
# Referrer-Policy: same-origin

SSL Labs Score

Run your domain through SSL Labs — with the default Traefik v3 TLS settings and the security headers middleware above, you should score A or A+.

Check Traefik Dashboard

Visit https://traefik.example.com (using the credentials from Step 3) to see:

  • All discovered routers and services
  • Certificate status and expiry
  • Middleware assignments
  • Health of each backend

Monitoring & Observability

Prometheus Metrics

Enable the built-in metrics endpoint in traefik.yml:

metrics:
  prometheus:
    addEntryPointsLabels: true
    addServicesLabels: true
    addRoutersLabels: true
    entryPoint: metrics
 
entryPoints:
  metrics:
    address: ":8082"

Add the metrics entrypoint port to the Docker Compose ports section (8082:8082) and configure a Prometheus scrape job:

# prometheus.yml
scrape_configs:
  - job_name: traefik
    static_configs:
      - targets: ["traefik:8082"]

Import the official Traefik Grafana dashboard (ID 17346) for instant visibility into request rates, error rates, and certificate status.

Log Rotation

Add logrotate config to prevent logs from filling disk:

sudo tee /etc/logrotate.d/traefik > /dev/null <<'EOF'
/home/user/traefik/logs/*.log {
    daily
    rotate 14
    compress
    missingok
    notifempty
    sharedscripts
    postrotate
        docker kill --signal="USR1" traefik
    endscript
}
EOF

Troubleshooting

SymptomLikely CauseFix
Certificate not issuedPort 80 blocked or DNS not resolvingCheck firewall and DNS propagation with dig +short yourdomain.com
acme.json permission errorFile is world-readablechmod 600 certs/acme.json
Service not appearing in dashboardContainer not on proxy networkAdd networks: [proxy] to service
404 from TraefikRouter rule mismatchDouble-check Host() label matches the request hostname exactly
Let's Encrypt rate limitToo many certificate requestsUse staging resolver (https://acme-staging-v02.api.letsencrypt.org/directory) during testing

Switch to the Let's Encrypt staging environment during development to avoid hitting rate limits (5 certificates per domain per week):

certificatesResolvers:
  staging:
    acme:
      caServer: "https://acme-staging-v02.api.letsencrypt.org/directory"
      email: you@example.com
      storage: /certs/acme-staging.json
      httpChallenge:
        entryPoint: web

Extensions & Next Steps

IP Allowlisting for Admin Services

Restrict access to internal tools by source IP:

# In middleware.yml
http:
  middlewares:
    lan-only:
      ipAllowList:
        sourceRange:
          - "192.168.1.0/24"
          - "10.0.0.0/8"

Apply it to internal services:

- "traefik.http.routers.portainer.middlewares=lan-only@file,secure-headers@file"

Forward Authentication with Authentik or Authelia

Replace per-service basic auth with SSO by placing an auth middleware in front of every service:

# middleware.yml
http:
  middlewares:
    authentik:
      forwardAuth:
        address: "http://authentik:9000/outpost.goauthentik.io/auth/traefik"
        trustForwardHeader: true
        authResponseHeaders:
          - X-authentik-username
          - X-authentik-groups

Automatic Container Labeling with Labels Templates

For teams with many similar services, use a .env file with a shared label template and Docker Compose's extension fields (x-traefik-labels) to DRY up repetitive label blocks.

High Availability

Run two Traefik instances with a shared Redis backend for certificate storage and state:

certificatesResolvers:
  letsencrypt:
    acme:
      storage: redis://redis:6379

This allows zero-downtime Traefik updates and active-active load balancing via keepalived or your cloud provider's LB.


Summary

You now have a production-grade reverse proxy that:

  • Terminates TLS for every service with auto-renewing Let's Encrypt certificates
  • Enforces HTTPS, security headers, and rate limiting globally
  • Auto-discovers new containers the moment they start — zero manual config
  • Exposes Prometheus metrics ready for Grafana dashboards

The pattern scales from a single Raspberry Pi running three services to a multi-node Docker Swarm cluster with hundreds of containers. Once Traefik is in place, adding a new service is three labels and a docker compose up.