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:
| Part | What it does | In my setup |
|---|---|---|
| Entrypoint | A port Traefik listens on | web (80) and websecure (443) |
| Router | A rule that matches requests, usually by host name | one per service, e.g. Host(`vault.example.com`) |
| Middleware | Something that changes or checks a request on the way | the HTTP-to-HTTPS redirect (more in the hardening article) |
| Service | Where the request finally goes | a 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
- 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. - Router: forward ports 80 and 443 from your internet router to the Docker host. Nothing else needs to be open.
- A shared Docker network that Traefik and all published containers join:
docker network create proxy
- 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 labeltraefik.enable=trueget published. Without this, every container you start would be reachable from the internet.providers.docker.network=proxy: Traefik talks to containers through the sharedproxynetwork. 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
.ymlfile in/dynamicis read as extra routes, andwatch=trueapplies changes on save. That’s how services outside Docker get in (step 5). - Redirect: a request to
http://is answered with a redirect tohttps://. 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:
| Tag | What you get on the next pull | Risk |
|---|---|---|
latest | Always 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 |
v3 | Every new 3.x release (3.7, 3.8, …) | Low: minor versions add features and only rarely change behaviour |
v3.7 | Only 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.16 | Exactly this version, forever | None 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 exampleHost(`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 see | Usual cause |
|---|---|
404 page not found from Traefik | No router matches: typo in the host name, traefik.enable=true missing, or the container isn’t running |
502 Bad Gateway | Router found, but Traefik can’t reach the service: wrong server.port, or the container isn’t in the proxy network |
504 Gateway Timeout | The 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 page | Two 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=falseis not optional. Publishing should always be a deliberate decision per container.- One shared network and
traefik.docker.network=proxyon every service saved me from most 502 errors. - Back up
acme.jsontogether 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.