10. Networking
Docker · 10. NetworkingDocker networking has a reputation for being magic. It is not — it is four kernel features Docker configures for you, and once you can name them, every behaviour follows.
The default bridge, built by hand
Section titled “The default bridge, built by hand”When Docker starts, it creates a virtual switch on the host:
docker0 a LINUX BRIDGE — a software ethernet switch with an IP, usually 172.17.0.1/16When you start a container, Docker creates a veth pair: two virtual network interfaces joined like a cable. One end goes into the container’s network namespace and is renamed eth0; the other stays on the host and is plugged into the bridge.
HOST NAMESPACE CONTAINER NAMESPACE
┌────────────────────┐ │ eth0 10.0.1.5 │ ← the real NIC │ │ │ docker0 172.17.0.1 │ ├── veth9a3f ══════════════ eth0 172.17.0.2 container A │ ├── veth2b81 ══════════════ eth0 172.17.0.3 container B │ └── vethc047 ══════════════ eth0 172.17.0.4 container C └────────────────────┘ ↑ a veth PAIR: what goes in one end comes out the otherThat is the whole topology. Containers on the same bridge can reach each other directly, because they are on the same layer-2 segment — 172.17.0.2 can ping 172.17.0.3 with no configuration at all.
Outbound traffic to the internet goes: container → veth → docker0 → the host’s routing table → iptables MASQUERADE rewrites the source address to the host’s IP → out the real NIC. Replies come back and are rewritten in reverse. This is exactly what a home router does for the devices behind it, implemented in the host kernel.
You can see all of it:
ip link show type bridge → docker0 bridge link → which veths are attached iptables -t nat -L -n → the MASQUERADE and DNAT rules docker network inspect bridge → the containers and their IPsPublishing a port is a DNAT rule
Section titled “Publishing a port is a DNAT rule” docker run -p 8080:80 nginxThis does exactly one thing at the kernel level:
iptables -t nat -A DOCKER \ -p tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80“Any packet arriving at this host on port 8080, rewrite its destination to 172.17.0.2 port 80.” Plus a docker-proxy userspace process as a fallback for cases iptables cannot cover (notably container-to-its-own-published-port traffic and hosts without the right kernel modules).
Two consequences.
EXPOSE in the Dockerfile publishes nothing. It is a label. Chapter 5 said this; here is why it is true: nothing about EXPOSE writes an iptables rule. docker run -P (capital P) reads the EXPOSE metadata and publishes each port to a random high host port, and that is the only thing that consumes it.
Published ports bypass the host firewall. This one causes real incidents. Docker inserts its DNAT rules into the nat table’s PREROUTING chain, which is evaluated before the filter table’s INPUT chain where UFW and firewalld put their rules. So:
ufw deny 5432 docker run -p 5432:5432 postgres → Postgres is reachable from the internet. UFW says it is blocked. UFW is not lying; it is simply never consulted for this traffic.The fix is to bind to a specific interface rather than all of them:
docker run -p 127.0.0.1:5432:5432 postgres ← localhost only docker run -p 10.0.1.5:5432:5432 postgres ← one private IPAlways specify the bind address for anything that should not be public. -p 5432:5432 means 0.0.0.0:5432 — every interface, including the public one.
User-defined networks, and DNS
Section titled “User-defined networks, and DNS”The default bridge network is a legacy thing. Containers on it have IPs and no name resolution — you would have to know that the database is 172.17.0.3, which changes every time it restarts.
A user-defined bridge adds an embedded DNS server:
docker network create appnet docker run -d --name db --network appnet postgres docker run -d --name api --network appnet myapiNow, from inside api:
psql -h db -U postgres ← "db" resolves. To the current IP.Docker runs a DNS resolver at 127.0.0.11 inside each container’s namespace, and every container’s /etc/resolv.conf points at it. It resolves container names, network aliases and Compose service names, and forwards everything else to the host’s resolvers.
DEFAULT "bridge" NETWORK USER-DEFINED NETWORK
no DNS — IPs only DNS by container name all containers can reach only containers on this all others network can reach each other --link (deprecated, don't) aliases, multiple networks changing settings needs a attach/detach a running daemon restart container on the flyAlways create a user-defined network. Compose does it for you automatically, which is one of several reasons Compose is worth using even for two containers.
Isolation between networks is real: a container on frontend cannot reach a container on backend unless it is attached to both. That is the basis of the standard three-tier pattern:
docker network create frontend docker network create backend
proxy → frontend api → frontend AND backend db → backend only ← unreachable from proxy, by constructionlocalhost does not mean what you want
Section titled “localhost does not mean what you want”Chapter 2’s network namespace, stated as the rule people actually need:
INSIDE A CONTAINER, "localhost" MEANS THAT CONTAINER. WRONG RIGHT
api connects to api connects to localhost:5432 db:5432 → connection refused → works, on a user-defined networkAnd in the other direction — reaching the host from inside a container:
host.docker.internal Docker Desktop (Mac/Windows): built in. Linux: add --add-host host.docker.internal:host-gateway
172.17.0.1 the bridge gateway address = the host, on the default bridge. Works but is brittle if the subnet changes.
--network host the container shares the host's stack; localhost genuinely is the host.The host-gateway form is the portable one:
docker run --add-host=host.docker.internal:host-gateway myappOne more common case: a service on the host bound to 127.0.0.1 is not reachable from a container even with host.docker.internal, because the container arrives on the bridge interface, not on loopback. It must be bound to 0.0.0.0 or to the bridge IP.
The other network modes
Section titled “The other network modes” bridge (default) the topology above. NAT, port publishing.
host NO network namespace at all. The container uses the host's stack directly: same IP, same ports, no NAT. → fastest (no NAT, no veth hop) — meaningful for very high packet rates → -p does nothing and is ignored with a warning → real port conflicts with the host and other containers → Linux only. Docker Desktop's version is a partial emulation with different behaviour.
none a network namespace with only loopback. Nothing in, nothing out. Good for batch jobs that must not reach the network.
container:<id> share ANOTHER container's namespace. Same IP, same ports, and they reach each other on localhost. This is exactly what a Kubernetes Pod is — see the Kubernetes networking chapter.
macvlan the container gets its OWN MAC address and an IP on the physical LAN. It looks like a separate machine to the rest of the network. Used for legacy systems that must be addressable directly. Note: the host itself usually cannot reach its own macvlan containers, which surprises everyone the first time.
overlay a VXLAN tunnel across multiple hosts, for Swarm. Kubernetes solves the same problem with CNI plugins instead.The container:<id> mode is worth dwelling on, because it makes Kubernetes click: a Pod is a set of containers sharing one network namespace. That is why containers in a Pod talk over localhost and share a port space — it is this Docker feature, and the Kubernetes networking chapter builds on it.
DNS inside containers
Section titled “DNS inside containers” docker run --dns 10.0.0.53 myapp override the resolver docker run --dns-search corp.local add a search domainContainers inherit the host’s /etc/resolv.conf by default, with two adjustments: 127.0.0.1 entries are stripped (they would mean the container itself), and on user-defined networks it is replaced entirely by 127.0.0.11.
Two recurring problems:
A host using systemd-resolved. Its /etc/resolv.conf points at 127.0.0.53, which is stripped, and containers silently fall back to Google’s 8.8.8.8. Internal names stop resolving. Fix by setting real resolvers in /etc/docker/daemon.json:
{ "dns": ["10.0.0.53", "10.0.0.54"] }Alpine and musl. Chapter 5’s warning applies here specifically. musl’s resolver queries all nameservers in parallel and takes the first response, does not always retry over TCP for large answers, and historically handled search domains differently. Symptoms are intermittent and confusing. If DNS is flaky in Alpine containers and fine everywhere else, that is the cause; switching to a -slim base is usually faster than debugging it.
Inspecting a container’s network
Section titled “Inspecting a container’s network” docker network inspect appnet which containers, which IPs docker port <container> the published port mapping docker exec c ip addr interfaces as the container sees docker exec c cat /etc/resolv.conf the resolver in use docker exec c getent hosts db does the name resolve docker exec c nc -zv db 5432 can it actually connectFor an image with no tools, join its namespace from one that has them:
docker run --rm -it --network container:<id> nicolaka/netshootThat container has dig, tcpdump, curl, ss and iperf, and it is in the target’s network namespace, so it sees exactly what the target sees. It is the single most useful debugging trick in Docker networking.
What to take from this chapter
Section titled “What to take from this chapter”- A container’s network is a veth pair — one end renamed
eth0inside the namespace, one end plugged into thedocker0bridge. Outbound traffic is iptables MASQUERADE, exactly like a home router. - Publishing a port is a DNAT rule, nothing more.
EXPOSEwrites no rule and publishes nothing. - Published ports bypass UFW and firewalld, because Docker’s DNAT runs in
PREROUTING, before thefilter/INPUTchain those tools use. Always bind explicitly —-p 127.0.0.1:5432:5432, not-p 5432:5432. - The default
bridgenetwork has no DNS. A user-defined network gives you an embedded resolver at127.0.0.11that resolves container names to current IPs. Always create one; Compose does it for you. - Separate networks are a real isolation boundary — the
frontend/backendsplit makes the database unreachable from the proxy by construction. localhostinside a container is that container. To reach the host usehost.docker.internalwith--add-host=host.docker.internal:host-gateway, and note a host service bound to127.0.0.1is still unreachable.- Modes:
hostremoves the namespace (fastest, no-p, real port conflicts),noneisolates completely,container:<id>shares another container’s namespace — which is precisely what a Kubernetes Pod is — andmacvlanputs the container on the physical LAN with its own MAC. - DNS breaks in two predictable ways: a systemd-resolved host leaving containers on public resolvers, and musl’s resolver on Alpine failing intermittently.
- The best debugging move is
--network container:<id>withnicolaka/netshoot— full tooling, inside the target’s namespace.
Next: security — the socket, rootless mode, capabilities, what --privileged really does, and how much of the CVE noise actually matters.