Skip to content
Menu

Networking9 min read

Docker Networking

Container networking from the kernel up

Docker Networking from First Principles

Docker networking is not magic — it is built entirely on Linux kernel primitives: network namespaces, virtual ethernet (veth) pairs, bridges, and iptables. Understanding these building blocks demystifies every Docker networking behavior.

The Three Kernel Primitives

  • Network Namespace — an isolated copy of the network stack (interfaces, routing table, iptables rules, sockets). Each container gets one.
  • veth pair — a virtual Ethernet cable with two ends. Packets sent into one end come out the other. Used to connect a container's namespace to the host.
  • Bridge (docker0) — a virtual L2 switch in the host namespace. All container veth endpoints attach here, enabling container-to-container communication.

Default Bridge Architecture

When Docker starts, it creates a Linux bridge interface called docker0 (typically 172.17.0.1/16). Each new container gets:

  • Its own network namespace (isolated network stack)
  • A veth pair: one end appears as eth0 inside the container, the other end attaches to docker0
  • An IP address from the bridge's subnet (e.g., 172.17.0.2, 172.17.0.3, ...)
  • A default route pointing to the bridge IP (172.17.0.1)

Topology: docker0 Bridge, veth Pairs, and Containers

Host Network Namespace
eth0 (physical)
192.168.1.100
|
iptables NAT (MASQUERADE)
|
docker0 (bridge)
172.17.0.1/16
|
vethABC123
|
|
vethDEF456
|
|
vethGHI789
|
network namespace boundary
Container A
eth0: 172.17.0.2
default gw: 172.17.0.1
Container B
eth0: 172.17.0.3
default gw: 172.17.0.1
Container C
eth0: 172.17.0.4
default gw: 172.17.0.1

Note

Note: Container-to-container traffic on the same bridge never leaves the host kernel. The bridge forwards frames at L2 — like a physical switch connecting VMs on the same hypervisor.

Outbound Traffic: Container to Internet

Containers sit behind NAT. When a container sends traffic to the internet, the packet traverses several stages in the host kernel:

  1. Container eth0

    Packet leaves container namespace via veth

  2. docker0 bridge

    Bridge receives frame, routes to host stack

  3. iptables MASQUERADE

    SNAT: rewrite src IP to host IP

  4. Host eth0

    Physical interface sends to gateway

  5. Internet

    Reply comes back, conntrack maps it

The MASQUERADE Rule

Docker inserts this iptables rule in the nat table's POSTROUTING chain:

console
console

    $ iptables -t nat -L POSTROUTING -n -v --line-numbers

    Chain POSTROUTING (policy ACCEPT)

num   target     prot opt source               destination

1     MASQUERADE  all  --  172.17.0.0/16        0.0.0.0/0  ! -o docker0
  

The rule in iptables syntax:

console
# Source NAT for container traffic leaving the host
-t nat -A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE

Breaking Down the Rule

  • -t nat — operate on the NAT table
  • -A POSTROUTING — append to POSTROUTING chain (after routing decision)
  • -s 172.17.0.0/16 — match packets from the Docker subnet
  • ! -o docker0 — only when the packet is NOT going out the docker0 interface (i.e., heading to the outside world, not to other containers)
  • -j MASQUERADE — SNAT the source IP to the outgoing interface's IP (like SNAT but dynamically selects the IP)

Tip

Tip: MASQUERADE vs SNAT — MASQUERADE dynamically discovers the outgoing IP (useful for DHCP). SNAT requires a static IP. Docker uses MASQUERADE because it doesn't know the host's external IP at rule creation time.

Inbound Traffic: Port Mapping (-p 8080:80)

Port mapping exposes a container service on a host port. Under the hood, Docker installs two iptables rules:

1. DNAT Rule (nat table, DOCKER chain)

console
# Redirect host port 8080 to container 172.17.0.2 port 80
-t nat -A DOCKER -p tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80

This rewrites the destination address. Traffic arriving at host:8080 gets rewritten to 172.17.0.2:80 before routing.

2. ACCEPT Rule (filter table, DOCKER chain)

console
# Allow forwarded traffic to reach the container
-A DOCKER -d 172.17.0.2/32 ! -i docker0 -o docker0 -p tcp --dport 80 -j ACCEPT

