Skip to content

Traefik v3: Automatic Routing, TLS, and Middleware for Docker

Running multiple Docker apps behind Nginx means writing upstream blocks, running nginx -s reload, and doing it again every time a container restarts with a new IP. Nginx is a file-driven proxy built for static infrastructure. Docker is the opposite: containers die, restart, scale, and get replaced continuously. The mismatch is not subtle, it creates a permanent maintenance loop.

Traefik eliminates that loop. It watches the Docker socket directly. When a container starts with the right labels, Traefik builds the route. When the container stops, the route disappears. No config files per service, no reload commands, no manual certificate management.

This post covers Traefik's architecture, a direct comparison with Nginx and Nginx Proxy Manager, and a complete production setup: static config, Docker Compose, label-based routing, Let's Encrypt TLS, HTTP-to-HTTPS redirect, path-based routing, and dashboard access via SSH tunnel.

Assumed knowledge: Docker Compose basics, what a reverse proxy does.

Difficulty: Intermediate

Assumes working knowledge of Docker, Linux, and basic networking concepts.

Prerequisites

  • Docker Engine 24+
  • Docker Compose v2
  • A domain with DNS you control (required for Let's Encrypt TLS)
  • A Linux server with ports 80 and 443 accessible

The examples use infra_net as the shared Docker network, consistent with the setup in Self-Hosted Dev Infrastructure with Docker Compose.

Versions used in this post

Traefik v3.2


Background

How Traefik works

Traefik is an edge router, it sits in front of your containers and decides which one handles each incoming request. It was designed specifically for environments where services are added, removed, and restarted constantly. That assumption drives every design decision: the config model, the discovery mechanism, the TLS handling.

The architecture has two distinct layers.

Static config loads once at startup. It defines entrypoints (the ports Traefik listens on), providers (where Traefik discovers services), and certificate resolvers. It cannot be hot-reloaded. Any change requires a Traefik restart.

Dynamic config is discovered continuously at runtime. The Docker provider watches the socket. When a container starts with traefik.enable=true, Traefik reads its labels and builds the route immediately. When the container stops, the route disappears. No restart, no config edits, no downtime.

This separation is what makes Traefik operationally useful for Docker. Your routing table updates itself.

Why Nginx falls short in Docker environments

Nginx stores routing config in files. Every upstream server, every virtual host, every proxy_pass block is a .conf file you write manually and reload manually. That model works when your server topology changes rarely. It breaks down in Docker environments.

When a container restarts, Docker assigns a new internal IP. Nginx still holds the old upstream IP. Requests fail with "connection refused" until you edit the config and run nginx -s reload. At ten containers, this becomes a constant maintenance burden.

The deeper issue is that Nginx has no Docker integration whatsoever. It has no concept of containers. You are the synchronization layer between Docker's state and Nginx's config and that synchronization is always one container restart away from being wrong.

Nginx Proxy Manager extends Nginx with a GUI but does not change its underlying limitations. NPM generates static Nginx config files behind the scenes. Clicking "Save" writes a .conf and reloads Nginx. The routing table still does not update automatically when containers restart or scale. The GUI itself becomes a single point of failure: if the NPM container is down, you cannot manage routes. Every route lives in a SQLite database inside the GUI container not version-controlled, not scriptable.

NPM routing state is not reproducible

NPM stores its routing table in a SQLite database inside a GUI container. If that container fails or its volume is lost, your routing table is gone. Traefik's routing state lives in your docker-compose.yml, which is version-controlled and can be reproduced on any server in minutes.

The operational difference is concrete:

Capability Nginx / NPM Traefik
Route discovery on container start Manual config + reload Automatic from labels
Route removal on container stop Manual config edit Automatic
TLS certificate management Certbot + cron Built-in, auto-renewing
Config version control Possible with .conf files Labels live in Compose file
Reusable middleware Separate blocks per service Named, referenced by label
Zero-downtime routing changes No (requires reload) Yes
Automation API None natively Full HTTP API

Nginx is an excellent proxy for static workloads. The problem is fit, not quality.


Implementation

1. Define the shared Docker network

All containers Traefik routes to must share a network. Define it once here. External app stacks reference it with external: true.

docker-compose.yml
networks:
  infra_net:
    driver: bridge
    name: infra_net

volumes:
  letsencrypt:
  traefik_logs:

The explicit name: infra_net is required. Without it, Docker prefixes the name with the Compose project name and external references break.

2. Static configuration

This file is read once at startup. Mount it read-only.

traefik/traefik.yml
api:
  dashboard: true

entryPoints:
  web:
    address: ":80"
  websecure:
    address: ":443"
  traefik:
    address: ":8080"

providers:
  docker:
    exposedByDefault: false
    network: infra_net

certificatesResolvers:
  le:
    acme:
      email: [email protected]
      storage: /letsencrypt/acme.json
      httpChallenge:
        entryPoint: web

log:
  level: INFO

accessLog:
  filePath: /logs/access.log

exposedByDefault: false is the single most important setting in this file. Without it, every container on infra_net gets a public route, including your databases and internal services. Always set this to false and opt services in explicitly with traefik.enable=true.

The httpChallenge ACME resolver uses port 80 to verify domain ownership. Traefik handles the HTTP-01 challenge automatically, fetches the certificate, and renews it before expiry. No certbot, no cron job.

Never expose the Traefik dashboard on 0.0.0.0

In insecure mode, the dashboard shows your full routing table, middleware config, and container topology with no authentication. Always bind it to 127.0.0.1:8080 and access it via SSH tunnel. Exposing it publicly is an information disclosure vulnerability with no upside.

3. Traefik service in Docker Compose

docker-compose.yml
services:
  traefik:
    image: traefik:v3.2
    container_name: traefik
    restart: unless-stopped
    ports:
      - "0.0.0.0:80:80"
      - "0.0.0.0:443:443"
      - "127.0.0.1:8080:8080"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
      - letsencrypt:/letsencrypt
      - traefik_logs:/logs
    networks:
      - infra_net
    labels:
      - "traefik.enable=false"

Three things to note:

  • traefik.enable=false on the Traefik container itself prevents Traefik from creating a self-referential route.
  • The Docker socket is mounted :ro (read-only). Traefik can read container metadata but cannot issue Docker write commands.
  • Dashboard port 8080 binds to 127.0.0.1 only, not reachable from outside the host.

4. Routing a service with labels

Labels on a container are the routing config. This routes a backend API to api.example.com over HTTPS with an auto-issued certificate:

docker-compose.yml
  api:
    image: your-api-image:latest
    container_name: api
    restart: unless-stopped
    networks:
      - infra_net
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.api.rule=Host(`api.example.com`)"
      - "traefik.http.routers.api.entrypoints=websecure"
      - "traefik.http.routers.api.tls=true"
      - "traefik.http.routers.api.tls.certresolver=le"
      - "traefik.http.services.api.loadbalancer.server.port=8000"

What each label does:

  • traefik.enable=true opt this container into Traefik routing
  • routers.api.rule match incoming requests by Host header
  • routers.api.entrypoints=websecure listen on port 443
  • routers.api.tls=true terminate TLS on this router
  • routers.api.tls.certresolver=le request a cert from the Let's Encrypt resolver
  • services.api.loadbalancer.server.port=8000 forward traffic to port 8000 inside the container

Traefik infers the container's IP from the shared Docker network. You never specify an IP address anywhere.

When this container starts, the route appears in the dashboard within seconds. When it stops, the route disappears. No config edit, no reload.

5. HTTP to HTTPS redirect

Every service needs two routers: one on websecure for real traffic and one on web that redirects to HTTPS.

docker-compose.yml
    labels:
      - "traefik.enable=true"

      # HTTPS router
      - "traefik.http.routers.api.rule=Host(`api.example.com`)"
      - "traefik.http.routers.api.entrypoints=websecure"
      - "traefik.http.routers.api.tls=true"
      - "traefik.http.routers.api.tls.certresolver=le"
      - "traefik.http.services.api.loadbalancer.server.port=8000"

      # HTTP redirect router
      - "traefik.http.routers.api-http.rule=Host(`api.example.com`)"
      - "traefik.http.routers.api-http.entrypoints=web"
      - "traefik.http.routers.api-http.middlewares=redirect-https"

      # Redirect middleware definition
      - "traefik.http.middlewares.redirect-https.redirectscheme.scheme=https"
      - "traefik.http.middlewares.redirect-https.redirectscheme.permanent=true"

Define middlewares once in a dynamic file

When you have more than two or three services, defining redirect-https inline in every Compose file creates repetition. Move middleware definitions to a dynamic.yml file provider and reference them by name. One change propagates to every router that uses the middleware.

6. Path-based routing

For services reachable at a path rather than a subdomain, use PathPrefix:

docker-compose.yml
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.docs.rule=Host(`example.com`) && PathPrefix(`/docs`)"
      - "traefik.http.routers.docs.entrypoints=websecure"
      - "traefik.http.routers.docs.tls=true"
      - "traefik.http.routers.docs.tls.certresolver=le"
      - "traefik.http.services.docs.loadbalancer.server.port=8080"

Ambiguous PathPrefix routing

If two routers both use PathPrefix(/) on the same entrypoint, Traefik routes all traffic to whichever registered first. The second router receives nothing and logs no error. Use specific path prefixes or separate entrypoints to keep routing deterministic.

7. Accessing the dashboard via SSH tunnel

The dashboard is on port 8080, bound to 127.0.0.1. Forward it to your local machine over SSH:

terminal
ssh -L 8080:127.0.0.1:8080 user@your-server

Open http://localhost:8080/dashboard/ in your browser. You can inspect every router, service, middleware, and certificate Traefik has discovered, no config files involved.


Testing

Bring the stack up:

terminal
docker compose up -d

Confirm Traefik has loaded the Docker provider:

terminal
curl -s http://localhost:8080/api/providers | jq 'keys'
expected output
["docker"]

List all routers Traefik has built:

terminal
curl -s http://localhost:8080/api/http/routers | jq '.[].name'
expected output
"api@docker"
"api-http@docker"

Test the HTTPS route end-to-end:

terminal
curl -I https://api.example.com
expected output
HTTP/2 200
server: traefik

Verify the HTTP-to-HTTPS redirect:

terminal
curl -I http://api.example.com
expected output
HTTP/1.1 301 Moved Permanently
Location: https://api.example.com/

Confirm the TLS certificate is valid:

terminal
curl -v https://api.example.com 2>&1 | grep "SSL certificate"
expected output
SSL certificate verify ok.

Verify acme.json has correct permissions inside the container. Let's Encrypt will not write to it otherwise:

terminal
docker exec traefik ls -la /letsencrypt/acme.json
expected output
-rw------- 1 root root ... acme.json

If permissions are wrong, Traefik logs a warning at startup and the ACME resolver fails silently on the first certificate request.


Pitfalls

acme.json must be mode 600

Let's Encrypt certificate storage requires acme.json to be readable only by its owner. If the file has permissions broader than 600, Traefik refuses to use it and logs "file permissions should be 600". The named volume approach in this post avoids this because Docker creates the file with the correct owner. If you use a bind mount instead, run chmod 600 acme.json before starting Traefik.

Let's Encrypt production rate limits

The production ACME API allows 5 failed validation attempts per hour per domain. If your DNS is misconfigured or port 80 is blocked, you will exhaust the limit and be locked out for an hour. Always test with the staging CA first: add caServer: https://acme-staging-v02.api.letsencrypt.org/directory under the acme: block. Staging certificates are browser-untrusted, but they confirm the full ACME flow. Switch to production only after staging succeeds.

Docker socket access is host-wide

Mounting /var/run/docker.sock:ro gives Traefik read access to all container metadata on the host. Any container with socket access can enumerate every other container running. The :ro flag prevents write operations, but the Docker socket API has no fine-grained permission model. In high-security environments, use Traefik's file provider and remove the socket mount. You trade auto-discovery for a reduced attack surface.

Container must declare infra_net itself

Adding a container to infra_net in Traefik's Compose file is not sufficient. The target container must also declare networks: [infra_net] in its own Compose file. If a container does not appear in the dashboard despite having traefik.enable=true, check this first it is the most common cause.


Production considerations

Use Let's Encrypt staging during initial setup. Rate limit violations on the production endpoint can block certificate issuance for your domain for up to a week. Verify the full ACME flow on staging, then switch.

Back up acme.json off the server. This file contains your private keys and issued certificates. If the volume is lost, Traefik requests new certificates on next startup but your previous certificates are unrecoverable. Store encrypted backups outside the server, automated on a schedule.

Ship access logs to an aggregator. Set accessLog.filePath in the static config. The default format is Common Log Format. When something breaks at 3AM, the access log is usually the first place showing what changed. Logs left on disk fill the volume and are unavailable if the container is down.

Enable Prometheus metrics. Add metrics: { prometheus: {} } to the static config. The /metrics endpoint at http://localhost:8080/metrics exposes request counts, latency histograms, and certificate expiry timestamps. Alert on certificate expiry at 30 days gives enough runway to debug renewal failures before the cert actually expires.

Move middleware to a file provider for multi-engineer environments. Labels are per-service. A dynamic.yml file-defined secure-headers middleware can be referenced by every router with one label and updated in one place. Inline middleware definitions in labels do not scale past a handful of services.


Wrapping up

Traefik turns Docker labels into live routes with no config reload, no certificate management scripts, and no per-service proxy config files. The comparison with Nginx is not about raw performance. Nginx is faster for static file serving. It is about operational fit. Nginx was built for stable upstream servers and infrequent config changes. Traefik was built for environments where containers restart, scale, and get replaced continuously. The right tool depends entirely on which world your infrastructure lives in.

The next layer to harden is network security: which ports should be public, how to lock down internal services, and how Docker's iptables rules interact with UFW.


Comments