Docker Networking From Scratch
25 Sep 2025I’ve been writing Dockerfiles for years — containers, compose stacks, the whole thing. But I never really understood what Docker does when it “creates a network” for a container. So I rebuilt it by hand. Some reading, some Claude, and most of it at a terminal — this post is what I figured out.
First, what is a network interface?
Everything here is built from network interfaces, so it’s worth being clear about what one is.
Your machine has a network card (a NIC) — the Ethernet port or the WiFi chip. Every NIC comes with a MAC address, a hardware address like ca:0e:af:0a:e0:29 that’s set at the factory.
Conceptually, the card just moves data between the wire and memory. Everything smart — IP addresses, routing, firewall rules — happens in the kernel, attached to a network interface like eth0 or wlan0. An interface is the kernel’s handle for “a thing I can send and receive data through”. For a real card, a driver connects the interface to the hardware.
Here’s the key part: since an interface is just a kernel object, the kernel can create one with no hardware behind it at all. That’s a virtual interface. To the rest of the kernel it looks like a real network card — it even gets a MAC address, which the kernel makes up — but there’s no wire, so the kernel itself decides where data sent into it goes. Docker’s whole network is built out of these.
Follow along
Reading about this only got me so far. What made it click was building it myself. So as we go through each idea, we’ll build that piece by hand and watch it work — or fail — in a terminal.
We’ll make two pretend containers, alice and bob, using only plain Linux tools. First we’ll get them talking to each other, then out to the internet.
All you need is one container to work in. netshoot is an image that comes with every networking tool we’ll use:
docker run -it --privileged --name netlab nicolaka/netshoot
--privileged lets us create namespaces and change kernel settings inside the container. Every command from here on runs inside netlab, and netlab plays the role of “the host”.
Giving each container its own network
By default, every process on a Linux machine shares one network stack: the same interfaces, the same routing table, the same firewall rules and the same set of ports. If containers worked that way, they’d all share eth0 with the host, see each other’s traffic, and fight over ports. Two containers couldn’t both run a web server on port 80.
Linux solves this with a network namespace: a completely separate copy of the network stack, with its own interfaces, routes, firewall rules and ports. Every container gets one. Port 80 in one namespace has nothing to do with port 80 in another. The container is still an ordinary process on the host — it just sees a different network.
Think of the machine as an apartment building. The host’s network is the shared lobby, and each namespace is a private apartment.
ip netns add alice
ip netns add bob
This creates two new network namespaces, named alice and bob — two empty apartments. Nothing is running in them yet. Normally a namespace disappears when the last process using it exits, but ip netns add gives each one a name (a file under /run/netns/) that keeps it alive, so we can keep coming back to it.
To do something inside one, ip netns exec <name> <command> runs a command in that namespace — roughly what docker exec does. Let’s use it to look inside alice. ip link lists network interfaces, so running it inside alice shows every interface alice can see:
ip netns exec alice ip link
# 1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN ...
Just one interface: lo, the loopback interface. It’s how a namespace talks to itself (127.0.0.1), and it starts out switched off. Let’s turn it on in both:
ip netns exec alice ip link set lo up
ip netns exec bob ip link set lo up
So alice has her own private network now. No port clashes, no seeing anyone else’s traffic. Great — except she has nothing to talk through. No eth0, no way out of her apartment. She isn’t just isolated, she’s completely cut off. Not even the host can reach her.
Running a cable
To get alice out of her apartment, we need a cable. Linux has a virtual interface built for exactly this: a veth pair (virtual Ethernet). It’s two virtual interfaces joined back to back. Whatever goes into one end comes out of the other. Picture a network cable with a plug on each end.
The trick is that the two ends can live in different namespaces. Put one end in alice’s apartment and leave the other in the lobby, and you’ve run a cable through the wall.
ip link add veth-alice type veth peer name veth-alice-br
ip link add veth-bob type veth peer name veth-bob-br
Reading the first line left to right: ip link add veth-alice creates a new interface called veth-alice, type veth makes it one end of a veth pair, and peer name veth-alice-br names the other end. One command, one cable, two ends. The -br ends are the ones we’ll later plug into the bridge.
Both ends start out in the lobby, because that’s where we ran the command. ip link show type veth lists just the veth interfaces:
ip link show type veth
# veth-alice-br@veth-alice ...
# veth-alice@veth-alice-br ...
The name after @ is the other end of the cable: veth-alice-br@veth-alice means veth-alice-br is wired to veth-alice.
Now push one end of each cable through the wall, into its apartment:
ip link set veth-alice netns alice
ip link set veth-bob netns bob
alice now has veth-alice next to lo: her end of the cable. That’s all a container’s eth0 is. Even netlab’s own eth0 shows up as eth0@if22 — Docker ran the same kind of cable for it before we typed anything.
But look at what we’ve got: two cables, each with one end in an apartment and the other end lying loose in the lobby, plugged into nothing. We could connect alice straight to bob with a single cable. But a real host runs dozens of containers, and a cable between every pair of them gets out of hand fast. What we want is one central box that every cable plugs into.
A switch to plug the cables into
That box is a switch, and Linux has a virtual one called a bridge. Each cable plugs into one of its ports.
Data between devices moves in frames: chunks of bytes with a header holding the sender’s and receiver’s MAC addresses. The bridge’s job is to send each frame out of the right port. To do that, it keeps a table of which MAC address is behind which port, and it fills that table in by itself: whenever a frame comes in, it notes the sender’s MAC and the port it arrived on.
When a frame is addressed to a MAC that’s in the table, the bridge sends it out of that one port. When the MAC isn’t in the table yet, it sends a copy out of every port — called flooding — and the right device picks it up.
Docker’s bridge is docker0, and it lives in the lobby (the host namespace). Ours will be br0:
ip link add br0 type bridge
ip link set br0 up
Plug the lobby ends of both cables into it:
ip link set veth-alice-br master br0
ip link set veth-alice-br up
ip link set veth-bob-br master br0
ip link set veth-bob-br up
alice and bob are now wired to the same switch. One thing is still missing before they can talk: addresses. A cable and a switch can carry frames, but alice still doesn’t have an IP address, so there’s nothing to send to and nothing to send from.
ip netns exec alice ip addr add 10.0.0.1/24 dev veth-alice
ip netns exec alice ip link set veth-alice up
ip netns exec bob ip addr add 10.0.0.2/24 dev veth-bob
ip netns exec bob ip link set veth-bob up
For each of them, the first line gives their end of the cable an IP address, and the second switches it on.
The /24 at the end defines alice’s subnet: the group of addresses she treats as neighbours, reachable straight through her cable without going through anyone else.
An IP address is four numbers, and each number is 8 bits. /24 means the first 24 bits — the first three numbers, 10.0.0 — are the subnet’s name, shared by everyone in it. The last number tells devices apart. So alice’s subnet is 10.0.0.0/24: every address from 10.0.0.0 to 10.0.0.255. bob, at 10.0.0.2, is in it, so alice knows she can reach him directly. An address like 8.8.8.8 isn’t, and we’ll see what that means later.
To check the cable is live, run ip netns exec alice ip addr show and look at veth-alice. UP means alice’s end is switched on, and LOWER_UP means the other end is on too, so the cable works end to end.
Everything is wired and addressed. Time to see if alice can actually reach bob.
Finding each other
There’s one gap left, and it’s easy to miss. alice knows bob’s IP address. But the switch delivers frames by MAC address, and alice has no idea what bob’s MAC is.
The fix is ARP (Address Resolution Protocol). alice shouts to everyone plugged into the bridge: “who has 10.0.0.2?” The bridge floods that to every port. bob recognises his own IP and replies with his MAC. alice saves the answer so she doesn’t have to ask again.
Let’s watch it happen. In a second terminal, start capturing on the bridge before you ping, so you catch the very first exchange:
docker exec -it netlab tcpdump -i br0 -n
Then, back in the first terminal:
ip netns exec alice ping -c 3 10.0.0.2
The ping works, and tcpdump shows exactly how:
ARP, Request who-has 10.0.0.2 tell 10.0.0.1, length 28
ARP, Reply 10.0.0.2 is-at ca:0e:af:0a:e0:29, length 28
IP 10.0.0.1 > 10.0.0.2: ICMP echo request ...
IP 10.0.0.2 > 10.0.0.1: ICMP echo reply ...
sequenceDiagram
participant alice as alice (10.0.0.1)
participant br0 as br0 (bridge)
participant bob as bob (10.0.0.2)
alice->>br0: ARP broadcast - who has 10.0.0.2?
br0->>bob: floods to all ports
bob-->>alice: ARP reply - 10.0.0.2 is at ca:0e:af:0a:e0:29
Note over alice: saves bob's MAC
alice->>br0: ping, addressed to bob's MAC
br0->>bob: forwards out of bob's port
bob-->>alice: ping reply
alice has now saved bob’s MAC address in her ARP cache:
ip netns exec alice ip neigh
# 10.0.0.2 dev veth-alice lladdr ca:0e:af:0a:e0:29 REACHABLE
To watch the exchange again, clear the cache with ip netns exec alice ip neigh flush all and ping again.
That’s two containers talking through a switch — exactly what two containers on docker0 do. But a container that can only talk to its neighbours isn’t much use. Almost the first thing any real container does is reach out to the internet — to install a package, call an API, pull some data. So can alice get out?
Getting out to the internet
ip netns exec alice ping -c 3 8.8.8.8
# Network is unreachable
To see why, look at alice’s routing table — her list of rules for where to send traffic, by destination:
ip netns exec alice ip route
# 10.0.0.0/24 dev veth-alice proto kernel scope link src 10.0.0.1
One rule: “for my subnet, 10.0.0.0/24, use the cable”. 8.8.8.8 isn’t in her subnet, and she has no rule for anything else. What she needs is a default route — a catch-all saying “for everything else, hand it to this gateway”. A gateway is a device on your own subnet that forwards traffic on to other networks — usually a router. Here, that’s the host, sitting in the lobby.
But there’s a catch. So far, br0 has only been a switch: it passes frames between alice’s and bob’s cables, but the host itself isn’t on that network. It has no address in 10.0.0.0/24, so alice has no way to reach it, and nothing to point her default route at.
The fix is to give the bridge an IP address:
ip addr add 10.0.0.254/24 dev br0
This plugs the host into its own switch, as a third device next to alice and bob:
alice 10.0.0.1 ─┐
bob 10.0.0.2 ─┼─ br0
host 10.0.0.254 ─┘
10.0.0.254 is in alice’s subnet, so she can reach the host directly, just like she reaches bob. That’s all a gateway needs to be. Docker does exactly the same thing: docker0 has the address 172.17.0.1, and that’s every container’s gateway.
Now alice and bob can point their default routes at it:
ip netns exec alice ip route add default via 10.0.0.254
ip netns exec bob ip route add default via 10.0.0.254
default via 10.0.0.254 reads as: “for anything outside my subnet, send it to 10.0.0.254”.
Ping again, and it no longer says “unreachable”. Instead it just hangs, with 100% packet loss. That’s actually progress: alice now knows where to send the packet, and she sends it. Something further along is losing it. There are two problems, one after the other.
The host won’t pass the packet on
alice’s ping arrives at the host, but it isn’t for the host — it’s for 8.8.8.8. And by default, Linux drops any packet that isn’t addressed to itself. That’s a sensible default: an ordinary machine shouldn’t quietly relay other people’s traffic.
A gateway’s whole job is relaying traffic, though, so we have to switch that on. This is called IP forwarding, and it’s a kernel setting. Files under /proc/sys are the kernel’s live settings, and writing to one changes it:
echo 1 > /proc/sys/net/ipv4/ip_forward
Now the host will pass alice’s ping on toward 8.8.8.8. But that alone still isn’t enough.
Nobody can reply to alice
Look at the sender address on alice’s ping: 10.0.0.1. That’s a private address. Ranges like 10.x.x.x, 172.16–31.x.x and 192.168.x.x are reserved for use inside private networks, and routers on the internet won’t deliver traffic to them. So even if the ping reaches 8.8.8.8, its reply is addressed to 10.0.0.1, and there’s no way for it to come back.
The fix is for the host to swap the sender address for its own on the way out, and swap it back when the reply comes in. Then the internet only ever sees the host’s address, which it can reply to. This is NAT (network address translation). It’s the building’s front desk: outgoing mail gets the building’s address stamped on it, and when replies arrive, the front desk hands each one to the right apartment.
In Linux, NAT is done with iptables, the kernel’s rules for filtering and rewriting packets.
Every packet that moves through the kernel passes a fixed set of checkpoints, and iptables lets you attach rules to each one. When a packet reaches a checkpoint, the rules there run, and they can let it through, drop it or rewrite it. alice’s ping passes three checkpoints in the host:
arrives on br0 → PREROUTING → "not for me" → FORWARD → POSTROUTING → leaves via eth0
- PREROUTING — the moment it arrives, before the kernel has decided where it goes
- FORWARD — the kernel has decided it’s passing through, not for the host
- POSTROUTING — the last stop, just before it leaves
(A packet addressed to the host itself would go to INPUT instead of FORWARD, and one created by a program on the host starts at OUTPUT. alice’s ping touches neither.)
Our rule goes on POSTROUTING. By then the kernel knows the packet is leaving through eth0, so it knows which address to stamp on it:
iptables -t nat -A POSTROUTING -s 10.0.0.0/24 ! -o br0 -j MASQUERADE
Piece by piece:
-t nat -A POSTROUTING— add a rule to the NAT rules at the POSTROUTING checkpoint-s 10.0.0.0/24— only for packets sent from alice and bob’s subnet! -o br0— only if they’re leaving through an interface other thanbr0, i.e. heading out, not to a neighbour-j MASQUERADE— replace the sender address with the address of the interface the packet leaves through
That covers the way out. For the way back, the kernel’s connection tracking (conntrack) keeps a note of every rewrite. When the reply from 8.8.8.8 arrives, conntrack recognises it, swaps 10.0.0.1 back in, and the packet is delivered to alice.
Try it once more:
ip netns exec alice ping -c 3 8.8.8.8
# 3 packets transmitted, 3 received, 0% packet loss
The full journey:
sequenceDiagram
participant alice as alice (10.0.0.1)
participant gw as br0 (gateway)
participant nat as POSTROUTING (front desk)
participant inet as 8.8.8.8
alice->>gw: ping 8.8.8.8, from 10.0.0.1
gw->>nat: forwarded toward eth0
nat->>inet: sender rewritten to 172.17.0.4 (netlab's eth0)
Note over nat: conntrack notes 10.0.0.1
inet-->>nat: reply to 172.17.0.4
nat-->>alice: swapped back, delivered to 10.0.0.1
You can see the rule doing its job — its packet and byte counters are no longer zero:
iptables -t nat -L POSTROUTING -n -v
One last detail: 172.17.0.4 is also a private address. netlab is itself a Docker container, so Docker’s own MASQUERADE rule rewrites the packet a second time on its way out. Same trick, stacked twice.
So what does docker run do?
Exactly what we just did, with different names:
graph LR
subgraph c_ns["container namespace"]
c_if["eth0 / 172.17.0.2"]
end
subgraph host_ns["host namespace - lobby"]
br0["docker0 / 172.17.0.1"]
end
subgraph inet["outside"]
world["internet"]
end
c_if <-->|veth pair| br0
br0 <-->|NAT| world
| What we did by hand | What Docker does |
|---|---|
ip netns add alice |
creates the container’s network namespace |
| veth pair, one end moved into alice | creates a veth pair and renames the inside end eth0 |
br0 |
docker0 |
ip addr add 10.0.0.1/24 |
gives the container an IP from 172.17.0.0/16 |
ip addr add 10.0.0.254/24 dev br0 |
docker0 has 172.17.0.1 |
ip route add default via 10.0.0.254 |
sets the container’s default route to 172.17.0.1 |
ip_forward + MASQUERADE |
set up once, when the Docker daemon starts |
| — | writes the container’s /etc/resolv.conf for DNS |
Docker’s real NAT rule is the same shape as ours: -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE. It also installs more rules around it — FORWARD rules that let container traffic through, and rules that keep separate Docker networks apart.
Cleanup
Everything we built lives inside netlab, so removing the container removes it all:
docker rm -f netlab
Bonus: one struct, many behaviors
Remember that an interface is “just a kernel object”? Here’s what that object is. Every network interface in Linux — real card, bridge, veth — is a struct net_device. Heavily simplified:
struct net_device {
char name[IFNAMSIZ];
unsigned int mtu;
const unsigned char *dev_addr; // MAC address
const struct net_device_ops *netdev_ops; // function pointers
/* ...hundreds more fields */
};
netdev_ops holds the interface’s functions, like “send a frame”, and each kind of interface plugs in its own: a real card’s hands the frame to the hardware, while a veth’s hands it straight to the other end. Same struct, different behavior — polymorphism in C, using function pointers. The kernel does this all over the place.
Wrapping up
Each piece fixed something the previous one left broken. A namespace isolated the container, but cut it off. A veth pair connected it, but left loose cables in the lobby. A bridge joined those cables, and ARP let containers find each other’s MAC addresses. Finally, a default route, IP forwarding and NAT got them out to the internet. Together, that’s Docker’s default network.
You probably won’t need this depth in day-to-day work — Docker handles all of it for you. But I think it’s worth having the mental model. Once you can picture what’s underneath, the abstraction stops being magic.