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.

Why Traefik Ignores a Running Container Decision diagram showing the three reasons a container produces no Traefik router. Why Traefik Ignores a Running Container Is a router listed for the service? No router at all add traefik.enable=true Router, status error fix service port label Router ok, still 404 Host rule does not match
With exposedbydefault disabled, a container needs explicit labels and a reachable network to become a route.

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

  1. 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
  1. Route a path prefix to a different service when the frontend expects the API on the same origin. StripPrefix removes /api before 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
  1. 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.

  1. Recreate the services so Docker applies the new labels. Labels are part of the container config, so restart is not enough.
#!/usr/bin/env bash
set -euo pipefail
docker compose up -d --force-recreate api web
One Proxy, Many Hostnames Traefik in the centre routes four hostnames to four internal services. One Proxy, Many Hostnames Traefik :443 app.localhost web :5173 api.localhost api :8080 mail.localhost mailpit :8025 s3.localhost minio :9001
Each spoke is one set of labels on one service; no service publishes its own port.

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

  1. Lint labels in CI. A short script can assert that every service with traefik.enable=true also 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"
  1. Keep the dashboard local. Publish it as 127.0.0.1:8080:8080 only. On a shared network, --api.insecure exposes routing details to anyone who can reach the port.

  2. Name routers after services. One router per service with the same name keeps docker compose logs proxy readable and lets the Compose override files for local and CI change a rule without guessing router names.

Published Ports vs Hostname Routing Comparison of publishing one port per service against routing through a proxy by hostname. Published Ports vs Hostname Routing One port per service Proxy with labels 3000, 5173, 8025, 9001 app, api, mail, s3 names collides across projects only 80 and 443 bound no TLS without extra work TLS for every service cookies split by port one parent domain
Hostname routing trades a few labels for a stack that never collides with other projects.

Platform caveats

macOS (Docker Desktop): the Docker socket path inside containers is /var/run/docker.sock even though the host path differs; mount it exactly as shown. With the "Allow the default Docker socket" setting disabled, Traefik logs Cannot connect to the Docker daemon and 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.0 only when run natively; under emulation a few dev servers default to localhost. 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.sock or podman.sock; mount that path to /var/run/docker.sock inside 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.&lt;name&gt;.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.