This allows the DNAT'd traffic through the FORWARD chain to reach the container.

  1. External client

    Connects to host:8080

  2. PREROUTING/DNAT

    Rewrite dst to 172.17.0.2:80

  3. FORWARD/ACCEPT

    Filter allows the packet

  4. docker0 bridge

    Routes to veth pair

  5. Container :80

    Receives the connection

Warning

Warning: Port mapping with -p 8080:80 binds to 0.0.0.0:8080 by default, meaning it's accessible from ALL interfaces — including the public internet. Use -p 127.0.0.1:8080:80 to bind only to localhost.

Network Drivers

Docker supports multiple networking modes via pluggable drivers. Each driver addresses a different use case and makes different trade-offs between isolation, performance, and complexity.

Driver Isolation Performance Multi-Host Use Case
bridge Per-network namespace Good (NAT overhead) No Default for standalone containers
host None (shares host namespace) Best (no NAT, no veth) No Performance-critical services, monitoring agents
none Complete (no networking) N/A No Security-sensitive batch jobs, offline processing
macvlan L2 isolation (own MAC) Excellent (no bridge) Same L2 segment Legacy apps needing LAN presence, DHCP clients
overlay Per-network namespace Moderate (VXLAN encap) Yes (Swarm) Multi-host container communication

Driver Details

bridge (default)

  • Creates an isolated Linux bridge
  • NAT for outbound traffic (MASQUERADE)
  • Port mapping (-p) for inbound
  • Containers on same bridge can communicate directly
  • Containers on different bridges are isolated
console
$ docker run --network bridge nginx

host

  • Container shares the host's network namespace
  • No isolation — container sees all host interfaces
  • No NAT, no port mapping needed — bind directly to host ports
  • Best performance (zero networking overhead)
  • Port conflicts with host and other --network host containers
console
$ docker run --network host nginx

none

  • Container gets only the loopback interface
  • No external network access whatsoever
  • Use case: security-sensitive offline processing, generating secrets
console
$ docker run --network none alpine ip addr
# Only shows lo (127.0.0.1)

macvlan

  • Container gets its own MAC address on the physical network
  • Appears as a real device on the LAN (visible to other machines, can get DHCP lease)
  • No bridge, no NAT — direct L2 access
  • Requires promiscuous mode on the parent interface
  • Cannot communicate with the host (by design — use macvlan sub-interface on host to fix)
console
$ docker network create -d macvlan \
  --subnet=192.168.1.0/24 \
  --gateway=192.168.1.1 \
  -o parent=eth0 my_macvlan

overlay (Docker Swarm)

  • VXLAN-based multi-host networking — containers on different Docker hosts can communicate as if on the same L2 network
  • Encapsulates L2 frames in UDP (port 4789) between hosts
  • Requires a key-value store for coordination (built into Swarm, or external etcd/consul)
  • Each overlay network has its own VXLAN Network Identifier (VNI)
  • Conceptually the precursor to Kubernetes overlay networking (Flannel VXLAN mode)
console
$ docker network create -d overlay --attachable my_overlay

Note

Note: ipvlan is similar to macvlan but shares the parent's MAC address. It avoids promiscuous mode requirements and works better in cloud environments where MAC filtering is enforced (e.g., AWS).

Docker's Embedded DNS Server

Docker runs an embedded DNS server at 127.0.0.11 inside each container on user-defined networks. This enables service discovery by container name.

Default Bridge
  • No automatic DNS resolution
  • Must use IP addresses or legacy --link flag
  • /etc/resolv.conf points to host's DNS
  • Cannot resolve container names
console
# This FAILS on default bridge:
$ docker exec app ping db
ping: db: Name or service not known
User-Defined Bridge
  • Automatic DNS resolution by container name
  • /etc/resolv.conf points to 127.0.0.11
  • Also resolves network aliases
  • DNS round-robin for multiple containers with same alias
console
# This WORKS on user-defined bridge:
$ docker exec app ping db
PING db (172.18.0.3): 56 data bytes
64 bytes from 172.18.0.3: seq=0 ...

How Embedded DNS Works

  • Docker injects nameserver 127.0.0.11 into each container's /etc/resolv.conf
  • The DNS server runs in the Docker daemon, not in the container
  • It intercepts DNS queries via iptables DNAT rules inside the container's namespace
  • Container name queries are resolved locally; all others are forwarded to the host's configured DNS
  • Supports round-robin load balancing when multiple containers share a network alias

