Why Networking Is the Hardest Part
Ask any ops engineer what causes the most container outages, and the answer is almost always networking. Containers are designed for isolation — each one gets its own network namespace with its own interfaces, routing table, and iptables rules. That isolation is powerful for security but creates a fundamental tension: how do you connect things that are designed to be disconnected?
The networking challenges compound at scale:
- Single host — Multiple containers need to talk to each other and the outside world
- Multi-host — Containers on different machines need seamless communication
- Service discovery — Containers start and stop; IP addresses change constantly
- Load balancing — Traffic needs distribution across container replicas
- Security — Not every container should reach every other container
Docker provides five network drivers to address these scenarios, each with different trade-offs between isolation, performance, and complexity.
Docker Network Drivers Overview
Every Docker network is backed by a driver that determines how packets flow between containers, the host, and the external network. Understanding driver selection is the most impactful networking decision you'll make.
| Driver | Scope | Isolation | DNS | Performance | Use Case |
|---|---|---|---|---|---|
bridge |
Single host | Network namespace per container | User-defined only | Good (NAT overhead) | Default for standalone containers |
host |
Single host | None — shares host network | Host resolver | Best (no encapsulation) | Performance-critical apps, no port conflicts |
overlay |
Multi-host | VXLAN encapsulation | Built-in service discovery | Moderate (encapsulation cost) | Swarm services, cross-host communication |
macvlan |
Single host | MAC per container on physical network | None (external DNS) | Excellent (no NAT) | Legacy apps needing direct LAN presence |
none |
Single host | Complete (no networking) | None | N/A | Batch processing, security-sensitive workloads |
Decision Matrix: When to Use Each Driver
# List all networks on your Docker host
docker network ls
# Output:
# NETWORK ID NAME DRIVER SCOPE
# a1b2c3d4e5f6 bridge bridge local
# f6e5d4c3b2a1 host host local
# 0a1b2c3d4e5f none null local
docker run without specifying --network, the container joins the default bridge network. This is almost never what you want in production — always create and specify user-defined bridge networks.
Bridge Networks
The bridge driver is Docker's default and most commonly used network type. It creates a virtual Ethernet bridge (a software switch) on the host, and every container on that bridge gets its own IP address from a private subnet.
The Default docker0 Bridge
When Docker installs, it creates a bridge interface called docker0 with a default subnet (typically 172.17.0.0/16). Every container that doesn't specify a network connects here:
# Inspect the default bridge network
docker network inspect bridge
# Key output fields:
# "Subnet": "172.17.0.0/16"
# "Gateway": "172.17.0.1"
# See docker0 on the host
ip addr show docker0
# => docker0: mtu 1500
# => inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0
# Run a container and check its IP
docker run --rm alpine ip addr show eth0
# => eth0: inet 172.17.0.2/16 scope global eth0
Creating Custom Bridge Networks
# Create a user-defined bridge network
docker network create --driver bridge \
--subnet 192.168.100.0/24 \
--gateway 192.168.100.1 \
--ip-range 192.168.100.128/25 \
my-app-network
# Run containers on the custom network
docker run -d --name web --network my-app-network nginx:alpine
docker run -d --name api --network my-app-network node:alpine
# Containers can reach each other by NAME (DNS)
docker exec web ping -c 3 api
# => PING api (192.168.100.129): 64 bytes from 192.168.100.129
# Connect a running container to an additional network
docker network connect my-app-network existing-container
# Disconnect a container from a network
docker network disconnect bridge existing-container
flowchart TD
subgraph Host["Docker Host"]
subgraph Bridge["docker0 / user-defined bridge"]
direction LR
VETH1["veth pair"] --- C1["Container A
172.17.0.2"]
VETH2["veth pair"] --- C2["Container B
172.17.0.3"]
VETH3["veth pair"] --- C3["Container C
172.17.0.4"]
end
GW["Gateway 172.17.0.1"]
IPT["iptables NAT"]
end
EXT["External Network / Internet"]
Bridge --- GW
GW --- IPT
IPT --- EXT
User-Defined Bridge vs Default Bridge
This is one of Docker's most important networking distinctions. The default bridge network and user-defined bridge networks behave differently in critical ways:
| Feature | Default Bridge | User-Defined Bridge |
|---|---|---|
| DNS resolution | No — must use IP or --link (deprecated) |
Yes — automatic by container name |
| Isolation | All containers share one network | Separate network per logical group |
| Live connect/disconnect | Requires restart | Hot-plug without restart |
| Configuration | Docker-managed (limited) | Custom subnet, gateway, IP range |
| ICC (inter-container) | All containers can reach all others | Only containers on same network |
| Environment variables | --link shares env vars |
Use DNS — no env var leaking |
# Demonstrate DNS resolution difference
# Default bridge — no DNS
docker run -d --name db-default alpine sleep 3600
docker run --rm alpine ping -c 1 db-default
# => ping: bad address 'db-default' (FAILS)
# User-defined bridge — DNS works
docker network create mynet
docker run -d --name db-custom --network mynet alpine sleep 3600
docker run --rm --network mynet alpine ping -c 1 db-custom
# => PING db-custom (192.168.100.2): 56 data bytes (SUCCESS)
# Cleanup
docker rm -f db-default db-custom
docker network rm mynet
Host Networking
With --network host, Docker removes all network isolation. The container shares the host's network namespace directly — it sees the same interfaces, same IP addresses, and same ports as the host machine.
# Run nginx directly on host's port 80
docker run -d --network host --name web-host nginx:alpine
# No port mapping needed — nginx binds to host's port 80
curl http://localhost:80
# => Welcome to nginx!
# Container sees host's network interfaces
docker exec web-host ip addr
# => Shows eth0, wlan0, etc. — same as host
# Check — no docker-proxy process for port forwarding
ss -tlnp | grep 80
# => shows nginx directly bound, not docker-proxy
| Aspect | Bridge Network | Host Network |
|---|---|---|
| Port mapping | Required (-p) |
Not needed — direct bind |
| Performance | NAT overhead (~5% latency) | Native performance |
| Port conflicts | Isolated per container | Competes with host services |
| Security | Network namespace isolation | No isolation — full host access |
| Multiple replicas | Different host ports per replica | Cannot — port conflict |
Port Mapping Deep Dive
Port mapping is how containers on bridge networks expose services to the outside world. Docker uses iptables DNAT rules to forward host ports to container ports.
# Basic port mapping: hostPort:containerPort
docker run -d -p 8080:80 --name web nginx:alpine
# => Host:8080 forwards to Container:80
# Map to specific host IP (not all interfaces)
docker run -d -p 127.0.0.1:8080:80 --name web-local nginx:alpine
# => Only accessible from localhost, not external
# Map UDP port
docker run -d -p 5353:53/udp --name dns coredns/coredns
# Random host port (Docker picks available port)
docker run -d -P --name web-random nginx:alpine
docker port web-random
# => 80/tcp -> 0.0.0.0:32768
# Multiple port mappings
docker run -d \
-p 80:80 \
-p 443:443 \
-p 8443:8443 \
--name multi-port nginx:alpine
# Map a range of ports
docker run -d -p 7000-7010:7000-7010 --name port-range myapp
# Check which ports a container exposes
docker inspect --format='{{range $p, $conf := .NetworkSettings.Ports}}{{$p}} -> {{(index $conf 0).HostPort}}{{"\n"}}{{end}}' web
How Port Mapping Works: iptables DNAT
When you publish a port with -p 8080:80, Docker creates an iptables rule that rewrites the destination of incoming packets. A packet arriving at host:8080 gets its destination changed to the container's IP:80 via DNAT (Destination NAT). Additionally, Docker starts a docker-proxy process for hairpin NAT scenarios where a container connects to its own published port via the host IP.
# See the iptables rules Docker creates
sudo iptables -t nat -L DOCKER -n -v
# => DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:80
# docker-proxy process
ps aux | grep docker-proxy
# => /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 8080
# -container-ip 172.17.0.2 -container-port 80
Container-to-Container Communication
Within the same user-defined bridge network, containers can freely communicate using container names as hostnames. Docker runs an embedded DNS server at 127.0.0.11 that resolves these names.
# Create a network and deploy a multi-container app
docker network create app-net
# Database
docker run -d --name postgres --network app-net \
-e POSTGRES_PASSWORD=secret \
postgres:16-alpine
# Application connects to database by name
docker run -d --name api --network app-net \
-e DATABASE_URL="postgresql://postgres:secret@postgres:5432/mydb" \
my-api-image
# Frontend connects to API by name
docker run -d --name frontend --network app-net \
-p 3000:3000 \
-e API_URL="http://api:8080" \
my-frontend-image
# Verify connectivity
docker exec api ping -c 2 postgres
# => PING postgres (192.168.100.2): 56 data bytes — success
# DNS resolution from inside a container
docker exec api nslookup postgres
# => Name: postgres
# => Address: 192.168.100.2
Cross-Network Isolation
# Containers on different networks CANNOT communicate
docker network create frontend-net
docker network create backend-net
docker run -d --name web --network frontend-net alpine sleep 3600
docker run -d --name db --network backend-net alpine sleep 3600
# This fails — networks are isolated
docker exec web ping -c 1 db
# => ping: bad address 'db'
# Connect the API to BOTH networks for controlled access
docker run -d --name api --network frontend-net alpine sleep 3600
docker network connect backend-net api
# Now api can reach both web and db
docker exec api ping -c 1 web # => Success
docker exec api ping -c 1 db # => Success
# But web still cannot reach db directly
docker exec web ping -c 1 db # => Fails
flowchart LR
subgraph FN["frontend-net"]
WEB["web"]
API_F["api (eth0)"]
end
subgraph BN["backend-net"]
API_B["api (eth1)"]
DB["db"]
end
WEB <-->|"can communicate"| API_F
API_B <-->|"can communicate"| DB
API_F -.-|"same container"| API_B
WEB x--x|"blocked"| DB
Overlay Networks
Overlay networks solve the multi-host problem: how do containers on different physical machines communicate as if they're on the same LAN? The answer is VXLAN (Virtual eXtensible LAN) tunneling — wrapping container packets inside UDP packets that traverse the physical network.
# Initialize Docker Swarm (required for overlay networks)
docker swarm init --advertise-addr 192.168.1.10
# Create an overlay network
docker network create --driver overlay \
--subnet 10.0.9.0/24 \
--attachable \
my-overlay
# --attachable allows standalone containers (not just services)
# Deploy a service across multiple nodes
docker service create --name web \
--network my-overlay \
--replicas 3 \
--publish published=80,target=80 \
nginx:alpine
# Containers on different hosts can communicate by service name
# Host A: container on 10.0.9.3
# Host B: container on 10.0.9.4
# They communicate as if on same LAN
flowchart TB
subgraph H1["Host 1 (192.168.1.10)"]
C1["Container A
10.0.9.3"]
VTEP1["VXLAN Tunnel Endpoint"]
end
subgraph H2["Host 2 (192.168.1.11)"]
C2["Container B
10.0.9.4"]
VTEP2["VXLAN Tunnel Endpoint"]
end
PHYS["Physical Network
UDP:4789 encapsulation"]
C1 --> VTEP1
VTEP1 -->|"VXLAN encap"| PHYS
PHYS -->|"VXLAN decap"| VTEP2
VTEP2 --> C2
Macvlan Networks
Macvlan assigns a unique MAC address to each container and places them directly on the physical network. Containers appear as physical devices on the LAN — they get IPs from the same DHCP server as bare-metal machines.
# Create a macvlan network on the host's physical interface
docker network create -d macvlan \
--subnet=192.168.1.0/24 \
--gateway=192.168.1.1 \
-o parent=eth0 \
my-macvlan
# Run a container with a specific IP on the LAN
docker run -d --network my-macvlan \
--ip 192.168.1.200 \
--name legacy-app \
my-legacy-image
# The container is reachable from any device on the LAN
# External machine:
ping 192.168.1.200
# => 64 bytes from 192.168.1.200 — reachable directly
# Check the container's MAC address
docker exec legacy-app ip link show eth0
# => link/ether 02:42:c0:a8:01:c8 (unique MAC on physical network)
| Macvlan Mode | Description | Use Case |
|---|---|---|
bridge (default) |
Containers can communicate with each other and external network | Most common — direct LAN presence |
802.1q trunk |
Sub-interfaces with VLAN tags | Multi-tenant environments with VLAN segmentation |
passthru |
Entire NIC dedicated to single container | Maximum performance, single container per NIC |
Network Inspection & Debugging
When containers can't communicate, you need systematic debugging tools. Docker provides built-in inspection commands, and Linux utilities give you low-level visibility.
# List all networks
docker network ls
# => NETWORK ID NAME DRIVER SCOPE
# Inspect a network — shows containers, subnet, gateway
docker network inspect my-app-network
# Find which networks a container belongs to
docker inspect --format='{{json .NetworkSettings.Networks}}' my-container | jq
# Get container's IP address
docker inspect --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' web
# Test connectivity between containers
docker exec web ping -c 3 api
docker exec web wget -qO- http://api:8080/health
# DNS resolution debugging
docker exec web nslookup api
docker exec web cat /etc/resolv.conf
# => nameserver 127.0.0.11 (Docker's embedded DNS)
# Check exposed and published ports
docker port web
# => 80/tcp -> 0.0.0.0:8080
# Network-level statistics
docker stats --format "table {{.Name}}\t{{.NetIO}}" --no-stream
# Trace packet flow with tcpdump (from host)
docker run --rm --net=container:web nicolaka/netshoot tcpdump -i eth0 -n port 80
The nicolaka/netshoot Container
When your minimal containers (Alpine, distroless) lack debugging tools, use netshoot — a purpose-built networking troubleshooting container with curl, dig, nslookup, tcpdump, nmap, iperf3, and more. Attach it to any container's network namespace:
# Attach to a container's network namespace for debugging
docker run -it --rm --net=container:problematic-container \
nicolaka/netshoot bash
# Now you have full networking tools in that container's network
curl http://localhost:8080/health
dig api.my-network
ss -tlnp
traceroute external-service.com
Exercises
Multi-Container Application Network
Create a user-defined bridge network. Deploy PostgreSQL, a Node.js API that connects to Postgres by name, and an Nginx reverse proxy that forwards to the API by name. Verify the full chain works without using any IP addresses — only container names.
Network Isolation Test
Create three networks: frontend, backend, and monitoring. Deploy containers on each and verify that containers on frontend cannot reach backend containers directly. Then create a "gateway" container connected to both and verify it can reach containers on both networks.
Host vs Bridge Benchmark
Run iperf3 in server mode on a container with bridge networking, then host networking. Compare the bandwidth and latency results. Use docker run --rm -it --network host nicolaka/netshoot iperf3 -c localhost for host mode. Measure at least 10 runs and calculate the average difference.
Port Mapping Exploration
Run 5 Nginx containers with -P (random ports). Use docker port to discover assigned ports. Then use sudo iptables -t nat -L DOCKER -n to see the iptables rules Docker created. Map the relationship between each rule and each container.
Conclusion & Next Steps
Docker networking is fundamentally about controlling connectivity — who can talk to whom, how packets flow, and where services are discovered. The key takeaways:
- Always use user-defined bridges — automatic DNS, isolation, live connect/disconnect
- Bridge for single-host apps — the right default for 90% of workloads
- Host for performance — when NAT overhead is unacceptable, accept the security trade-off
- Overlay for multi-host — VXLAN tunneling makes containers on different hosts appear local
- Macvlan for legacy — when applications must appear directly on the physical network
- Port mapping = iptables — understanding DNAT helps debug connectivity issues
With these fundamentals solid, you're ready to go deeper into the Linux primitives that make all of this work — veth pairs, iptables rules, network namespaces, and the DNS server that ties it all together.
Next in the Series
In Part 11: Advanced Networking & Service Discovery, we dive beneath Docker's abstractions into the Linux networking primitives — veth pairs, bridges, iptables chains, NAT traversal, and the embedded DNS server — giving you the debugging superpowers to diagnose any networking issue.