Skip to main content
RunBook Academy

Docker & ContainersVII · NetworkingPrimitives

Docker networking primitives — namespaces, veth, bridges

Advanced⏱ ~32 min

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

Not yet marked complete on this device.

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:

  1. Packet arrives on host NIC.
  2. Netfilter PREROUTING DNATs to container IP:port.
  3. Bridge forwards to the container’s veth.
  4. 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

Read-only / Safewhat is actually reachable from outside?
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 of ufw.
  • 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:

  1. Bind the publish to an address. -p 127.0.0.1:5432:5432 for anything that should not leave the host, or -p 192.0.2.10:5432:5432 to 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.
  2. 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.
  3. **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.
Read-only / Safeverify the binding is actually restricted
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:5432

Illustrative 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

Read-only / Safefind the bridge
docker inspect CONTAINER --format '{{json .NetworkSettings.Networks}}' | jq
Read-only / Safehost-side veth
PID=$(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/iflink
Read-only / Safeiptables DNAT rules
iptables -t nat -L DOCKER -n -v
Read-only / Safeconntrack entry
conntrack -L | grep 172.17.0

Knowledge check

Knowledge check · 5 questions

  1. Q1. Where is the host-side end of the veth pair that connects to a container?

  2. Q2. The Docker userland-proxy is required for published ports on Linux.

  3. 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?

  4. Q4. Which of these actually stop a published database port being reachable from outside the host? Select all that apply.

  5. Q5. Which Linux construct holds the host-side end of the veth pair?

Passing score: 75%. Answers are checked in this browser.