Traefik v3 Reverse Proxy - Automatic HTTPS and Label-Based Routing for Docker

Status: Active
Last Updated: 2026-08-26
Category: Networking - Phase 3: Reverse Proxies
Prerequisites: tcp-ip-fundamentals, dns-fundamentals, docker-networking
Time: 2-3 hours
Tags: traefik, reverse-proxy, docker, letsencrypt, tls, middleware, rate-limit, basic-auth, security-headers, compose

Summary

Traefik is a modern edge router/reverse proxy that discovers services automatically โ€” in this guide, from Docker container labels โ€” and terminates TLS with certificates it obtains and renews itself from Let's Encrypt. This lesson stands up a production-ready Traefik v3 instance with Docker Compose: two entrypoints (HTTP/HTTPS), automatic ACME certs, label-based routing, and the three middlewares almost every deployment needs (rate limiting, basic auth, security headers).

๐ŸŽฏ What You'll Learn

By the end of this article, you'll be able to:


Table of Contents

  1. What Is a Reverse Proxy?
  2. Traefik's Mental Model
  3. Installing Traefik v3 with Docker Compose
  4. Automatic Let's Encrypt Certificates
  5. Routing Containers with Labels
  6. Common Middlewares
  7. The Dashboard
  8. Troubleshooting
  9. Key Takeaways
  10. Next Steps

What Is a Reverse Proxy?

A forward proxy acts on behalf of clients going out. A reverse proxy acts on behalf of servers coming in: clients hit one public endpoint, and the proxy decides which internal service actually answers.

Why put one at the edge?

Concern Without reverse proxy With reverse proxy
TLS Each app manages its own certs One component terminates TLS for everything
Ports Apps fight over 80/443 or use ugly :8081 URLs Every app gets a clean hostname on standard ports
Exposure Each container publishes ports to the host Only the proxy is public; backends stay on an internal network
Cross-cutting policy Reimplemented per app Rate limits, auth, headers applied centrally

Other well-known reverse proxies are Nginx, Caddy, HAProxy, and Apache httpd. Traefik's differentiator is dynamic configuration discovery: instead of writing server {} blocks by hand, Traefik watches providers (Docker, Kubernetes, files) and builds its routing table live as containers come and go.

Traefik's Mental Model

Every request flows through four concepts (this is the v3 model documented under Routing & Load Balancing):

