studyDocker › Container Networking

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.


Provenance

Two pages of the Docker Engine networking documentation, and the exercise tests one sentence from each.

The gap the exercise is aimed at What neither page says out loud — and what Part 3 is for — is the scope of that resolution. “Automatic DNS resolution between containers” turns out to mean “between containers on this network”, and the way you discover the qualifier is a resolver that is running, reachable, and answers NXDOMAIN.

Prerequisites

The exercise's own prose covers most of what you need. Two things it leans on without deriving:


Environment

Two figures below are specific to this machine and will differ on yours without changing anything: the upstream resolver 192.168.65.7 (Docker Desktop's VM gateway) and the default-bridge address 172.17.0.10 (which depends on what else you have running). Ping latencies are illustrative — the claim rests on 0% vs 100% packet loss, not on the milliseconds.


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 is a copy of the host's, 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 and leave it open — Part 4 depends on the same shell process outliving a network change made from outside it):

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\ valid_lft forever preferred_lft forever
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?
# 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)] # Overrides: [] # Option ndots from: internal

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: 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. (busybox nslookup asks for A and AAAA and prints a header for each, which is why “Non-authoritative answer:” appears twice — the first, empty one is the AAAA reply.)

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.943 ms 64 bytes from 172.20.0.3: seq=1 ttl=64 time=0.079 ms 64 bytes from 172.20.0.3: seq=2 ttl=64 time=0.130 ms --- net-beta ping statistics --- 3 packets transmitted, 3 packets received, 0% packet loss round-trip min/avg/max = 0.079/0.384/0.943 ms
<title>Welcome to nginx!</title>

Both questions answer yes. Sub-millisecond, with the first packet the slow one (the ARP round trip) — a virtual cable across a bridge inside the same kernel. The exact times are illustrative; 0% packet loss is the claim. 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.10/16 brd 172.17.255.255 scope global eth0\ valid_lft forever preferred_lft forever

172.17.x — the default bridge's subnet. Note your address; you need it in a moment, and it will differ from this one depending on what else is already running on your machine. Substitute it everywhere 172.17.0.10 appears below.

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
nslookup net-legacy2
Predict: same host, same bridge driver, two containers one subnet apart. Does net-legacy1 resolve net-legacy2?
# Generated by Docker Engine. # This file can be edited; Docker Engine will not make further changes once it # has been modified. nameserver 192.168.65.7 # Based on host file: '/etc/resolv.conf' (legacy) # Overrides: []
exit=2
ping: bad address 'net-legacy2'
Server: 192.168.65.7 Address: 192.168.65.7:53 Non-authoritative answer: ** server can't find net-legacy2: NXDOMAIN

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 — 192.168.65.7, Docker Desktop's VM gateway, which is simply the host's own resolver copied in — and it has of course never heard of it.

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

Perfectly reachable, at the same sub-millisecond order as 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\ valid_lft forever preferred_lft forever 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?
exit=2
Server: 127.0.0.11 Address: 127.0.0.11:53 Non-authoritative answer: ** 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?
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 — it was still sitting there when this capture was cut off after 12 seconds. Ctrl-C it, or re-run with an explicit timeout.
wget -O- -T 5 http://172.20.0.2/
Connecting to 172.20.0.2 (172.20.0.2:80) wget: download timed out

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. And note the two failures were independent — the ping never sent a DNS query, and the getent in Step 11 never sent a packet to net-alpha. Each half broke for its own reason.


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 — and per the Docker docs quoted in Provenance — user-defined bridges accept attach and detach on running containers. 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.

Do this one in the same Terminal B shell you opened in Setup. That the running shell's view changes underneath it, without the shell or the container restarting, is the point of the part.

Step 13 · baseline, and note the exact processes
net-gamma
echo "SESSION-PID=$$"
getent hosts net-beta ; echo "exit=$?"
SESSION-PID=149 exit=2
host shell
docker inspect -f '{{.State.StartedAt}} pid={{.State.Pid}}' net-beta
2026-07-25T16:06:05.809984334Z pid=24477
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 the still-open net-gamma shell:

getent hosts net-beta ; echo "exit=$?"
echo "SESSION-PID-STILL=$$"
wget -qO- http://net-beta/ | grep title
Predict: does net-beta need a restart before net-gamma can find it? Does its PID change?
172.21.0.3 net-beta net-beta exit=0 SESSION-PID-STILL=149
<title>Welcome to nginx!</title>

And back on the host, docker inspect again:

2026-07-25T16:06:05.809984334Z pid=24477

Same shell (PID 149 before and after), same container start time, same container PID. Nothing restarted; nginx never noticed. A new veth pair was pushed into the existing network namespace and the DNS zone was updated, live, under a process that was already running.

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
1: lo inet 127.0.0.1/8 scope host lo\ valid_lft forever preferred_lft forever 11: eth0 inet 172.20.0.3/16 brd 172.20.255.255 scope global eth0\ valid_lft forever preferred_lft forever 12: eth1 inet 172.21.0.3/16 brd 172.21.255.255 scope global eth1\ valid_lft forever preferred_lft forever
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?
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.

Step 17 · the symmetric move
host shell
docker network disconnect net-green net-beta

Immediately, from net-gamma — and then from the host:

exit=2
1: lo inet 127.0.0.1/8 scope host lo\ valid_lft forever preferred_lft forever 11: eth0 inet 172.20.0.3/16 brd 172.20.255.255 scope global eth0\ valid_lft forever preferred_lft forever

eth1 is gone and the name is dead again, once more with no restart. Reconnect with docker network connect net-green net-beta before continuing — it comes back as 172.21.0.3. The interface index may not: a reconnected endpoint gets a fresh ifindex, so eth1 showed up as 14: rather than 12: on the second attach.


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 18 · 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: docker port net-alpha prints zero lines and still exits 0. It has published nothing and has been happily serving Part 1's requests regardless.

Step 19 · 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?
<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 20 · 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?
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 -s -o /dev/null -w '%{http_code}' http://localhost:39217/ returns 200.

Step 21 · 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.10 at the same sub-millisecond latency, 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, container PID and even the asking shell's 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 get a copy of the host's /etc/resolv.conf, whose nameserver 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 net-vault
Do not prune net-delta, net-solo and net-vault only exist if you did the “Go deeper” steps; docker network rm on a name that isn't there prints an error and changes nothing. 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.