all systems self-hosted

~/selfhostjournal $

← All articles

Traefik as a reverse proxy for Docker: my configuration explained

8–12 minutes

In short

Problem: Every self-hosted service wants its own port, its own certificate and its own way to be reached from outside. After the third service, that gets messy.

Solution: One Traefik container in front of everything. It answers on ports 80 and 443, gets Let’s Encrypt certificates on its own, and sends each request to the right service, based on a few labels in each docker-compose.yml.

Tested with: Traefik 3.7, Docker Compose on a small home server, Let’s Encrypt, about ten services (Docker containers and one Home Assistant VM)

Traefik is the front door of my homelab. Every request from the internet arrives there first, and Traefik decides which service gets it. This article explains my configuration line by line: how Traefik is set up, how a container gets published with labels, how services outside Docker are added, and how the certificates work. Security hardening (geo-blocking, CrowdSec, single sign-on in front of services) is left out on purpose. It gets its own article, because it builds on everything here.

1. How Traefik thinks

Traefik has four building blocks. Once these are clear, every config line makes sense:

PartWhat it doesIn my setup
EntrypointA port Traefik listens onweb (80) and websecure (443)
RouterA rule that matches requests, usually by host nameone per service, e.g. Host(`vault.example.com`)
MiddlewareSomething that changes or checks a request on the waythe HTTP-to-HTTPS redirect (more in the hardening article)
ServiceWhere the request finally goesa container port or a URL in the local network

There are also two kinds of configuration. The static configuration is read once at start: entrypoints, certificate settings, where to find routes. The dynamic configuration is the routes themselves. Traefik reads it from Docker labels and from files, and picks up changes without a restart.

2. Before you start

  1. DNS: an A record for every host name (for example vault.example.com) that points to your public IP address. A wildcard record works too.
  2. Router: forward ports 80 and 443 from your internet router to the Docker host. Nothing else needs to be open.
  3. A shared Docker network that Traefik and all published containers join:
docker network create proxy
  1. A file for the certificates. Traefik refuses to use it if other users can read it:
mkdir -p traefik-data/dynamic
touch traefik-data/acme.json
chmod 600 traefik-data/acme.json

3. The Traefik container

I put the whole static configuration into the command section of the compose file. A separate traefik.yml works just as well; I like having everything in one place.

services:
  traefik:
    image: traefik:v3.7
    container_name: traefik
    restart: unless-stopped
    networks:
      - proxy
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik-data/acme.json:/acme.json
      - ./traefik-data/dynamic:/dynamic:ro
      - ./traefik-logs:/var/log/traefik
    command:
      # General
      - "--global.checknewversion=false"
      - "--global.sendanonymoususage=false"
      - "--log.level=INFO"
      - "--accesslog=true"
      - "--accesslog.filepath=/var/log/traefik/access.log"
      - "--api.dashboard=true"

      # Where routes come from
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--providers.docker.network=proxy"
      - "--providers.file.directory=/dynamic"
      - "--providers.file.watch=true"

      # Ports
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"

      # Send everything from http to https
      - "--entrypoints.web.http.redirections.entrypoint.to=websecure"
      - "--entrypoints.web.http.redirections.entrypoint.scheme=https"

      # Let's Encrypt
      - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
      - "--certificatesresolvers.letsencrypt.acme.email=you@example.com"
      - "--certificatesresolvers.letsencrypt.acme.storage=/acme.json"

      # No timeout for long uploads (Nextcloud)
      - "--entrypoints.web.transport.respondingTimeouts.readTimeout=0"
      - "--entrypoints.websecure.transport.respondingTimeouts.readTimeout=0"

networks:
  proxy:
    external: true

