Routing Compose Services by Hostname With Traefik
Traefik is running, the service is up, and curl https://api.localhost still returns 404 page not found — the plain-text body Traefik sends when no router matches the request. This page explains how Traefik discovers Compose services through labels, why a correctly running container can still be invisible to it, and how to wire each service to a hostname so the stack needs exactly two published ports. It builds on the proxy setup in local HTTPS and reverse proxies.
Hostname routing is worth the small amount of configuration because published ports are a shared, global resource on a laptop. Two projects that both publish 3000 cannot run at the same time, and every new service adds one more number for the team to remember. With a proxy, services keep their internal ports private and are addressed by name.
Diagnostic
Ask Traefik which routers it knows about. The API is available when the proxy runs with --api.insecure=true and publishes port 8080 on loopback:
#!/usr/bin/env bash
set -euo pipefail
curl -sS -o /dev/null -w 'status %{http_code}\n' https://api.localhost/health
curl -sS http://127.0.0.1:8080/api/http/routers | jq -r '.[] | "\(.name)\t\(.rule)\t\(.status)"'
docker inspect --format '{{json .Config.Labels}}' "$(docker compose ps -q api)" | jq .
Expected bad output when the router is missing:
status 404
dashboard@internal PathPrefix(`/api`) || PathPrefix(`/dashboard`) enabled
{
"com.docker.compose.service": "api",
"com.docker.compose.project": "shop"
}
Only the internal dashboard router exists, and the container carries no traefik.* labels at all. A second common pattern is a router listed with status disabled or an error message about a missing service — that means labels are present but inconsistent.
Root cause
Traefik's Docker provider watches the Docker socket and builds routers from container labels. When the proxy runs with --providers.docker.exposedbydefault=false — the safe default for a shared machine — any container without traefik.enable=true is ignored, so a service can be healthy and completely invisible to the proxy. When labels exist, Traefik still needs to know which container port to forward to; with more than one exposed port and no loadbalancer.server.port label it cannot choose, and the router errors. Finally, the Host() rule is an exact match on the request's Host header, so a rule for api.localhost never matches a request sent to api.local or api.localhost.test. Traefik v3 normalises case and strips a default port from the Host header, so API.localhost:443 still matches; a genuinely different name does not.
The three causes produce three distinct symptoms in the router table, which is why the diagnostic starts there rather than with the container. No router at all means the provider skipped the container — missing traefik.enable, a label typo such as traefik.enabled, or a proxy that cannot read the Docker socket. A router in an error state means Traefik parsed the labels but could not build a working service, usually because of the port. An enabled router that still yields a 404 means the rule itself does not match what the client sends, which is a naming problem rather than a wiring problem. Reading the table first avoids the common loop of recreating containers and editing unrelated labels in the hope that something changes.
Resolution
- Label each service that should be reachable. Keep the router name equal to the service name so errors are easy to trace.
services:
api:
build: ./api
expose:
- "8080"
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=Host(`api.localhost`)
- traefik.http.routers.api.entrypoints=websecure
- traefik.http.routers.api.tls=true
- traefik.http.services.api.loadbalancer.server.port=8080
web:
build: ./web
labels:
- traefik.enable=true
- traefik.http.routers.web.rule=Host(`app.localhost`)
- traefik.http.routers.web.entrypoints=websecure
- traefik.http.routers.web.tls=true
- traefik.http.services.web.loadbalancer.server.port=5173
- Route a path prefix to a different service when the frontend expects the API on the same origin.
StripPrefixremoves/apibefore the request reaches the upstream.
services:
api:
labels:
- traefik.http.routers.api-path.rule=Host(`app.localhost`) && PathPrefix(`/api`)
- traefik.http.routers.api-path.entrypoints=websecure
- traefik.http.routers.api-path.tls=true
- traefik.http.routers.api-path.middlewares=api-strip
- traefik.http.routers.api-path.service=api
- traefik.http.middlewares.api-strip.stripprefix.prefixes=/api
- Remove the old published ports from the services now behind the proxy, so nothing bypasses it and ports stop colliding with other projects.
#!/usr/bin/env bash
set -euo pipefail
docker compose config --format json | jq -r '.services | to_entries[] | select(.value.ports) | "\(.key): \(.value.ports | map(.published) | join(","))"'
Only proxy: 80,443 (and optionally the dashboard on 127.0.0.1:8080) should remain.
- Recreate the services so Docker applies the new labels. Labels are part of the container config, so
restartis not enough.
#!/usr/bin/env bash
set -euo pipefail
docker compose up -d --force-recreate api web
Expected output
$ curl -sS -o /dev/null -w 'status %{http_code}\n' https://api.localhost/health
status 200
$ curl -sS http://127.0.0.1:8080/api/http/routers | jq -r '.[] | "\(.name)\t\(.rule)\t\(.status)"'
api@docker Host(`api.localhost`) enabled
api-path@docker Host(`app.localhost`) && PathPrefix(`/api`) enabled
web@docker Host(`app.localhost`) enabled
dashboard@internal PathPrefix(`/api`) || PathPrefix(`/dashboard`) enabled
Every router is enabled, and Traefik resolves overlapping rules by priority, which defaults to rule length — the longer Host && PathPrefix rule wins over the bare Host rule for /api requests, so no explicit priority is needed here.
Prevention
- Lint labels in CI. A short script can assert that every service with
traefik.enable=truealso declares a Host rule and an upstream port, catching the most common mistakes before merge:
#!/usr/bin/env bash
set -euo pipefail
docker compose config --format json | jq -e '
.services | to_entries
| map(select((.value.labels // {})["traefik.enable"] == "true"))
| all(.value.labels | (keys | any(test("routers\\..*\\.rule"))) and (keys | any(test("loadbalancer.server.port"))))
' >/dev/null && echo "traefik labels ok"
Keep the dashboard local. Publish it as
127.0.0.1:8080:8080only. On a shared network,--api.insecureexposes routing details to anyone who can reach the port.Name routers after services. One router per service with the same name keeps
docker compose logs proxyreadable and lets the Compose override files for local and CI change a rule without guessing router names.
Platform caveats
macOS (Docker Desktop): the Docker socket path inside containers is
/var/run/docker.sockeven though the host path differs; mount it exactly as shown. With the "Allow the default Docker socket" setting disabled, Traefik logsCannot connect to the Docker daemonand builds no routers.
WSL2: when Docker Desktop's WSL integration is used, the socket works as on Linux. With Docker Engine installed directly inside WSL, ensure the engine is started (
sudo service docker start) before the proxy, or the provider starts with an empty router table and does not retry for several seconds.
Apple Silicon (ARM64): some older upstream images bind
0.0.0.0only when run natively; under emulation a few dev servers default tolocalhost. If the router is enabled but returns 502, check the upstream bind address rather than the labels.
Rootless Docker / Podman: the socket lives at
$XDG_RUNTIME_DIR/docker.sockorpodman.sock; mount that path to/var/run/docker.sockinside the proxy.
Rollback
To return to published ports, drop the labels and restore the ports: entries from version control, then recreate the services:
#!/usr/bin/env bash
set -euo pipefail
git checkout HEAD~1 -- compose.yaml
docker compose up -d --force-recreate --remove-orphans
Frequently Asked Questions
Why does docker compose restart not pick up new Traefik labels?
Labels are stored in the container's configuration at creation time. restart stops and starts the same container, so its labels do not change. Use docker compose up -d --force-recreate <service> or simply docker compose up -d, which recreates any container whose configuration changed.
Do I need traefik.http.services.<name>.loadbalancer.server.port if the image exposes one port?
Not strictly — Traefik uses the single exposed port automatically. Set it anyway: many base images expose several ports, and an image update that adds one silently breaks routing when the label is missing.
How do I route WebSockets or gRPC through the same proxy?
WebSockets work through a normal HTTP router with no extra configuration. For gRPC, set the service scheme to h2c with traefik.http.services.<name>.loadbalancer.server.scheme=h2c so Traefik speaks HTTP/2 cleartext to the upstream.