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.
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.
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.
| Question | Command | What 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. |
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 see | What 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. |
| Mode | What it actually does | Reach 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. |
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.
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.
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.
ip -4 -o addr show eth0
cat /etc/resolv.conf
net-beta is a name Docker invented. Something has to answer for it — what's in /etc/resolv.conf? Click to check.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.
getent hosts net-beta
nslookup net-beta
nslookup names the culprit explicitly: the answer came from 127.0.0.11. Not from your ISP, not from /etc/hosts.
ping -c 3 net-beta
wget -qO- http://net-beta/ | grep 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.
wget -O- -T 5 http://net-beta:9999/
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.
docker network inspect net-blue \
--format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{println}}{{end}}'
That list is the DNS zone. Nothing else is.
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.
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
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.
docker exec -it net-legacy1 sh
cat /etc/resolv.conf
getent hosts net-legacy2 ; echo "exit=$?"
ping -c 2 net-legacy2
net-legacy1 resolve net-legacy2? Click to check.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.
ping -c 3 172.17.0.6
wget -qO- http://172.17.0.6/ | grep title
net-legacy2 unreachable? Click to check.Perfectly reachable, at identical latency to Part 1. The two questions have come apart:
Default bridge | User-defined net-blue | |
|---|---|---|
| /etc/resolv.conf | nameserver 192.168.65.7the host's upstream resolver | nameserver 127.0.0.11Docker's embedded DNS |
| Name resolves? | No — bad address / NXDOMAIN | Yes — 172.20.0.3 |
| IP reachable? | Yes — 0% packet loss | Yes — 0% packet loss |
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.
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.
grep nameserver /etc/resolv.conf
ip -4 -o addr show eth0
ip route
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.
getent hosts net-alpha ; echo "exit=$?"
nslookup net-alpha
getent hosts net-alpha work? Click to check.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.
ping -c 3 -w 6 172.20.0.2
wget -O- http://172.20.0.2/
net-gamma has a default gateway. Refused? Timeout? Network unreachable? Click to check.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.
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.
getent hosts net-beta ; echo "exit=$?"
docker inspect -f '{{.State.StartedAt}} pid={{.State.Pid}}' net-beta
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
net-beta need a restart before net-gamma can find it? Does its PID change? Click to check.And back on the host, docker inspect again:
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.
getent hosts 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 shelldocker exec net-beta ip -4 -o addr show
getent hosts net-alpha ; echo "exit=$?"
wget -O- -T 4 http://172.20.0.2/
net-beta now talks to both net-alpha and net-gamma. Can net-gamma reach net-alpha through it? Click to check.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.
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.
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.
docker run -d --name net-pub --network net-blue -p 39217:80 nginx:alpine
docker port net-pub
docker port net-alpha
…and nothing at all for net-alpha, which has published nothing and has been happily serving Part 1's requests regardless.
wget -qO- http://net-pub/ | grep title
wget -O- -T 4 http://net-pub:39217/
net-alpha, which succeeds — port 80, the published 39217, or both? Click to check.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.
wget -O- -T 4 http://net-pub/
wget -O- -T 4 http://172.20.0.4:39217/
net-pub is published to the world. Does that make it reachable from net-gamma? Click to check.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.
wget -qO- http://host.docker.internal:39217/ | grep 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.
On one user-defined network: nameserver 127.0.0.11 in /etc/resolv.conf, getent hosts net-beta → 172.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.
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.
docker network connect and disconnect, live, on running containers.
docker run -d --name net-delta --network net-blue --network-alias db --network-alias cache nginx:alpine, then from net-alpha ask for db, cache and net-delta — all three return 172.20.0.5. The alias is per network attachment, not per container, so the same container can be db on one network and something else on another. This is how blue/green cutovers work without touching client config: attach the new container under the alias, detach the old one.docker run -d --name net-solo --network none nginx:alpine, then docker exec net-solo ip -o addr show — only lo. Reaching for a container IP gives an instant wget: can't connect to remote host (172.20.0.2): Network unreachable — the fourth failure signature from the mental model, and a different one from Part 3's timeout: here the container's own kernel has no route and never puts a packet on any wire.sudo iptables -L DOCKER-ISOLATION-STAGE-2 -v -n while re-running Part 3's wget, and watch the DROP rule's packet counter tick up. That counter is the isolation boundary, in one number.--internal networks. docker network create --internal net-vault builds a bridge with no route to the outside: containers on it can talk to each other and to nothing else, not even the internet. Compare wget -O- -T 5 http://example.com/ from a container there versus one on net-blue. This is the “database subnet” pattern done properly.docker compose is doing. Bring up any compose project and run docker network ls — there's a <project>_default user-defined bridge. Everything in Parts 1 and 4 is what makes postgres:5432 resolve from your app container. Compose is a thin wrapper over exactly these commands.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
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
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.