A 502 from Cloudflare Tunnel, caused by a compose service name
The tunnel said 502 while every container was green. Compose DNS only knows service names, and a six-line alias solved it in one step.
TL;DR
Public URL returned 502 while everything looked healthy and curl to the proxy worked fine. Ingress pointed to proxy on port 8199 but the service is actually plane-proxy, so Docker DNS had no record and cloudflared couldn't reach the origin. Adding a network alias proxy to plane-proxy fixed it, keeping the name stable for anything outside the repo.
The 502 Bad Gateway page showed up while every container was green. docker compose ps looked clean, nothing was restart-looping. From inside the host, the proxy answered right away: a curl to port 8199 came back 200. But the public URL through the tunnel kept returning 502.
My first guess went the wrong way: a broken proxy config, a bad tunnel token, or an ingress rule in the Cloudflare dashboard pointing at the wrong place. I reopened the proxy config and compared it line by line. Nothing odd. Everything answered fine from the inside.
The real problem sat much lower: name resolution. In the dashboard, the ingress pointed at http://proxy:8199. That name proxy had never been registered anywhere, because the service in my compose file is called plane-proxy. Docker Compose registers service names into its network DNS, and every container can reach the others through those names [1]. A nickname invented in a dashboard does not come along automatically.
Which means the tunnel itself was healthy. The Cloudflare docs are blunt about it: a 502 on a tunnel means the tunnel is already connected to the Cloudflare network, but cloudflared cannot reach the origin written in the ingress rule, and the problem sits between cloudflared and the local service [3]. Both facts met in one spot: cloudflared looked for a host named proxy, the compose network DNS said no such host, and every request died as a 502 [4].
The fix was six lines under the plane-proxy service:
networks:
default:
aliases:
# the cloudflared ingress points at http://proxy:8199,
# while the service is named plane-proxy
- proxyThe container came back up, I opened the public URL, and this time a clean 200 arrived over HTTPS.
Why is it this simple? Because aliases is designed exactly for this: declaring an alternative hostname for a service on a specific network, and other containers are free to reach it through the service name or the alias [2]. The alias is network-scoped, so it lives right where cloudflared was looking.
A service name is a contract, not a detail
What made this incident stick was how small it was. Nothing was misconfigured, nothing was broken. Two worlds just used different names for the same thing, and Docker Compose had no way to know that a Cloudflare dashboard out there called my service by another name. Dashboard config lives outside the repo, never shows up in a git diff, and that is precisely why it slips through.
Since then I treat service names as an API contract. Once another system depends on a name, renaming it is silently changing an endpoint: the tunnel dashboard, healthchecks, even CI scripts with the old hostname hardcoded can fall over without a single build error. I do not rename services anymore before checking who calls the old name.
Docker actually ships two official doors for alternative names. One is aliases in the networks block, the other is links. The docs themselves say links are not required for service-to-service communication, because every service already reaches the others by name by default [8]. For my case aliases fits better: explicit, readable in the compose file, and its reason to exist is clear.
The tempting little trap
One shortcut almost got me: set container_name: proxy so the short name resolves. Good thing I checked first. Besides tying the name to a single container, compose even refuses to scale a service beyond one container once container_name is set, and the error only surfaces when you actually need the scale [8]. In my opinion this is the wrong place for a naming solution: if the goal is a contract for other containers, a network alias stays more honest, because that is what it is designed for.
Here is the mistake I made myself: the moment a compose file gets adapted from an upstream template, service names follow the new example while the dashboard ingress still speaks the old one. A one-line difference, and every request pays for it. From now on the alias gets pinned in compose, so if a service name changes again, the outside world never breaks with it.