```text
Client
  โ”‚
  โ–ผ
EntryPoint (:443)          โ”€โ”€ where traffic enters
  โ–ผ
Router                     โ”€โ”€ matches a rule (e.g. Host(`app.example.com`))
  โ–ผ
Middleware(s)              โ”€โ”€ transforms the request (auth, rate limit, headers)
  โ–ผ
Service                    โ”€โ”€ load-balances to the actual backend (your container)

Configuration comes from static settings (entrypoints, providers, cert resolvers โ€” CLI flags or traefik.yml) and dynamic settings (routers/middlewares/services โ€” labels, files, APIs).

Installing Traefik v3 with Docker Compose

Directory layout:

traefik/
โ”œโ”€โ”€ docker-compose.yml
โ”œโ”€โ”€ traefik/                 # mounted as /etc/traefik
โ”‚   โ”œโ”€โ”€ traefik.yml          # static config
โ”‚   โ””โ”€โ”€ acme.json            # created manually; Traefik stores certs here (chmod 600)
โ””โ”€โ”€ .env                     # domain + acme email

Static configuration โ€” traefik.yml

entryPoints:
  web:
    address: ":80"
    http:
      redirections:
        entryPoint:
          to: websecure
          scheme: https
  websecure:
    address: ":443"

providers:
  docker:
    endpoint: "unix:///var/run/docker.sock"
    exposedByDefault: false        # only route containers that opt in with traefik.enable=true
    network: proxy                  # default network to reach backends

certificatesResolvers:
  letsencrypt:
    acme:
      email: "${ACME_EMAIL}"
      storage: /etc/traefik/acme.json
      httpChallenge:
        entryPoint: web

api:
  dashboard: true                   # enable, but keep it OFF the public internet (see below)

log:
  level: INFO
accessLog: {}

Two lines matter enormously:

docker-compose.yml

services:
  traefik:
    image: traefik:v3.3
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    environment:
      - ACME_EMAIL=${ACME_EMAIL}
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
      - ./traefik/acme.json:/etc/traefik/acme.json
    networks:
      - proxy

networks:
  proxy:
    name: proxy

.env:

ACME_EMAIL=you@example.com

Bring it up:

mkdir -p traefik && touch traefik/acme.json && chmod 600 traefik/acme.json
docker compose up -d
docker compose logs -f traefik     # watch for ACME errors early

Security note: mounting the Docker socket gives Traefik root-equivalent access to the host. Read-only (:ro) is the minimum; if that bothers you, look at socket proxies (e.g. tecnativa/docker-socket-proxy) or running Traefik outside Docker with a file provider.

Automatic Let's Encrypt Certificates

The pieces wired above do the whole job:

  1. certificatesResolvers.letsencrypt defines an ACME resolver using Let's Encrypt.
  2. httpChallenge.entryPoint: web means the challenge is answered on port 80 โ€” so DNS for the hostname must already point at this server, and nothing else may own :80.
  3. storage: /etc/traefik/acme.json persists issued certs across restarts. If you forget the file or mount it read-write-less, Traefik will happily re-request certificates until it hits Let's Encrypt rate limits (5 duplicates/week per exact hostname set).

Verify issuance in the logs:

docker compose logs traefik | grep -i acme

Alternatives worth knowing: the TLS-ALPN-01 challenge (works when port 80 is unavailable but 443 is), and the DNS-01 challenge (needed for wildcard certs like *.example.com, requires a supported DNS provider API). All are options under the same acme: block.

For staging tests, add caServer: https://acme-staging-v02.api.letsencrypt.org/directory first so you don't burn production rate limits while debugging.

Routing Containers with Labels

With the provider watching Docker and exposedByDefault: false, enabling routing for any service is pure labels:

services:
  whoami:
    image: traefik/whoami
    restart: unless-stopped
    networks:
      - proxy
    labels:
      - "traefik.enable=true"
      # Router: match the host, use HTTPS entrypoint, resolve certs
      - "traefik.http.routers.whoami.rule=Host(`whoami.example.com`)"
      - "traefik.http.routers.whoami.entrypoints=websecure"
      - "traefik.http.routers.whoami.tls.certresolver=letsencrypt"
      # Service: which port inside the container to forward to
      - "traefik.http.services.whoami.loadbalancer.server.port=80"
    # no "ports:" section needed โ€” Traefik reaches it over the shared network

networks:
  proxy:
    external: true

Notes:

Test: point DNS at the server, then curl -v https://whoami.example.com โ€” you should get a valid cert and the whoami response including the X-Forwarded-* headers Traefik added.

Common Middlewares

Middlewares are declared dynamically (labels here) and attached by adding them to a router:

- "traefik.http.routers.<router>.middlewares=<name@docker>"

Rate limiting

Per the RateLimit middleware docs, the token bucket takes an average (steady requests/sec), a period (how often that average refills), and a burst (allowed momentary spike). By default the client is identified by its source IP.

- "traefik.http.middlewares.web-ratelimit.ratelimit.average=10"
- "traefik.http.middlewares.web-ratelimit.ratelimit.period=1s"
- "traefik.http.middlewares.web-ratelimit.ratelimit.burst=20"

Behind another LB/NAT? Set sourceCriterion.ipStrategy.depth or key on a header instead, otherwise everyone shares one bucket.

Basic auth

Generate a hash (note: $ signs need escaping or single-quoting in Compose):

# apt install apache2-utils
htpasswd -nB admin
# New password: ********
# admin:$2y$05$e6kPz0zW... (copy this)
- "traefik.http.middlewares.admin-auth.basicauth.users=admin:$$2y$$05$$e6kPz0zW..."

Double each $ in Compose files (or use ${...}-free single quotes in a dynamic file provider). For anything beyond throwaway protection, prefer ForwardAuth against a real identity provider (Authelia, Authentik, OAuth2 Proxy).

Security headers

The Headers middleware sets response headers:

- "traefik.http.middlewares.secure-headers.headers.stsSeconds=31536000"
- "traefik.http.middlewares.secure-headers.headers.stsIncludeSubdomains=true"
- "traefik.http.middlewares.secure-headers.headers.browserXssFilter=true"
- "traefik.http.middlewares.secure-headers.headers.contentTypeNosniff=true"
- "traefik.http.middlewares.secure-headers.headers.frameDeny=true"
- "traefik.http.middlewares.secure-headers.headers.referrerPolicy=no-referrer-when-downgrade"

Chaining them together

- "traefik.http.routers.whoami.entrypoints=websecure"
- "traefik.http.routers.whoami.rule=Host(`whoami.example.com`)"
- "traefik.http.routers.whoami.tls.certresolver=letsencrypt"
- "traefik.http.routers.whoami.middlewares=secure-headers,web-ratelimit"

Order matters: middlewares run left-to-right on requests.

The Dashboard

api.dashboard: true enables it, but never expose it publicly unauthenticated. Safe pattern โ€” route it through basic auth on an internal hostname:

# On the traefik service itself:
labels:
  - "traefik.enable=true"
  - "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=admin-auth"

Even safer: bind it to localhost only and open an SSH tunnel when you need it.

Troubleshooting

Symptom Likely cause / fix
404 page not found Router rule doesn't match, or container lacks traefik.enable=true; check the dashboard/router list
Certificate not issued DNS doesn't point here yet, port 80 blocked, or wrong ACME email; check docker compose logs traefik | grep -i acme
Hit Let's Encrypt rate limit You restarted with an empty/unwritable acme.json. Fix permissions (600, correct mount) and test against the staging CA
502 Bad Gateway Backend listening on a different port than server.port, or container isn't on the proxy network
Gateway Timeout Backend app bound to 127.0.0.1 inside its container instead of 0.0.0.0
Middleware ignored Typo in name, or missing the @docker provider suffix; names must match exactly
Labels silently ignored YAML indentation error, or labels on the wrong service in a multi-service Compose file
Dashboard unreachable You enabled the API but never defined a router to api@internal โ€” that's by design

Debug escalation path: log.level: DEBUG in traefik.yml, then read the logs โ€” Traefik prints exactly why a router was skipped or a challenge failed.

Key Takeaways

Next Steps

Sources & Related

Sources consulted during research (Traefik v3.3 documentation and the Traefik user guides):

Related KB articles:

Change Log

Choose Theme

Your selection is saved locally.

Neural Cacophony
Aperture v2
Flux v1
Mosaic Chaos
Nexus v1
Nexus Zest
Prism v2
Synapse