Docker & ContainersVII · NetworkingPrimitives
Docker networking primitives — namespaces, veth, bridges
What you'll learn
- Trace a packet from the host NIC to a container process and back
- Identify every Linux networking construct Docker creates
- Inspect each construct from the host
Prerequisites
Verified against Docker Engine 29.x · Docker Engine 28.x · Docker Compose 2.x · containerd 2.x · runc 1.2.x · BuildKit 0.20+ · Linux kernel 5.15+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-09
A container is a Linux process with its own network namespace. The process sees its own interfaces, its own routing table, its own iptables, and its own sockets. None of these are real; they are all constructed by the kernel and the OCI runtime when the namespace is created.
This lesson walks through every primitive Docker creates when you
docker run -p 8080:80 nginx, with commands to inspect each one
from the host.
The Linux networking stack Docker uses
flowchart LR
App[Container app<br/>:80] --> VethC[veth inside container<br/>eth0]
VethC --> VethH[veth on host<br/>vethXXXX@if2]
VethH --> Br[Linux bridge<br/>docker0]
Br --> BridgeIP["bridge IP 172.17.0.1"]
Br --> NAT["iptables DNAT<br/>:8080 → :80"]
NAT --> HostNIC[Host NIC<br/>eth0]
HostNIC --> Internet((Internet))
Internet --> HostNIC
HostNIC --> NAT
NAT --> Br
Br --> VethH
VethH --> VethC
VethC --> App
Every element in this diagram is real, observable, and inspectable from the host.
The primitives
Network namespace
Each container gets its own network namespace. The namespace is created when the container is created; destroyed when the container is removed.
PID=$(docker inspect --format '{{.State.Pid}}' CONTAINER)
ls -l /proc/$PID/ns/net
# net -> net:[4026532631]
The namespace has its own:
- Interfaces (
ip link). - IP addresses (
ip addr). - Routing table (
ip route). - iptables rules.
- Sockets.
veth pair
A veth pair is a virtual ethernet cable. One end is inside the
container’s network namespace (named eth0 by default). The other
end is inside the host’s network namespace (named
veth<random>@if2).
# From the host, list veth interfaces
ip link show | grep veth
The two ends of the pair are linked: a packet sent into one end emerges from the other. This is how the container reaches the host’s network stack.
Linux bridge
A Linux bridge is a virtual switch. By default, Docker creates a
bridge named docker0 (or br-<id> for user-defined networks).
All containers on the default network connect to this bridge.
ip link show docker0
ip addr show docker0
# 172.17.0.1/16
The bridge has an IP address (172.17.0.1/16 by default). This is the host side of the bridge. Containers on the default network get addresses in this range.
When the container sends a packet, the packet travels: container eth0 → veth pair → bridge → host routing → outbound interface.
NAT (iptables / nftables)
Published ports use DNAT. When a packet arrives on the host
intended for host-ip:8080, iptables rewrites the destination to
container-ip:80 and forwards it to the bridge.
iptables -t nat -L -n -v | head -20
Look for the DOCKER chain:
Chain DOCKER (2 references)
pkts bytes target prot opt in out source destination
0 0 DNAT tcp -- !docker0 any anywhere anywhere tcp dpt:8080 to:172.17.0.2:80
This rewrites incoming port 8080 to the container’s IP on port 80.
Tracing a packet
When you curl http://localhost:8080 from the host, the packet
follows this path:
sequenceDiagram
participant Curl
participant Netfilter
participant Bridge
participant Veth
participant Nginx
Curl->>Netfilter: TCP SYN to 127.0.0.1:8080
Netfilter->>Netfilter: PREROUTING chain, DNAT to 172.17.0.2:80
Netfilter->>Bridge: Forward via bridge
Bridge->>Veth: Forward to vethXXXX
Veth->>Nginx: Deliver to container eth0
Nginx-->>Veth: TCP SYN-ACK
Veth-->>Bridge: Deliver back
Bridge-->>Netfilter: Forward
Netfilter-->>Curl: TCP SYN-ACK on 127.0.0.1:8080
When you curl http://localhost:8080 from a remote host:
- Packet arrives on host NIC.
- Netfilter PREROUTING DNATs to container IP:port.
- Bridge forwards to the container’s veth.
- Container processes.
Return traffic goes via conntrack, which reverses the NAT.
The userland proxy
docker-proxy is a small userspace process that listens on the
published host port and forwards to the container. It runs
alongside the iptables DNAT rules, not instead of them: the DNAT
handles traffic arriving from outside the host, and the proxy covers
the cases DNAT does not, notably loopback traffic on some
configurations and hairpin connections where a container reaches the
host’s published port for its own service.
--userland-proxy defaults to true. This is worth stating
plainly because it is often described the other way round: the
daemon ships with the proxy enabled, and "userland-proxy": false
in daemon.json is an opt-out you choose, not a default you
inherit.
The userland-proxy is:
- One process per published port, per protocol.
- Visible in
ps aux | grep docker-proxy, holding the host socket. - Slower than direct DNAT for the paths it handles, because each packet crosses into userspace and back.
- A real cost at scale: a host publishing several hundred ports carries several hundred of these processes and their memory.
Turning it off with "userland-proxy": false is a reasonable
production choice on Linux, and it is a behaviour change rather than
a pure optimisation — test loopback and container-to-published-port
access afterwards, because those are the paths that change.
Publishing a port goes around your firewall
iptables -t nat -S DOCKER | grep -v '^-N'
echo '--- host sockets holding published ports ---'
ss -tlnp | grep -E 'docker-proxy|LISTEN' | head -10-A DOCKER -i docker0 -j RETURN
-A DOCKER ! -i br-8e1f2a3b4c5d -p tcp -m tcp --dport 5432 -j DNAT --to-destination 172.19.0.4:5432
LISTEN 0 4096 0.0.0.0:5432 0.0.0.0:* users:(("docker-proxy",pid=3311,fd=4))
LISTEN 0 4096 127.0.0.1:5432 0.0.0.0:* users:(("docker-proxy",pid=3318,fd=4))Illustrative output
The two LISTEN lines are the whole story, and the difference
between them is the fix:
0.0.0.0:5432— published on every interface. Reachable from anywhere that can route to this host, regardless ofufw.127.0.0.1:5432— published on loopback only. Reachable from the host and from an SSH tunnel, and from nothing else, because the DNAT rule itself is scoped and there is no path for an external packet to match it.
Three mitigations, and the first one is the one you should reach for by default:
- Bind the publish to an address.
-p 127.0.0.1:5432:5432for anything that should not leave the host, or-p 192.0.2.10:5432:5432to pin it to one interface. This is a one-word change, it is enforced by the rule itself rather than by a separate firewall, and it cannot drift out of sync with a firewall config that lives somewhere else. - Do not publish at all. A database that is only reached by other containers needs no
-p. Put it on a user-defined network with its clients and let the embedded DNS find it. A port that is not published cannot be scanned. - **Filter in
DOCKER-USER.** Docker leaves this chain empty and jumps to it before its own rules, specifically so that operators have somewhere to put filtering that Docker will not overwrite when it rewrites its chains. Rules added to FORWARD directly are liable to be reordered underneath Docker; rules in DOCKER-USER are not.
PORT=5432
ss -tlnp "sport = :$PORT"
iptables -t nat -S DOCKER | grep -- "--dport $PORT"State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 4096 127.0.0.1:5432 0.0.0.0:* users:(("docker-proxy",pid=3318,fd=4))
-A DOCKER ! -i br-8e1f2a3b4c5d -p tcp -m tcp -d 127.0.0.1 --dport 5432 -j DNAT --to-destination 172.19.0.4:5432Illustrative output
127.0.0.1 in both lines is the pass condition. 0.0.0.0 in the
LISTEN line, or a DNAT rule with no -d restriction, means the
port is open to everything that can reach this host — and it will
keep being open after the next ufw reload.
Inspecting the actual stack on a running container
docker inspect CONTAINER --format '{{json .NetworkSettings.Networks}}' | jqPID=$(docker inspect --format '{{.State.Pid}}' CONTAINER)
IFINDEX=$(nsenter -t $PID -n ip link show eth0 | awk -F: '{print $1}' | head -1)
echo "Container-side ifindex: $IFINDEX"
ip -o link show | grep "^$IFINDEX:"
# Or, more directly: peer ifindex from inside the container
nsenter -t $PID -n cat /sys/class/net/eth0/iflinkiptables -t nat -L DOCKER -n -vconntrack -L | grep 172.17.0Knowledge check
Knowledge check · 5 questions
Q1. Where is the host-side end of the veth pair that connects to a container?
Q2. The Docker userland-proxy is required for published ports on Linux.
Q3. A host runs ufw with only 22 and 443 allowed. A container is started with `-p 5432:5432`. Is 5432 reachable from the internet?
Q4. Which of these actually stop a published database port being reachable from outside the host? Select all that apply.
Q5. Which Linux construct holds the host-side end of the veth pair?
Passing score: 75%. Answers are checked in this browser.