What the lines do:

  • Docker socket: Traefik watches Docker to see which containers start and stop and which labels they have. Read-only is enough. How to protect this further is part of the hardening article.
  • exposedbydefault=false: only containers with the label traefik.enable=true get published. Without this, every container you start would be reachable from the internet.
  • providers.docker.network=proxy: Traefik talks to containers through the shared proxy network. This matters when a container is in more than one network: without it, Traefik may pick an address it can’t reach.
  • File provider: every .yml file in /dynamic is read as extra routes, and watch=true applies changes on save. That’s how services outside Docker get in (step 5).
  • Redirect: a request to http:// is answered with a redirect to https://. No service needs its own redirect.
  • Access log: one line per request. It’s the first place to look when something doesn’t arrive, and later the input for intrusion detection.
  • readTimeout=0: Traefik’s default read timeout cut off large Nextcloud uploads. Zero means no limit. If you don’t upload big files, leave it out.

Which version tag to use

The part after the colon in image: traefik:v3.7 is the tag. It decides which Traefik version Docker downloads, and which version you get after the next docker compose pull. Traefik publishes the same release under several tags:

TagWhat you get on the next pullRisk
latestAlways the newest release, including the next major version (v4 one day)Highest: a major version can remove or rename options, and your routes stop working
v3Every new 3.x release (3.7, 3.8, …)Low: minor versions add features and only rarely change behaviour
v3.7Only bug and security fixes of 3.7 (3.7.16, 3.7.17, …)Lowest, but you have to change the tag yourself to move to 3.8
v3.7.16Exactly this version, foreverNone from updates, but you also get no security fixes until you change it

Traefik follows semantic versioning: the first number (major) may break your configuration, the second (minor) adds features, the third (patch) only fixes bugs and security issues. The safer the tag, the more often you have to update it by hand.

Why this matters for a reverse proxy in particular: when Traefik breaks, every service is offline at once, not just one. And in many homelabs the updates run automatically at night. With latest, a major release can take everything down at 4 am, and you find out in the morning when nothing loads.

My recommendation is v3 if your updates run automatically, or v3.7 if you update by hand and read the release notes anyway. Traefik is exposed to the internet, so don’t pin an exact version and then forget it. Security fixes only arrive with new patch releases.

Updating and going back are both one change in the compose file:

# which version runs right now?
docker exec traefik traefik version

# update: change the tag if needed (e.g. v3.7 -> v3.8), then
docker compose pull traefik
docker compose up -d traefik
docker logs traefik --since 2m    # look for errors

# something broke? put the old tag back and run the same two commands again

Tip: before a minor or major update, read the migration notes in the Traefik documentation (“Migration” section). They list exactly which options changed. For patch updates, that’s not necessary.

Start it with docker compose up -d and check docker logs traefik. There should be no errors, only a line saying the providers started.

4. Publishing a container with labels

This is where Traefik shines. A service doesn’t publish any port on the host. It joins the proxy network and describes its route with labels. Here is my Vaultwarden as an example, reduced to the Traefik part:

services:
  vaultwarden:
    image: vaultwarden/server:latest
    restart: unless-stopped
    networks:
      - proxy
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy"
      - "traefik.http.routers.vaultwarden.rule=Host(`vault.example.com`)"
      - "traefik.http.routers.vaultwarden.entrypoints=websecure"
      - "traefik.http.routers.vaultwarden.tls.certresolver=letsencrypt"
      - "traefik.http.routers.vaultwarden.service=vaultwarden"
      - "traefik.http.services.vaultwarden.loadbalancer.server.port=80"

networks:
  proxy:
    external: true
  • Router name (vaultwarden): any name, but unique across all containers. Copying a block and forgetting to rename it is the most common mistake; the second container then silently replaces the first.
  • rule: which host name belongs to this service. You can combine rules, for example Host(`example.com`) && PathPrefix(`/api`).
  • entrypoints=websecure: HTTPS only. Plain HTTP never reaches the router, because the entrypoint redirects it first.
  • tls.certresolver=letsencrypt: Traefik requests the certificate for the host name on the first request and renews it on its own.
  • server.port: the port inside the container, not one on the host. Traefik usually guesses it from the image, but stating it avoids surprises with images that expose several ports.

After docker compose up -d the service is online under its name, with a valid certificate. No Traefik restart, no port forwarding, no certificate handling.

5. Services outside Docker

