When I first built my homelab I reached for a heavier ingress stack: tunnels, an extra proxy layer. It worked, but every change meant reasoning about three moving parts. I ripped it out for plain Traefik, and everything got simpler.

Labels are the whole API

Exposing a container is just labels on it:

labels:
  - traefik.enable=true
  - "traefik.http.routers.myapp.rule=Host(`app.example.com`)"
  - traefik.http.routers.myapp.entrypoints=websecure
  - traefik.http.routers.myapp.tls.certresolver=le
  - traefik.http.services.myapp.loadbalancer.server.port=3000

A hostname, a port, done. Traefik discovers it, routes it, and mints a Let's Encrypt cert automatically.

Two notes on that snippet, both of which have cost me time. The rule needs to be quoted in YAML, because the backticks and the colons will otherwise be parsed as structure rather than as a string. And the port is the port inside the container on the shared network, not a published host port; you generally want no ports: stanza at all, so the only way to the app is through the proxy.

The other half: the file provider

Docker labels are not the whole story, and knowing where the seam is matters. A label-defined router lives and dies with its container, so anything that has to outlive a service, a redirect for a retired domain, a middleware several apps share, a route to something that isn't a container at all, belongs in the file provider instead. Point Traefik at a directory, turn on watching, and it reloads YAML from disk without a restart.

I learned that distinction the direct way: stop the container behind a hostname and the hostname stops answering entirely, because the router went with it. That is correct behaviour and it is still surprising the first time.

Middlewares are the other piece worth knowing. They chain in front of a router, and you can also bolt a set onto an entrypoint so that every request through port 443 passes through them regardless of which app it lands on. Security headers, rate limits and an IP blocklist belong there, applied once, rather than repeated on forty containers.

Less is more

No tunnel daemon to babysit, no second proxy to keep in sync. One reverse proxy, configured through the same compose files as the apps it serves. When something breaks, there's exactly one place to look, and the access log is JSON, so "which router handled this request" is a question with an answer.

The fancier stack had its reasons, but for a single box running a few dozen services, vanilla Traefik is the sweet spot.