cld-toys › Guided exercises › Docker

Container Networking

Two containers on one host are either one virtual cable apart or completely unable to exchange a packet — and the only thing deciding which is a membership list. Watch one name resolve to two different addresses, attach a running container to a second network live, and confirm that publishing a port does nothing for container-to-container traffic.


Concept

Docker runs a private DNS server at 127.0.0.11 inside every container on a user-defined network, and it answers only for containers you share a network with. Alongside it, a set of iptables rules silently drops traffic between separate bridges. Neither is physical: both are derived from a membership list you can change on a running container. This exercise makes that boundary visible from both sides — the name-resolution side and the packet side — and keeps them strictly separate, because conflating them is what makes container networking feel like guesswork.


Mental model

Two questions, not one

ping other-container is the command everyone reaches for, and it's a bad diagnostic: it can fail for two unrelated reasons and reports both the same way. Split it in half and keep the halves separate for the whole exercise.

QuestionCommandWhat it exercises
Does the name resolve? getent hosts <name>
nslookup <name>
Only DNS. Prints an address and exits 0, or prints nothing and exits 2. No packets are sent to the target at all.
Can packets get there? ping <ip>
wget -O- http://<ip>/
Only reachability. Skips DNS entirely by using the raw address.
The whole exercise in one line Those two answers are independent, and the interesting configurations are the ones where they disagree. The default bridge network is exactly such a case — names dead, packets fine — and that's Part 2.

The shape of a failure is also information. There are four distinct signatures, and they mean genuinely different things:

What you seeWhat it means
ping: bad address 'name'
getent exits 2 / NXDOMAIN
DNS said no. Nothing was ever sent to the target.
Connection refused (instant) Packets arrived. The host is reachable; nothing is listening on that port. This is a success for networking.
Hangs, then download timed out / 100% packet loss Packets left, and vanished. Something dropped them silently — a firewall rule, which is what network isolation is made of.
Network unreachable (instant) The container has no route to that address at all. Its own kernel refused to try.
The distinction worth memorising “Refused” versus “timed out”. The first means your networking is fine and your service isn't. The second means the packets never got there. You will see both below, from the same nginx on the same port.

The four network modes

ModeWhat it actually doesReach for it when…
User-defined bridge
docker network create X
Its own Linux bridge and subnet, plus an embedded DNS server at 127.0.0.11 in every attached container that resolves the names, hostnames and aliases of the other members. Containers can be attached and detached while running. Essentially always, for multi-container apps. This is what docker compose creates per project — which is why service names “just work” there and nobody learns that the DNS is a property of the network.
Default bridge
(no --network flag)
One shared legacy bridge on 172.17.0.0/16. Every container on the host lands here by default and can reach every other one by IP. No embedded DNS/etc/resolv.conf points straight at the host's upstream resolver, so container names mean nothing. Never, deliberately. It exists for backward compatibility with --link. Its two failure modes: names don't resolve, and isolation is nil — every unrelated container on the box is one IP away.
--network none A network namespace with nothing in it but loopback. No eth0, no route, no gateway. Batch jobs with no business talking to anything: untrusted format conversion, a build step, a fuzzing target.
--network host No network namespace at all — the container shares the host's stack. -p becomes meaningless; a bind to :8080 binds the host's :8080. Workloads where the NAT hop measurably matters. Note it doesn't behave the way you expect on macOS/Windows, where “the host” is a Linux VM, not your laptop.

The picture

net-blue 172.20.0.0/16 net-green 172.21.0.0/16 ┌───────────────────────────┐ ┌──────────────────────────┐ │ net-alpha 172.20.0.2 │ │ net-gamma 172.21.0.2 │ │ net-beta 172.20.0.3 │ │ │ │ │ ╳ │ │ │ DNS 127.0.0.11 knows: │ no route │ DNS 127.0.0.11 knows: │ │ net-alpha, net-beta │ no DNS │ net-gamma │ └───────────────────────────┘ └──────────────────────────┘ Same host. Same kernel. Same bridge driver. The only difference between "one hop away" and "unreachable" is which box a name is in.

Setup

Two terminals, side by side. Everything below is alpine-based, so the tools (ping, getent, nslookup, wget, ip) are busybox applets that are already there — nothing to install. nginx:alpine is used so each container is also serving something on port 80, which makes reachability testable with more than ICMP.

