A Loopback Publish in Compose Was Not Enough
I published a proxy port on loopback and a process on the same host still got refused. Docker filters by door, not by who is knocking.
Last Friday I added one service to the docker-compose file on my work machine: headroom, a token-compression proxy for AI workloads. The image is ghcr.io/headroomlabs-ai/headroom. Its job is to sit in front of LLM traffic and shrink context before it hits the provider, all processed locally [5]. The consumer is a router that runs natively on the host, so the decision looked easy: this service should only be reachable from the host itself. I published the port loopback-only.
First test went fine. curl to the loopback address from a host terminal came back clean. Then I pointed the router at the proxy and it called one path, /v1/compress, and the call was refused. What made it annoying: the client was on the same machine. Not another server, not the internet, no weird VPN in the middle.
My first suspect was the application. A typo in the URL, a wrong path, a config that never got reloaded, I checked all of it. Nothing. So I opened the Docker networking docs, and found a fact that changed how I look at port publishing.
What Docker actually filters
The default for publishing is open. If you publish without pinning a host address, like -p 8080:80, the port is published on all host addresses, IPv4 and IPv6 [2] [3]. Docker calls the default behavior insecure by default: a published port is reachable not only from the Docker host but from the outside too [6].
Putting a loopback address in front is a filter. The docs are blunt: include the loopback address in the publish flag, and only the Docker host can reach that port [6]. I had misread that phrase. The check is not about where the calling process runs, it is about which address traffic is addressed to and which path it takes to arrive.
My client found the host through its bridge interface; from the container side, the host shows up as the gateway address of the bridge network [1], and that turned out to be the whole story. Traffic entering through that door is not loopback traffic. My loopback-only publish rule only accepts traffic addressed to the host loopback. Everything else gets dropped, and the request died at two layers that both said the same thing: you are not local.
One detail that made me uneasy: in older releases, before Docker 28.0.0, other hosts on the same L2 segment could reach ports published to loopback [6]. Patched since, but a good reminder that loopback is not a magic word that makes everything safe.
Three exits, I took the narrowest
There are three common ways out of this. First, publish on every interface and lean entirely on application auth. Second, put the client and the proxy on one user-defined bridge network and call by service name; containers on the same user-defined network reach each other directly [2]. Third, keep the publish loopback-only and open the route at the application layer.
I took the third. In the compose service [4], only two blocks changed:
headroom:
image: ghcr.io/headroomlabs-ai/headroom:latest
ports:
- "127.x.x.x:8787:8787" # loopback-only: host access only
environment:
HEADROOM_HOST: "0.0.0.0"
# open ONE route for clients arriving via the bridge:
HEADROOM_COMPRESS_ALLOW_REMOTE: "1"That last env var belongs to headroom, not to Docker: it tells the application to allow remote clients on the /v1/compress route alone. The port stays locked to loopback on the host side, and everything else keeps following the normal publish rules [6].
Why not the easiest option? Because what I wanted was not "reachable", but "reachable as little as possible". Only one path opens up, normal auth keeps working, and no new port appears on any other interface. If another route needs bridge access tomorrow, it gets added explicitly, one at a time, on purpose.
Now when a connection gets refused in my Docker setup, my suspect list looks different. Not just "the app is broken", but also: which door did this traffic use, and who is that door supposed to let in. A question that sounds trivial, but in Docker the answer decides everything.
Sources
Sources:
[1] https://docs.docker.com/engine/network
[2] https://docs.docker.com/engine/network/drivers/bridge
[3] https://docs.docker.com/reference/cli/docker/container/run
[4] https://docs.docker.com/reference/compose-file/services
[5] https://github.com/headroomlabs-ai/headroom
[6] https://docs.docker.com/engine/network/port-publishing