Traefik Labels in Detail, Including the Dollar Trap

Contents
Traefik is configured by writing labels on a container, which sounds like a list of settings and is really a namespace. The keys are paths through a tree, and what looks like six independent lines is three objects that only know about each other because a segment in the middle of the key happens to be spelled the same.
That construction is what makes the configuration short and what makes its failures quiet. Nothing validates a name against anything, so a mistake produces a second, empty object rather than a complaint.

Three Objects, One Name
A router decides which requests belong to this container, a service says where to send them, and a middleware modifies them on the way. Each lives under its own branch of traefik.http, and each is identified by a name that is chosen freely – the label key is the declaration.
labels:
- "traefik.enable=true"
- "traefik.http.routers.blog.rule=Host(`blog.example.com`)"
- "traefik.http.routers.blog.entrypoints=websecure"
- "traefik.http.routers.blog.tls.certresolver=le"
- "traefik.http.routers.blog.middlewares=blog-auth@docker"
- "traefik.http.middlewares.blog-auth.basicauth.users=admin:$$2y$$05$$e6zX…"
- "traefik.http.services.blog.loadbalancer.server.port=8080"
The router and the service are bound because both are called blog. There is no line that says so. Writing traefik.http.services.blogg.loadbalancer.server.port creates a service named blogg that nothing points at, and a router named blog that has no service – and Traefik will answer requests for that host with a 404 rather than say what is missing.
When the container exposes exactly one port, the service label can be left out entirely and Traefik infers it. That convenience is worth knowing about mostly so it can be recognised in someone else’s file – as soon as a second port appears, the inference stops and the route breaks at the moment an unrelated port is added.
The Provider Suffix
Middlewares are referenced by name, and the name is not enough. Traefik keeps a separate namespace per provider – the Docker labels, a static file, a Kubernetes resource. A bare blog-auth is looked up in the provider that is asking, with a fallback that surprises people often enough to be worth stating: the reference belongs written as blog-auth@docker.
Getting it wrong is the quietest failure of the three. The router is valid, the service is valid, the route works – it simply serves the application without the authentication that was supposed to sit in front of it. Nothing in the log says a middleware was skipped, because from Traefik’s point of view none was requested.
The Dollar Trap
A bcrypt hash looks like $2y$05$e6zX... and consists largely of dollar signs. Docker Compose performs variable substitution on every value it reads, so $2y becomes an empty string, $05 becomes an empty string, and the password that reaches Traefik is a fragment that matches nothing.
# generate
htpasswd -nbB admin "passwort"
admin:$2y$05$e6zXx8Q0m2yYy1H4o1Yb1uJf2Rr7cKq5dW6vT8sN0pL3aG9hC1dS2
# in docker-compose.yml every $ is doubled
admin:$$2y$$05$$e6zXx8Q0m2yYy1H4o1Yb1uJf2Rr7cKq5dW6vT8sN0pL3aG9hC1dS2
# in a plain labels file or on the command line it stays single
The doubling is a Compose rule, not a Traefik rule, which is why the same string has to be written differently depending on where it lives. A hash placed in Traefik’s own dynamic configuration file keeps its single dollars; the same hash in a compose file needs them doubled; and moving a working configuration from one to the other breaks it in exactly this way.
The symptom is a login prompt that refuses every password, including the right one, which sends people to check the password rather than the file that ate it.
What Each Mistake Looks Like from Outside
| Mistake | Symptom | Where to look |
|---|---|---|
| Name differs between router and service | 404 for that host | the dashboard: a router with no service |
Middleware without @docker |
the site opens without authentication | the dashboard: the router lists no middleware |
| Single dollar signs in compose | 401 for every password | docker inspect: the label is shorter than the hash |
| Container not on the proxy network | 502 or 504 | the service has no reachable server |
traefik.enable=true missing |
404, nothing appears at all | the dashboard: the container is not listed |
| Two containers, same router name | one of them wins, apparently at random | the dashboard: one router, one backend |
Five of the six are answered by the dashboard, which is the argument for enabling it on an internal entrypoint even on a small machine. It shows what Traefik actually understood, and every one of these mistakes is a difference between what was written and what was understood.
Two Habits That Prevent Most of It
The first is to derive every name from the service and use it unchanged in all three places – container blog, router blog, service blog, middleware blog-auth. A name that is mechanical cannot be misremembered, and a diff between two containers’ labels then shows only the lines that genuinely differ.
The second is to read the labels back rather than the file. docker inspect shows the values as the daemon stored them, after Compose has finished substituting – which is where a doubled dollar becomes a single one again, and where a hash that survived is visibly the same length as the one that was generated.