My Home Assistant runs in a virtual machine, not in Docker, so it has no labels. For services like this I write the route as a file in the dynamic folder:

# traefik-data/dynamic/homeassistant.yml
http:
  routers:
    homeassistant:
      rule: "Host(`home.example.com`)"
      entryPoints:
        - websecure
      service: homeassistant-service
      tls:
        certResolver: letsencrypt

  services:
    homeassistant-service:
      loadBalancer:
        servers:
          - url: "http://192.0.2.20:8123"

The address is the VM’s local IP address and port. Save the file and the route is live; Traefik’s log shows that it read the new configuration. The same works for a NAS, a router’s web interface or anything else in your network that speaks HTTP.

Home Assistant also needs to know that it sits behind a proxy, or it rejects the requests with “400 Bad Request”. In its configuration.yaml:

http:
  use_x_forwarded_for: true
  trusted_proxies:
    - 192.0.2.10   # the Docker host running Traefik

6. Certificates with Let’s Encrypt

I use the TLS challenge: Let’s Encrypt connects to port 443, and Traefik proves it controls the host name right there. The advantages: no extra port, no DNS provider API key, and it works for every host name that points to your IP address. The limit: no wildcard certificates, for those you need the DNS challenge.

All certificates and the account key end up in acme.json. Back up this file. Without it, Traefik requests every certificate again after a rebuild, and Let’s Encrypt has rate limits. How I back up the whole homelab is explained in a separate article.

Watch the rate limits: while you are still experimenting, add --certificatesresolvers.letsencrypt.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory. The staging certificates aren’t trusted by browsers, but you can’t lock yourself out for a week by failing too often. Remove the line and empty acme.json once everything works.

7. The dashboard

Traefik’s dashboard shows every router, service and middleware it knows, and whether it has errors. It’s the fastest way to see why a route doesn’t work. I publish it like any other service, with labels on the Traefik container itself:

    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.traefik.rule=Host(`traefik.example.com`)"
      - "traefik.http.routers.traefik.entrypoints=websecure"
      - "traefik.http.routers.traefik.tls.certresolver=letsencrypt"
      - "traefik.http.routers.traefik.service=api@internal"

api@internal is Traefik’s built-in service for the dashboard; there’s no port to set.

Don’t leave the dashboard open: as shown here, anyone can see your whole setup. In my homelab it sits behind single sign-on. How that works is part of the hardening article. Until then, either don’t publish the dashboard at all or add at least a password (Traefik’s basicauth middleware).

8. When something doesn’t work

What you seeUsual cause
404 page not found from TraefikNo router matches: typo in the host name, traefik.enable=true missing, or the container isn’t running
502 Bad GatewayRouter found, but Traefik can’t reach the service: wrong server.port, or the container isn’t in the proxy network
504 Gateway TimeoutThe service is reachable but too slow, or a firewall drops the connection (typical for VMs)
Certificate “TRAEFIK DEFAULT CERT”Let’s Encrypt failed: port 443 not reachable from outside, DNS not pointing to you yet, or a rate limit. The reason is in docker logs traefik
A service shows another service’s pageTwo containers use the same router name

Two commands cover most cases:

# errors while reading routes or getting certificates
docker logs traefik 2>&1 | grep -i -E "error|acme"

# did the request reach Traefik, and what did it answer?
tail -f traefik-logs/access.log

What I learned

  • Labels beat config files for containers: the route lives next to the service it belongs to, and it disappears when the service does.
  • exposedbydefault=false is not optional. Publishing should always be a deliberate decision per container.
  • One shared network and traefik.docker.network=proxy on every service saved me from most 502 errors.
  • Back up acme.json together with the compose files.
  • A reverse proxy is not a firewall. It decides where a request goes, not whether it should get in. That’s the topic of the next article: geo-blocking, CrowdSec and single sign-on in front of Traefik’s routes.

Everything here worked in my setup at the time of writing. Software changes, and your setup is different: back up first, test carefully, and use the steps at your own risk. Details in the legal notice.