docker network create net-blue
docker network create net-green

docker run -d --name net-alpha --network net-blue  nginx:alpine
docker run -d --name net-beta  --network net-blue  nginx:alpine
docker run -d --name net-gamma --network net-green nginx:alpine

Note what is not in those docker run lines: no -p. Nothing is published to your host at all, and every bit of traffic in Parts 1–4 still works. Hold onto that for Part 5.

Terminal A — a shell inside net-alpha:

docker exec -it net-alpha sh

Terminal B — a shell inside net-gamma (not needed until Part 3, but open it now):

docker exec -it net-gamma sh

Some steps are run from your ordinary host shell rather than inside a container — those are labelled with the third tag colour.

If you're on macOS The Docker engine is a Linux VM, so container IPs like 172.20.0.2 are not routable from your Mac's terminal — ping 172.20.0.2 from macOS fails no matter how healthy the network is. Every observation below is therefore made from inside a container, which is where it belongs anyway. Your subnets may also differ from the 172.20/172.21 shown here; Docker allocates them in creation order.

Part 1 — one network, and the resolver that comes with it

What this part tests: the baseline. Two containers on one user-defined bridge, checking the two questions separately — name resolution first, then reachability — so that when they come apart in Part 2 you know what “together” looked like.

Step 1 · where you are
net-alpha
ip -4 -o addr show eth0
11: eth0 inet 172.20.0.2/16 brd 172.20.255.255 scope global eth0
Step 2 · who answers for the name "net-beta"?
net-alpha
cat /etc/resolv.conf
Predict: net-beta is a name Docker invented. Something has to answer for it — what's in /etc/resolv.conf? Click to check.
# Generated by Docker Engine. # This file can be edited; Docker Engine will not make further changes once it # has been modified. nameserver 127.0.0.11 options ndots:0 # Based on host file: '/etc/resolv.conf' (internal resolver) # ExtServers: [host(192.168.65.7)]

127.0.0.11 — a loopback address inside this container's network namespace, where Docker runs a small DNS server. This is the whole mechanism, sitting in plain sight. It answers for container names on the networks this container belongs to and forwards everything else to the real resolver listed as ExtServers. ndots:0 means even a bare single-label name like net-beta is tried as-is rather than being glued onto a search domain first.

Step 3 · question one: does the name resolve?
net-alpha
getent hosts net-beta
nslookup net-beta
172.20.0.3 net-beta net-beta
Server: 127.0.0.11 Address: 127.0.0.11:53 Non-authoritative answer: Name: net-beta Address: 172.20.0.3

nslookup names the culprit explicitly: the answer came from 127.0.0.11. Not from your ISP, not from /etc/hosts.

Step 4 · question two: can packets get there?
net-alpha
ping -c 3 net-beta
wget -qO- http://net-beta/ | grep title
PING net-beta (172.20.0.3): 56 data bytes 64 bytes from 172.20.0.3: seq=0 ttl=64 time=0.128 ms 64 bytes from 172.20.0.3: seq=1 ttl=64 time=0.127 ms 64 bytes from 172.20.0.3: seq=2 ttl=64 time=0.064 ms --- net-beta ping statistics --- 3 packets transmitted, 3 packets received, 0% packet loss
<title>Welcome to nginx!</title>

Both questions answer yes. 0.1 ms — a virtual cable across a bridge inside the same kernel. And note that HTTP worked with no -p anywhere.

Step 5 · what a reachable host with no listener looks like
net-alpha
wget -O- -T 5 http://net-beta:9999/
Connecting to net-beta:9999 (172.20.0.3:9999) wget: can't connect to remote host (172.20.0.3): Connection refused

Instant, and explicit: net-beta's kernel received the SYN and sent back a RST. Remember this shape — Part 3 runs the same request against the same nginx and gets something very different.

Step 6 · the membership list Docker is tracking
host shell
docker network inspect net-blue \
  --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{println}}{{end}}'
net-beta 172.20.0.3/16 net-alpha 172.20.0.2/16

That list is the DNS zone. Nothing else is.


Part 2 — the default bridge, where the two answers come apart

What this part tests: the claim in the mental model that name resolution and reachability are independent. The default bridge network is the cleanest proof, because it has no embedded DNS but perfectly ordinary connectivity — everything works except names.