User-Defined Bridges vs Default Bridge

Feature Default Bridge (docker0) User-Defined Bridge
DNS resolution No (IP only, or --link) Yes (automatic by container name)
Isolation All containers share one bridge Containers only see their own network
Connect/disconnect live No (must recreate container) Yes (docker network connect/disconnect)
Configurable subnet Limited (daemon.json only) Yes (at creation time)
Container linking Legacy --link (deprecated) Not needed — DNS handles it
Recommended No (legacy) Yes (always prefer this)
console
console

    $ docker network create my-app-net

    a1b2c3d4e5f6...


    $ docker run -d --name db --network my-app-net postgres

    $ docker run -d --name app --network my-app-net -e DB_HOST=db my-app


    # app can now resolve "db" to the postgres container's IP
  

Tip

Tip: A container can be attached to multiple user-defined networks simultaneously, letting it communicate with different groups of services while maintaining isolation between them. Use docker network connect second-net container-name.

Docker Compose Networking

Docker Compose automatically creates a dedicated bridge network for each project. This is one of its biggest ergonomic wins — zero networking configuration needed for most apps.

Default Behavior

  • Compose creates a network named <project-name>_default
  • All services in the docker-compose.yml join this network
  • Service names (the YAML keys) become DNS hostnames
  • Services can reach each other by name — no IP configuration needed
console
# docker-compose.yml
services:
  web:
    image: nginx
    ports:
      - "8080:80"
  api:
    image: my-api
    environment:
      - DB_HOST=db     # resolves via DNS
  db:
    image: postgres:16

# All three services can reach each other as "web", "api", "db"
# Compose creates network: myproject_default

Custom Network Configuration

console
services:
  web:
    image: nginx
    networks:
      - frontend
  api:
    image: my-api
    networks:
      - frontend
      - backend    # api bridges both networks
  db:
    image: postgres:16
    networks:
      - backend

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    ipam:
      config:
        - subnet: 172.28.0.0/16

# web can talk to api (frontend network)
# api can talk to db (backend network)
# web CANNOT talk to db (different networks, no route)
Compose Network Topology
frontend network
web
api
backend network
api
db
api is on both networks — web and db are isolated from each other

Debugging Docker Networking

Essential commands for inspecting and troubleshooting container networking:

console
console

    # List all Docker networks

    $ docker network ls

    NETWORK ID     NAME              DRIVER    SCOPE
a1b2c3d4e5f6   bridge            bridge    local
f6e5d4c3b2a1   host              host      local
1a2b3c4d5e6f   none              null      local
9f8e7d6c5b4a   myproject_default bridge    local



    # Inspect a network — shows subnet, gateway, connected containers

    $ docker network inspect bridge

    [{
    "Name": "bridge",
    "IPAM": { "Config": [{ "Subnet": "172.17.0.0/16", "Gateway": "172.17.0.1" }] },
    "Containers": {
        "abc123...": { "Name": "web", "IPv4Address": "172.17.0.2/16" }
    }
}]



    # Inspect networking from inside a container

    $ docker exec -it web ip addr

    1: lo: <LOOPBACK,UP> mtu 65536
    inet 127.0.0.1/8 scope host lo
2: eth0@if7: <BROADCAST,MULTICAST,UP> mtu 1500
    inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0



    # Check routing table inside a container

    $ docker exec -it web ip route

    default via 172.17.0.1 dev eth0
172.17.0.0/16 dev eth0 scope link src 172.17.0.2



    # Check DNS resolution

    $ docker exec -it web cat /etc/resolv.conf

    nameserver 127.0.0.11
options ndots:0



    # View Docker's iptables rules on the host

    $ iptables -t nat -L -n -v

    $ iptables -L DOCKER -n -v



    # See veth pairs — match host-side veth to container

    $ ip link show type veth

    $ brctl show docker0  # (or: bridge link show)
  

Tip

Tip: To find which veth on the host corresponds to a container's eth0, run docker exec container cat /sys/class/net/eth0/iflink — the number matches the host-side veth's interface index from ip link.

Warning

Warning: docker network inspect shows the current state. If you suspect stale iptables rules or phantom interfaces, check with iptables -L -n -v and ip link directly on the host. Docker doesn't always clean up perfectly after crashes.

Solidnines — solidnines.com