Container Networking
A container’s eth0 is one end of a veth pair on a host bridge; published ports are DNAT rules in the host kernel, container DNS is a tiny resolver the runtime injects, and localhost inside the container is the container — not the host.
The problem
docker run -p 8080:80 nginx starts, and curl localhost:8080 on the host returns the nginx page. Inside the container, curl localhost:8080 fails, and a second container cannot reach the first by localhost either. The traffic to port 8080 arrived at the host and ended up inside a process that never listened on 8080. What rewrote it, and why does localhost mean three different things?The default path: veth → bridge → NAT → NIC
In bridge mode — Docker’s default — every container gets a network namespace (Network Namespaces) with an eth0 that is one end of a veth pair. The host end joins the docker0 bridge, which owns 172.17.0.1/16 and acts as the containers’ default gateway. Containers on the same bridge reach each other by IP directly over the bridge, at L2, with no NAT. Traffic to anywhere else hits the host routing table, is forwarded out of the physical NIC, and on the way is masqueraded: the source address is rewritten to the host’s IP because 172.17.0.0/16 is not routable outside. Outbound container traffic is ordinary NAT: Many Private Hosts Behind One Public Address, with the host kernel as the NAT box and its conntrack table remembering each flow.
So a container’s outbound HTTP request to the internet crosses: container socket → netns routing → veth → bridge → host routing (ip_forward=1) → nftables POSTROUTING MASQUERADE → host NIC. Every one of those is a place to look when "the container has no network": is ip_forward on, is the masquerade rule present, does the bridge have an address, did the veth join it.
- Container processsocket in the container namespace↓
- Container eth0 (veth)172.17.0.2; default via 172.17.0.1↓
- docker0 bridgesoftware switch; container-to-container traffic stops here↓
- Host routing tablenet.ipv4.ip_forward = 1; decides the outgoing interface↓
- POSTROUTING MASQUERADEsource 172.17.0.2 → host IP; conntrack entry created↓
- Host NIC → networkthe network sees the host, never the container
Port publishing is DNAT
-p 8080:80 does not make the container listen on the host’s port 8080. It installs a destination NAT rule in the host kernel: packets arriving at the host addressed to :8080 (from any interface but the bridge) get their destination rewritten to 172.17.0.2:80 and are forwarded over the bridge. The container’s nginx is listening on 80 in its own namespace and sees a connection to 172.17.0.2:80 from the original client’s address. Replies are un-rewritten by conntrack on the way back. The host process table has nothing on 8080 except, in Docker’s case, a small `docker-proxy` user-space listener that exists to handle the one case iptables cannot: connections from the host itself to localhost:8080, because packets to 127.0.0.1 do not traverse the forwarding path.
This is why ss -ltn on the host may show docker-proxy on 8080 and not nginx, why the container’s logs show real client IPs (it is DNAT, not a proxy), and why -p 127.0.0.1:8080:80 — binding the rule to loopback — is the way to keep a published port off the public interface. Note the firewall consequence: DNAT happens in PREROUTING, before the host’s INPUT chain, so host firewall rules on INPUT do not apply to published ports; the packets take the FORWARD path, which Docker manages with its own chains and which has bypassed many a carefully written host firewall.
$ sudo iptables -t nat -S DOCKER
-N DOCKER
-A DOCKER -i docker0 -j RETURN
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80
$ sudo iptables -t nat -S POSTROUTING
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE # outbound: hide the container behind the host
$ sudo ss -ltnp | grep 8080
LISTEN 0 4096 0.0.0.0:8080 users:(("docker-proxy",pid=4123,fd=4)) # only for host→localhost:8080 hairpin
$ sudo conntrack -L | grep 8080
tcp 6 431999 ESTABLISHED src=203.0.113.9 dst=10.0.0.5 sport=51234 dport=8080 \
src=172.17.0.2 dst=203.0.113.9 sport=80 dport=51234 # the reply tuple, un-NATedThe modes
bridge is the default described above: isolated namespace, private IP, NAT out, DNAT in. host skips the namespace entirely: the container shares the host’s network stack, binds ports directly on the host’s interfaces, sees the host’s routes and firewall — no veth, no NAT, no port publishing, best throughput, no isolation, one port 80 for everyone. none is a namespace with only loopback. container:<id> joins another container’s namespace, which is what a Kubernetes pod is: several containers sharing one namespace and therefore localhost (Kubernetes Networking, Just Enough).
overlay networks span hosts. Each host runs a bridge for the network; frames between containers on different hosts are encapsulated — Docker Swarm and Flannel use VXLAN, Ethernet frames inside UDP on port 4789 — and sent host to host, then decapsulated onto the far bridge. It is the same Encapsulation: Data, Segment, Packet, Frame trick as a VPNs and Tunnels without the encryption, with the same MTU tax (VXLAN costs 50 bytes, so an overlay eth0 typically shows an MTU of 1450), and a distributed key-value store telling each host which container IP lives where.
User-defined bridge networks (docker network create app) behave like the default bridge plus two things that matter: automatic DNS for container names, and isolation from containers on other networks. Rootless container runtimes cannot create veth pairs or NAT rules on the host and instead use a user-space network stack (slirp4netns, pasta), which is slower and has its own port-forwarding rules; if a rootless container "has network but is slow", that is why.
| Mode | Namespace | Address | Reach other containers | Inbound | Cost / isolation |
|---|---|---|---|---|---|
| bridge (default) | own | private on docker0 | by IP; by name on user-defined bridges | DNAT via -p | NAT overhead / isolated |
| host | host’s | host IP | via host ports | binds host ports directly | fastest / none |
| none | own, lo only | 127.0.0.1 | no | no | none / total |
| container:<id> | shared with peer | peer’s | peer over localhost | through peer’s rules | pod model |
| overlay | own | private, cluster-wide | across hosts via VXLAN | ingress rules | 50-byte MTU tax / isolated |
DNS, localhost, and the Docker Desktop VM
On a user-defined network the runtime writes nameserver 127.0.0.11 into the container’s /etc/resolv.conf. That address is an embedded resolver in the Docker daemon, reached through NAT rules inside the namespace; it answers container and service names on the same network (db, api) with their container IPs and forwards everything else to the host’s resolvers. On the legacy default bridge there is no name resolution between containers — only IPs and the deprecated --link. Compose puts every service on a user-defined network, which is why postgres://db:5432 works in Compose and not with plain docker run on the default bridge.
localhost is 127.0.0.1 on the loopback of whichever namespace you are in. Inside the container it is the container: a database published on the host’s 5432 is not reachable at localhost:5432 from inside, and a service the container runs is not on the host’s localhost unless published. From a container, the host is reachable at the bridge gateway (172.17.0.1) on Linux, or at the special name host.docker.internal on Docker Desktop (and on Linux with --add-host=host.docker.internal:host-gateway).
On macOS and Windows there is no Linux kernel to hold the namespaces, so Docker Desktop runs a small Linux VM and every container lives inside it. docker0, the veths and the DNAT rules exist in the VM; published ports are forwarded a second time from the Mac to the VM by the desktop app. Consequences: --network host historically meant the VM’s network, not the Mac’s (newer Docker Desktop versions add an opt-in host-networking bridge); tcpdump on the Mac does not see container traffic; the extra hop costs latency and throughput; and there is a second NAT layer whose source addresses are the VM’s. Any container networking explanation that starts with "on the host…" assumes Linux.
Key points
- Bridge mode: container eth0 is a veth end on
docker0; outbound traffic is masqueraded to the host IP; the network never sees container addresses. -p host:containeris a DNAT rule inPREROUTING; the container sees real client IPs; hostINPUTfirewall rules do not apply to it.- host mode shares the host stack (fast, no isolation, no port publishing); none is loopback only; container: shares a peer’s namespace (the pod model).
- Overlay networks carry frames between hosts inside VXLAN/UDP: encapsulation without encryption, with a 50-byte MTU cost.
- Container DNS is the embedded resolver at
127.0.0.11on user-defined networks; the default bridge has no name resolution. localhostis the current namespace’s loopback: inside a container it is the container. The host is the bridge gateway orhost.docker.internal.- On macOS/Windows all of this happens inside a Linux VM, with a second forwarding layer in front of it.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why NAT instead of routing container addresses?
Container prefixes are private and allocated per host; the surrounding network has no route to them. Masquerading behind the host IP needs no cooperation from anyone else. Kubernetes chooses the opposite trade and requires routable pod IPs.
▸Why is port publishing a kernel rule rather than a proxy?
A DNAT rule forwards at packet level with no copies and preserves the client address. docker-proxy exists only for the loopback hairpin that packet forwarding cannot do.
▸Why does a container need its own resolver?
Service names are per network and change as containers restart; a resolver the daemon controls can answer them from its own state instead of depending on the host’s DNS.
▸Why is `localhost` not the host?
Because loopback is part of the network namespace, and the container has its own. There is no shared loopback across namespaces; that is precisely the isolation that lets two containers bind the same port.
How it fails
What the failure looks like from inside real software.
- App in a container cannot reach the database at
localhost:5432that runs on the host:localhostis the container; usehost.docker.internalor the bridge gateway, or put both on a user-defined network. docker runon the default bridge cannot resolvedb: no embedded DNS on the legacy bridge; create a user-defined network.- Host firewall allows only 22 and 443, yet a published port 8080 is reachable from the internet: DNAT in
PREROUTINGbypassesINPUT; bind the publish to127.0.0.1or add rules onDOCKER-USER. - Containers reach the internet but not each other on a user-defined network: inter-container communication disabled (
icc=false) or a network policy in the bridge’sFORWARDchain. - Overlay network works for small requests and stalls on large ones: VXLAN MTU tax with no MSS clamping; a 1500-byte inner packet does not fit.
- Throughput between two containers on Docker Desktop is a fraction of Linux: traffic crosses the Mac→VM boundary and an extra NAT layer.