Container Networking Deep Dive Part 2: Docker's Default Bridge Model, Unpacked
Part 1 built a two-container topology entirely with ip netns, ip link, and iptables: a bridge, two veth pairs, one MASQUERADE rule, one DNAT/FORWARD pair for a published port. This post builds the same shape with Docker doing the work, and then reads back exactly what Docker generated, so the automation stops being a black box.
The default bridge
A fresh Docker install creates one bridge network automatically, named bridge, backed by a Linux bridge interface named docker0:
ip addr show docker0
4: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN
link/ether 02:42:8f:1a:6c:3e brd ff:ff:ff:ff:ff:ff
inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0
172.17.0.1/16 is Docker’s default subnet, bigger than the /24 used by hand in Part 1, because Docker has no way to know how many containers you intend to run and defaults to a large pool. NO-CARRIER disappears the moment the first container attaches; a bridge with no ports shows exactly like this.
Running two containers
docker run -d --name app-a --network bridge alpine sleep infinity
docker run -d --name app-b --network bridge alpine sleep infinity
Inspect what Docker assigned:
docker inspect app-a --format '{{.NetworkSettings.IPAddress}}'
docker inspect app-b --format '{{.NetworkSettings.IPAddress}}'
172.17.0.2
172.17.0.3
From the host, the veth pairs are visible exactly as they were in Part 1, just named by Docker instead of by hand:
ip link show master docker0
5: vethf3a9c21@if4: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 ...
7: veth8b02d17@if6: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 ...
The veth<hash>@if<N> naming and the random hash are the only difference from veth-app-a in Part 1, same veth pair, same bridge attachment, same mechanism. Ping between them:
docker exec app-a ping -c 2 172.17.0.3
Works immediately, over layer 2, through docker0, with no iptables rule involved, identical to app-a reaching app-b directly over the bridge in Part 1.
What container-to-container name resolution actually is
Part 1 needed a raw IP because there was no name resolution set up. Docker ships one:
docker exec app-a cat /etc/resolv.conf
nameserver 127.0.0.11
options ndots:0
127.0.0.11 is not a real loopback address reaching outside the container. It is a per-container listener that Docker’s embedded DNS server binds to inside every container’s own network namespace, backed by iptables DNAT rules that redirect that specific address to the actual resolver process running in the host’s Docker daemon:
docker exec app-a nslookup app-b
Server: 127.0.0.11
Address: 127.0.0.11:53
Name: app-b
Address: 172.17.0.3
This only works on a user-defined bridge network, not the default bridge network used above. The default network predates embedded DNS and only supports legacy --link-based name resolution. Recreate the containers on a user-defined network to see it work:
docker network create demo-net
docker run -d --name app-a --network demo-net alpine sleep infinity
docker run -d --name app-b --network demo-net alpine sleep infinity
docker exec app-a ping -c 2 app-b
docker network create builds a second bridge, separate from docker0, with its own subnet, the same ip link add ... type bridge command from Part 1, run by Docker instead of by hand.
Reading the generated iptables rules
This is the part that matters. Dump the NAT table after the containers above are running:
sudo iptables -t nat -S
-N DOCKER
-A POSTROUTING -s 172.18.0.0/16 ! -o br-a1b2c3d4 -j MASQUERADE
-A DOCKER -i br-a1b2c3d4 -j RETURN
The MASQUERADE rule is structurally identical to the one written by hand in Part 1: source subnet, exclude the bridge’s own interface, masquerade everything else. Docker just names the bridge after a hash of the network ID instead of br-containers, and adds its own DOCKER chain so it can track and remove exactly the rules belonging to this network when it’s deleted, without touching rules from any other network.
Publish a port and inspect the difference:
docker run -d --name app-c --network demo-net -p 8080:8080 alpine sleep infinity
sudo iptables -t nat -S DOCKER
-N DOCKER
-A DOCKER ! -i br-a1b2c3d4 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.18.0.4:8080
This is the same DNAT rule from Part 1’s port-publishing section, dport 8080 to a container IP on 8080, with one addition: ! -i br-a1b2c3d4 restricts the rule to packets not already arriving from the bridge itself, so a container reaching another container’s published port over the internal network doesn’t get double-NATed. Docker’s FORWARD chain gets a matching ACCEPT entry for the same reason the hand-written rule in Part 1 needed one.
Why this matters when something breaks
Every “Docker networking is confusing” complaint resolves once the mapping above is internalized: docker0 is a Linux bridge, container IPs come from ip addr inside a namespace, -p flags become DNAT/FORWARD rule pairs in a DOCKER chain, and embedded DNS is a redirect to 127.0.0.11 backed by Docker’s own resolver process. When a published port doesn’t work, sudo iptables -t nat -S DOCKER shows you exactly what rule Docker thinks it wrote, the same debugging move as reading raw iptables output for any other NAT problem. Nothing about it requires trusting Docker’s own diagnostics; it can be verified with tools that predate Docker by two decades.
The next post compares this default bridge model against Docker’s other network drivers, host, macvlan/ipvlan, and none, and where each one trades away isolation, performance, or addressing flexibility that the bridge driver defaults to.