Containersdockerbridgedocker0vethdnat

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.

Linux
Interview question
Progress

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

Linux

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 to the world (Linux, bridge mode)
  1. Container processsocket in the container namespace
  2. Container eth0 (veth)172.17.0.2; default via 172.17.0.1
  3. docker0 bridgesoftware switch; container-to-container traffic stops here
  4. Host routing tablenet.ipv4.ip_forward = 1; decides the outgoing interface
  5. POSTROUTING MASQUERADEsource 172.17.0.2 → host IP; conntrack entry created
  6. Host NIC → networkthe network sees the host, never the container

Port publishing is DNAT

Linux

-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.

What -p 8080:80 installs (iptables view; nft equivalent under the hood on modern hosts)
$ 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-NATed

The modes

Linux

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.

Docker network modes
ModeNamespaceAddressReach other containersInboundCost / isolation
bridge (default)ownprivate on docker0by IP; by name on user-defined bridgesDNAT via -pNAT overhead / isolated
hosthost’shost IPvia host portsbinds host ports directlyfastest / none
noneown, lo only127.0.0.1nononone / total
container:<id>shared with peerpeer’speer over localhostthrough peer’s rulespod model
overlayownprivate, cluster-wideacross hosts via VXLANingress rules50-byte MTU tax / isolated

DNS, localhost, and the Docker Desktop VM

Linux

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:container is a DNAT rule in PREROUTING; the container sees real client IPs; host INPUT firewall 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.11 on user-defined networks; the default bridge has no name resolution.
  • localhost is the current namespace’s loopback: inside a container it is the container. The host is the bridge gateway or host.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:5432 that runs on the host: localhost is the container; use host.docker.internal or the bridge gateway, or put both on a user-defined network.
  • docker run on the default bridge cannot resolve db: 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 PREROUTING bypasses INPUT; bind the publish to 127.0.0.1 or add rules on DOCKER-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’s FORWARD chain.
  • 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.