Step 7 · two containers with no --network at all
host shell
docker run -d --name net-legacy1 nginx:alpine
docker run -d --name net-legacy2 nginx:alpine
docker exec net-legacy2 ip -4 -o addr show eth0
11: eth0 inet 172.17.0.6/16 brd 172.17.255.255 scope global eth0

172.17.x — the default bridge's subnet. Note the address; you need it in a moment, and yours will differ depending on what else is running on your machine.

Step 8 · ask for the name
host shell → net-legacy1
docker exec -it net-legacy1 sh
cat /etc/resolv.conf
getent hosts net-legacy2 ; echo "exit=$?"
ping -c 2 net-legacy2
Predict: same host, same bridge driver, two containers one subnet apart. Does net-legacy1 resolve net-legacy2? Click to check.
# Generated by Docker Engine. nameserver 192.168.65.7 # Based on host file: '/etc/resolv.conf' (legacy)
exit=2
ping: bad address 'net-legacy2'

No 127.0.0.11. The comment Docker writes into the file even labels it: (legacy). There is no embedded resolver in this container, so net-legacy2 goes to the upstream nameserver, which has of course never heard of it — nslookup spells it out: ** server can't find net-legacy2: NXDOMAIN.

Step 9 · now ask the other question, with the raw address
net-legacy1
ping -c 3 172.17.0.6
wget -qO- http://172.17.0.6/ | grep title
Predict: the name is dead. So is net-legacy2 unreachable? Click to check.
PING 172.17.0.6 (172.17.0.6): 56 data bytes 64 bytes from 172.17.0.6: seq=0 ttl=64 time=0.146 ms 64 bytes from 172.17.0.6: seq=1 ttl=64 time=0.120 ms 64 bytes from 172.17.0.6: seq=2 ttl=64 time=0.115 ms --- 172.17.0.6 ping statistics --- 3 packets transmitted, 3 packets received, 0% packet loss
<title>Welcome to nginx!</title>

Perfectly reachable, at identical latency to Part 1. The two questions have come apart:

Default bridgeUser-defined net-blue
/etc/resolv.confnameserver 192.168.65.7
the host's upstream resolver
nameserver 127.0.0.11
Docker's embedded DNS
Name resolves?Nobad address / NXDOMAINYes172.20.0.3
IP reachable?Yes — 0% packet lossYes — 0% packet loss
Why ping name is the wrong diagnostic The identical ping: bad address you'd get from a genuinely isolated container means something completely different here: this network is not isolated at all. Every container on the default bridge — including anything else running on your machine right now — can reach every other one. What it lacks is a name service, and that's a convenience feature, not a security boundary.

Part 3 — a different network, where nothing works and the failure looks different

What this part tests: the actual isolation boundary. net-gamma is on net-green and shares no network with anything in net-blue. Both questions should now answer no — and per the mental model, the reason each fails should be legible in the shape of the failure.

Step 10 · establish that nothing is missing on this side
net-gamma
grep nameserver /etc/resolv.conf
ip -4 -o addr show eth0
ip route
nameserver 127.0.0.11 11: eth0 inet 172.21.0.2/16 brd 172.21.255.255 scope global eth0 default via 172.21.0.1 dev eth0 172.21.0.0/16 dev eth0 scope link src 172.21.0.2

Note this carefully. net-gamma does have Docker's DNS at 127.0.0.11 — the resolver isn't missing here. It also has a default route, so it isn't route-less either.

Step 11 · question one
net-gamma
getent hosts net-alpha ; echo "exit=$?"
nslookup net-alpha
Predict: the embedded resolver is present and healthy. So does getent hosts net-alpha work? Click to check.
exit=2
Server: 127.0.0.11 Address: 127.0.0.11:53 ** server can't find net-alpha: NXDOMAIN

Read those two lines together — they're the sharpest thing in this exercise. The embedded DNS answered. It's running, it's reachable, it took the query and returned NXDOMAIN. The resolver is per container, but the zone it serves is per network: it knows the members of the networks this container is attached to and nothing else. net-alpha is not a name that exists in net-gamma's universe.

Step 12 · question two, with the raw address
net-gamma
ping -c 3 -w 6 172.20.0.2
wget -O- http://172.20.0.2/
Predict: both containers sit on bridges in the same kernel, and net-gamma has a default gateway. Refused? Timeout? Network unreachable? Click to check.
PING 172.20.0.2 (172.20.0.2): 56 data bytes --- 172.20.0.2 ping statistics --- 3 packets transmitted, 0 packets received, 100% packet loss
Connecting to 172.20.0.2 (172.20.0.2:80)
and there it sits. The wget never returns — Ctrl-C it, or re-run as wget -O- -T 5 … to get wget: download timed out after five seconds.

