Publishing ports and the embedded DNS: DNAT, bind addresses, and 127.0.0.11
Publishing a port installs an iptables DNAT rule rewriting host:port to container:port, scoped by the bind address. User-defined networks add an embedded DNS at 127.0.0.11 resolving names. Most 'works locally but not remotely' and 'name not found' bugs are these two, misread.
The demo worked on the developer’s laptop and failed in staging, and the difference was one missing colon. The compose file published the API as 127.0.0.1:8080:8080. On the laptop, the developer hit localhost:8080 and everything was fine. In staging, the load balancer health check came from another host, got connection-refused, and marked the service down — so it never received traffic, and the on-call engineer saw an outage with green container logs. docker ps showed the port “published,” curl localhost:8080 on the box itself returned 200, and yet the port was unreachable from anywhere else. The bind address 127.0.0.1 had silently scoped the published port to host-loopback only: the DNAT rule existed but only matched packets arriving on the loopback interface. Change it to 0.0.0.0:8080:8080 (or just 8080:8080) and the health check went green. Publishing a port is an iptables rule with an interface scope, and the embedded DNS is a second mechanism people conflate with it — both are worth understanding exactly, because together they explain most container-networking confusion.
Publishing a port is DNAT
-p 8080:8080 does not “open” a port the way a firewall rule does — it installs a DNAT (destination NAT) rule in iptables that rewrites the destination of packets arriving at the host’s port 8080 to the container’s IP and port. The rule lands in the nat table’s DOCKER chain, reached from PREROUTING (for traffic arriving from outside) and OUTPUT (for traffic originating on the host):
-A DOCKER ! -i docker0 -p tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:8080The published-port spec has three parts: [bind-address:]host-port:container-port. The bind address is the part that bites: omit it (or use 0.0.0.0) and the port is reachable on every host interface; set 127.0.0.1:8080:8080 and the DNAT only matches traffic arriving on loopback — reachable from the host itself, invisible to every other machine. That is the Hook bug: the rule existed, docker ps proudly listed it, but its interface scope excluded the health checker. A second subtlety: by default Docker’s DNAT rule is inserted ahead of the host firewall’s INPUT rules, so a published port can be reachable from the outside even if ufw/firewalld thinks it blocked that port — Docker and the host firewall are editing different chains, and people are surprised which one wins.
The userland-proxy
There are actually two paths for published-port traffic. The fast path is the iptables DNAT above, handled entirely in-kernel. But Docker also runs a small docker-proxy userland process per published port as a fallback — for hairpin cases and same-host loopback connections the DNAT rule alone doesn’t cover. The kernel path costs essentially nothing; the userland-proxy path copies bytes through a userspace process, adding single-digit microseconds of latency per packet plus a process and a held socket per published port. On a host publishing hundreds of ports this is real overhead, which is why high-throughput setups often disable it ("userland-proxy": false) and rely on DNAT plus a kernel hairpin route alone.
A container publishes 127.0.0.1:5432:5432 for Postgres. From the host, psql connects fine. From another machine on the same network, connection is refused. docker ps lists the port as published. What is wrong?
▸Why this works
Why does 127.0.0.1:port even exist as an option if it causes this confusion? Because it is the right default for a database or admin port you want reachable only by other processes on the same host (or by a reverse proxy on that host), never from the network. The footgun is using it by reflex and then expecting remote reachability. The discipline: bind to loopback when the consumer is co-located, bind to 0.0.0.0 when something off-box must connect, and never let docker ps showing “published” stand in for “reachable from where I need it.”
The embedded DNS at 127.0.0.11
Have you ever wondered why http://api:8080 just works inside a Compose project but breaks the moment you reference a container from a different project? That is DNS scope in action — and it is the second failure class to master.
On the default bridge, containers can only reach each other by IP — there is no name resolution. On a user-defined bridge network (the kind docker network create or any Compose service network makes), Docker runs an embedded DNS server that every container reaches at the fixed address 127.0.0.11:53. Docker rewrites each container’s /etc/resolv.conf to point at it, and it resolves container names and network aliases to current container IPs, forwarding everything else to the host’s configured resolvers. This is why on a Compose network you connect to http://api:8080 and it just works — api is a container name the embedded DNS knows, and the IP it returns is always current even after a restart gives the container a new address.
The numbers and failure modes that matter:
- The resolver lives at
127.0.0.11inside every container’s netns — a loopback address, so it never collides with the host’s DNS and is unreachable from outside the container. iptables NAT rules inside the netns redirect that loopback traffic to the daemon’s DNS listener. - Name resolution is per-network: a container only resolves names of containers attached to a network it shares. Two containers on different user-defined networks cannot resolve each other by name even on the same host — the classic “works in one compose file, not across two” bug.
- A short DNS TTL plus resolution-on-every-connect is what makes restarts transparent, but a client that caches a resolved IP forever (some JVM defaults, naive HTTP clients) keeps hitting the old IP after the target container restarts — a stale-connection bug that looks like the network but is the client’s resolver cache.
Service A reaches service B by the name 'b' in one Compose project. A second project's service C cannot resolve 'b' at all, though both run on the same host. Why?
- 01Explain exactly what '-p 127.0.0.1:8080:8080' installs and why the same container is reachable from the host but not from another machine.
- 02Describe the embedded DNS: its address, scope, what it returns, and two failure modes.
Two distinct mechanisms account for most container-networking confusion, and the senior skill is keeping them apart. Publishing a port (-p) installs an iptables DNAT rule in the nat table’s DOCKER chain that rewrites the destination of traffic arriving at the host port to the container’s IP and port; it is reachability plumbing, not a firewall opening. The published-port spec is [bind-address:]host-port:container-port, and the bind address scopes the rule to an interface: 127.0.0.1: makes the port loopback-only — reachable from the host, refused from every other machine — even though docker ps still proudly prints ‘published’. Docker inserts this DNAT ahead of the host firewall’s INPUT rules, so a published port can be externally reachable even when ufw or firewalld believe they blocked it. A per-port docker-proxy userland process is a fallback for hairpin and same-host loopback cases the DNAT alone misses; it adds single-digit-microsecond per-packet latency and a held socket per port, so high-throughput hosts often set userland-proxy false. The second mechanism is name resolution: a user-defined bridge network (the default bridge has none) runs an embedded DNS at the fixed loopback 127.0.0.11:53 inside every container, resolving container names and aliases to current IPs and forwarding the rest upstream — which is why http://api:8080 works on a Compose network and survives restarts that reassign IPs. Its scope is per-network, so two separate networks can’t resolve each other’s names without a shared network, and a client caching IPs forever keeps connecting to a dead address after a restart. When you debug, first decide whether the symptom is reachability (bind address, DNAT, firewall) or resolution (DNS scope, stale cache) — then you are fixing the right mechanism.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.