Traefik as a Reverse Proxy: Four Services Behind One Domain with Automatic HTTPS
A minimal Traefik v3 stack with Docker Compose: route four services behind one domain, automatic TLS via Let's Encrypt, plus the common pitfalls.
Anyone running several containers on one host quickly faces the same question: how do you get all of them behind one domain, each with valid HTTPS? Traefik solves this elegantly. It reads routing rules straight from Docker labels, fetches certificates from Let's Encrypt on its own, and needs no configuration file at all for a basic setup. This post walks through a working minimal stack with Traefik v3 that serves four services behind one domain – up and running in under 15 minutes.
What Traefik does – and when it fits
Traefik is a reverse proxy that pulls its configuration dynamically from a provider. With the Docker provider, Traefik watches the Docker socket: start a container with the right labels and the route appears instantly – no reload, no restart. This fits anywhere you publish several containerized services behind one or more domains and would rather not manage TLS certificates by hand.
The split into two levels matters. The static configuration defines how Traefik itself starts – entrypoints, providers and certificate resolvers. It rarely changes and is set here via CLI flags. The dynamic configuration describes the actual routes and is read at runtime from the container labels. This split explains why new services appear without restarting Traefik: only the dynamic level changes.
The request flow looks like this:
+-----------------------+
Internet --:443--> | Traefik |
(HTTPS) | entrypoint websecure |
+-----------+-----------+
|
+-----------+-----------+-----------+-----------+
| | | |
api.example app.example www.example www.example
| | | /docs
[ api ] [ app ] [ web ] [ docs ]
The minimal stack
We need exactly one file. Traefik itself is driven by CLI flags (the static configuration); the routes come via labels (the dynamic configuration) from the four services.
networks:
proxy:
services:
traefik:
image: traefik:v3.7
command:
# Docker provider: read routes from labels
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=proxy"
# Entrypoints: HTTP and HTTPS
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
# Redirect all HTTP to HTTPS
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
# Let's Encrypt via TLS-ALPN challenge
- "--certificatesresolvers.le.acme.email=admin@example.com"
- "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.le.acme.tlschallenge=true"
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./letsencrypt/acme.json:/letsencrypt/acme.json"
networks:
- proxy
api:
image: traefik/whoami
networks: [proxy]
labels:
- "traefik.enable=true"
- "traefik.http.routers.api.rule=Host(`api.example.com`)"
- "traefik.http.routers.api.entrypoints=websecure"
- "traefik.http.routers.api.tls.certresolver=le"
- "traefik.http.services.api.loadbalancer.server.port=80"
app:
image: traefik/whoami
networks: [proxy]
labels:
- "traefik.enable=true"
- "traefik.http.routers.app.rule=Host(`app.example.com`)"
- "traefik.http.routers.app.entrypoints=websecure"
- "traefik.http.routers.app.tls.certresolver=le"
- "traefik.http.services.app.loadbalancer.server.port=80"
web:
image: nginx:alpine
networks: [proxy]
labels:
- "traefik.enable=true"
- "traefik.http.routers.web.rule=Host(`www.example.com`)"
- "traefik.http.routers.web.entrypoints=websecure"
- "traefik.http.routers.web.tls.certresolver=le"
- "traefik.http.services.web.loadbalancer.server.port=80"
docs:
image: nginx:alpine
networks: [proxy]
labels:
- "traefik.enable=true"
- "traefik.http.routers.docs.rule=Host(`www.example.com`) && PathPrefix(`/docs`)"
- "traefik.http.routers.docs.entrypoints=websecure"
- "traefik.http.routers.docs.tls.certresolver=le"
- "traefik.http.services.docs.loadbalancer.server.port=80"
The labels line by line
Each service gets four related labels:
routers.<name>.ruledefines which requests the service receives.Host(...)matches the hostname,&&combines it withPathPrefix(...). Thedocsrouter shares its host withwebbut wins on/docs, because Traefik gives the more specific (longer) rule higher priority.routers.<name>.entrypoints=websecurebinds the route to port 443.routers.<name>.tls.certresolver=letells Traefik to obtain a certificate for this host via the resolverledefined above.services.<name>.loadbalancer.server.porttells Traefik which internal port the container listens on (here 80 for bothwhoamiandnginx).
Note exposedbydefault=false: without traefik.enable=true, a container is not published. That is the safe default – nothing ends up exposed by accident.
Starting it
The certificate file must exist with correct permissions before the first start:
mkdir -p letsencrypt
touch letsencrypt/acme.json
chmod 600 letsencrypt/acme.json
docker compose up -d
Once the DNS A records for api., app. and www.example.com point at the host, Traefik requests the certificates automatically on first access. A request to https://api.example.com returns the whoami output over valid HTTPS. The TLS-ALPN challenge used here requires that port 443 is reachable from outside and belongs exclusively to Traefik – the challenge runs over exactly that port.
What Traefik made of the labels shows up in the logs (docker compose logs -f traefik). Every new router and every certificate request appears there – the first place to look when a route doesn't take effect.
Three common pitfalls
1. A shared Docker network. Traefik can only reach a service if both sit on the same network. Here --providers.docker.network=proxy plus networks: [proxy] on every service handles that. Without it you get a 502 Bad Gateway even though the route exists.
2. Let's Encrypt limits while testing. The production CA has strict rate limits. Test against the staging environment first – an extra flag --certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory. The certificates won't be trusted, but you won't burn through your quota. For production, remove the flag and empty acme.json once.
3. Permissions on acme.json. Traefik refuses to start if the file is more open than 600 ("permissions are too open"). So create it as a file as shown above and mount it as a file – not as a directory whose permissions get inherited uncontrollably.
Where to go from here
You now have a production-grade reverse proxy. Obvious next steps: secure the dashboard, add middlewares for headers, rate limiting or basic auth, and switch to the HTTP challenge if port 443 isn't exclusively free for Traefik. The label reference for the Docker provider is in the official documentation.