Compare with Step 5's instant Connection refused from the same nginx serving the same port. Not a rejection — a silent drop. Something between the two bridges is swallowing the packets and answering nothing, which is precisely the signature of a firewall rule (Docker's DOCKER-ISOLATION-STAGE-1/-2 iptables chains, which exist to drop exactly this traffic).

The isolation isn't a missing wire: the route exists, the packets are emitted, and a rule kills them in flight.


Part 4 — attach a running container to a second network

What this part tests: that membership is live state, not something baked in at docker run time. Per the mental model, user-defined bridges accept attach and detach on running containers — and if the DNS zone really is just “the members of the networks I'm on”, a name should start resolving the instant membership changes, with no restart.

Step 13 · baseline, and note the exact process
net-gamma
getent hosts net-beta ; echo "exit=$?"
exit=2
host shell
docker inspect -f '{{.State.StartedAt}} pid={{.State.Pid}}' net-beta
2026-07-22T15:55:34.734351Z pid=15777
Step 14 · attach the running container
host shell
docker network connect net-green net-beta

No restart, no docker stop, nothing else. Then, immediately, in net-gamma:

getent hosts net-beta
wget -qO- http://net-beta/ | grep title
Predict: does net-beta need a restart before net-gamma can find it? Does its PID change? Click to check.
172.21.0.3 net-beta net-beta
<title>Welcome to nginx!</title>

And back on the host, docker inspect again:

2026-07-22T15:55:34.734351Z pid=15777

Same start time, same PID. Nothing restarted; nginx never noticed. A new veth pair was pushed into the existing network namespace and the DNS zone was updated, live.

Step 15 · ask the same question from both networks
net-alpha net-gamma
getent hosts net-beta
asked from net-alpha (net-blue)
172.20.0.3 net-beta net-beta
asked from net-gamma (net-green)
172.21.0.3 net-beta net-beta

One name, two answers, decided entirely by which network the asker is on. A dual-homed container has an address per network, and Docker's DNS hands each side the one that is actually usable from there. Confirm it from the host:

host shell
docker exec net-beta ip -4 -o addr show
11: eth0 inet 172.20.0.3/16 brd 172.20.255.255 scope global eth0 12: eth1 inet 172.21.0.3/16 brd 172.21.255.255 scope global eth1
Step 16 · does a dual-homed container bridge the two networks?
net-gamma
getent hosts net-alpha ; echo "exit=$?"
wget -O- -T 4 http://172.20.0.2/
Predict: net-beta now talks to both net-alpha and net-gamma. Can net-gamma reach net-alpha through it? Click to check.
exit=2
Connecting to 172.20.0.2 (172.20.0.2:80) wget: download timed out

Unchanged. Being multi-homed makes net-beta reachable from both sides; it does not make it a router. There is no forwarding between its two interfaces and no transitive DNS.

This is the shape of a real pattern: put your reverse proxy on frontend and backend, leave the database on backend only, and the database is unreachable from the frontend network without a single firewall rule of your own.

The symmetric move docker network disconnect net-green net-beta and net-gamma's getent goes straight back to exit=2, with eth1 gone from net-beta — again with no restart. Reconnect before continuing if you try it.

Part 5 — published ports have nothing to do with any of this

What this part tests: the most common misconception in the topic — that a container needs -p 8080:80 before another container can reach port 80. Everything above already disproves it (no -p anywhere), but it's worth watching the mapping actively fail to help.

Step 17 · a container that does publish a port
host shell
docker run -d --name net-pub --network net-blue -p 39217:80 nginx:alpine
docker port net-pub
docker port net-alpha
80/tcp -> 0.0.0.0:39217 80/tcp -> [::]:39217

…and nothing at all for net-alpha, which has published nothing and has been happily serving Part 1's requests regardless.

Step 18 · from the same network, which port works?
net-alpha
wget -qO- http://net-pub/ | grep title
wget -O- -T 4 http://net-pub:39217/
Predict: from net-alpha, which succeeds — port 80, the published 39217, or both? Click to check.
<title>Welcome to nginx!</title>
Connecting to net-pub:39217 (172.20.0.4:39217) wget: can't connect to remote host (172.20.0.4): Connection refused

Backwards from most people's mental model, and the refusal is the proof: port 39217 does not exist inside the container. -p creates a DNAT rule on the host rewriting host:39217 → container:80. From inside the container network there is no 39217 to connect to, only the 80 that nginx actually binds. Container-to-container traffic never touches that rule.

Step 19 · does publishing help across networks?
net-gamma
wget -O- -T 4 http://net-pub/
wget -O- -T 4 http://172.20.0.4:39217/
Predict: net-pub is published to the world. Does that make it reachable from net-gamma? Click to check.
wget: bad address 'net-pub'
Connecting to 172.20.0.4:39217 (172.20.0.4:39217) wget: download timed out

Publishing changed nothing about cross-network access. It exposes the container to the host's network stack, and net-gamma is not the host. Curl the host and the port is plainly there: curl -o /dev/null -w '%{http_code}' http://localhost:39217/ returns 200.

Step 20 · the exception that proves it
net-gamma
wget -qO- http://host.docker.internal:39217/ | grep title
<title>Welcome to nginx!</title>

net-gamma just reached a container it cannot reach — by leaving the container network entirely, arriving at the host's published port, and being DNAT'd back in. The isolation is between the two bridges; routing around it via the host is a real, and frequently accidental, hole.


What you should see

On one user-defined network: nameserver 127.0.0.11 in /etc/resolv.conf, getent hosts net-beta172.20.0.3, and 0% packet loss. On the default bridge: no embedded resolver at all, ping: bad address 'net-legacy2' — and yet ping 172.17.0.6 at the same 0.1 ms, the two questions visibly diverging. Across two networks: an embedded resolver that is running and answers NXDOMAIN, and a wget that connects to nothing forever rather than being refused. Then docker network connect on a running container making its name resolve immediately — to 172.21.0.3 from one network and 172.20.0.3 from the other, same name, same instant, same container, PID unchanged. And a published -p 39217:80 that is Connection refused from inside the container's own network.

Why

Docker's embedded DNS server is reachable at 127.0.0.11 inside a container's network namespace, and the records it serves are derived from exactly one thing: the set of endpoints on the networks that container is attached to. That's why the resolver can be present, healthy, and still return NXDOMAIN — it isn't broken, it's answering correctly for a zone that doesn't contain the name you asked for. It also explains the two different answers for net-beta in Part 4: the zone is built per network, so a container on two networks has two records, and each asker is told the address reachable from where it stands. And it explains why the default bridge has none of this — it predates the feature and is kept for compatibility, so containers there fall back to the host's resolver, which has no idea what a container is.

The packet side is a separate mechanism with the same root cause. Each user-defined network is a Linux bridge, and a container's veth lands in exactly one bridge per network it joins. Traffic to another bridge's subnet does get routed — the container emits it, the gateway sees it — and is then dropped by Docker's DOCKER-ISOLATION-STAGE-1/-2 iptables chains, which exist for precisely this purpose. Dropped, not rejected, which is why you get a hang rather than a Connection refused. That distinction is the whole diagnostic value of Part 3.

Publishing a port is a third, unrelated mechanism: a DNAT rule in the host's nat table mapping a host port to a container's IP and port. It concerns the boundary between the host and the container world, and has nothing to say about traffic already inside that world. Ports are open between containers on a shared network by default; -p is how you let your laptop in, and nothing else.

The actual lesson On a single Docker host, isolation is not a consequence of topology. Every container is a few virtual hops from every other one and always was. What separates them is bookkeeping — a DNS zone and a chain of iptables rules, both derived from a membership list you can change with docker network connect and disconnect, live, on running containers.

Go deeper

Sources: Docker Docs: Bridge network driver — in particular the “differences between user-defined bridges and the default bridge” section, which lists the DNS behavior you just watched · Docker Docs: Networking overview for the other drivers


Cleanup

Remove only what this exercise created, by name:

docker rm -f net-alpha net-beta net-gamma net-pub net-legacy1 net-legacy2 net-delta net-solo
docker network rm net-blue net-green
Do not prune net-delta and net-solo only exist if you did the “Go deeper” steps. Never reach for docker network prune or docker system prune here — they would take out networks and containers belonging to other projects on your machine.