Container Networking Internals: veth, Bridges, NAT, and Embedded DNS
What happens when a container sends a packet: network namespaces, veth pairs, bridges, NAT for egress and published ports, why published ports bypass firewalls like ufw, Docker's embedded DNS, inter-container isolation, and debugging with nsenter and tcpdump.
docker run -p 8080:80 feels like magic until a port is reachable when it shouldn't be, a container can reach another tenant's database, or DNS resolution fails in one network but not another. Underneath are a handful of plain Linux primitives. This covers Docker's default bridge driver on Linux; Kubernetes CNI plugins use the same building blocks in different arrangements.
Network namespaces
A network namespace is an isolated copy of the network stack: its own interfaces, routing table, ARP table, firewall rules, and port space. Each container gets one. That's why two containers can both listen on port 80 — they're in different namespaces.
# find a container's PID, then run host tools inside its network namespace
PID=$(docker inspect -f '{{.State.Pid}}' web)
sudo nsenter -t "$PID" -n ip addr
sudo nsenter -t "$PID" -n ss -ltnp
nsenter -n is the most useful debugging tool here: host binaries, container network view, no need for tools inside a minimal image.
veth pairs and the bridge
A namespace with only a loopback interface can't talk to anything. Docker connects it with a veth pair — a virtual cable with two ends:
- One end (
eth0) inside the container's namespace. - The other end (
vethXXXX) in the host namespace, attached to a bridge.
A bridge (docker0 for the default network, br-<id> for each user-defined network) is a virtual layer-2 switch. Containers on the same bridge reach each other directly by IP; the bridge also has an IP (e.g. 172.17.0.1) and acts as the containers' default gateway.
[container A eth0]──veth──┐
├── br-1a2b (172.18.0.1) ── host routing / NAT ── eth0 (host)
[container B eth0]──veth──┘
ip link show master br-1a2b # veth ends attached to that bridge
bridge link
Egress: masquerading
Container IPs are private. For outbound traffic, the host performs source NAT — a MASQUERADE rule rewrites the container's source address to the host's, so replies come back to the host and get translated back. It requires net.ipv4.ip_forward=1. Docker manages these rules in iptables (or, depending on version and configuration, via nftables).
sudo iptables -t nat -S POSTROUTING | grep -i masq
Ingress: published ports and DNAT
-p 8080:80 creates a destination NAT rule: packets arriving at the host on port 8080 are rewritten to container-ip:80 and forwarded. Docker may also run a userland proxy (docker-proxy) for some cases, such as connections from the host to its own published port.
The firewall surprise
Published-port traffic is DNAT'd in the nat table before it reaches the host's INPUT chain, and then traverses FORWARD. Host firewalls like ufw and firewalld's default zones mostly manage INPUT. Result: ufw deny 8080 doesn't stop access to a published container port. This is a long-standing, well-documented source of accidentally exposed databases.
Fixes:
- Bind published ports to an address:
-p 127.0.0.1:5432:5432for anything that should be local-only. The single most effective habit. (What Is an IP Address and a Port?.) - Don't publish internal services at all — let them talk over a user-defined network.
- Put filtering rules in the
DOCKER-USERchain, which Docker evaluates before its own forwarding rules and leaves alone. - Use an upstream firewall (cloud security groups) as an outer layer.
User-defined networks and embedded DNS
On the default bridge network, containers find each other only by IP. On user-defined networks, Docker runs an embedded DNS server reachable inside each container at 127.0.0.11: container names and service aliases resolve to their IPs on that network, and other queries are forwarded to the host's configured resolvers.
That's how db:5432 works in Compose. (Docker Compose for Local Development.) DNS problems to know:
- Containers on the default bridge don't get name resolution for each other.
- Host resolvers like
127.0.0.53(systemd-resolved) aren't reachable from inside a container's namespace; Docker detects common cases and substitutes upstream servers, but custom setups can break resolution. - Alpine/musl resolver behaviour differs from glibc (search domains,
ndots) and occasionally surprises.
Isolation between networks — and within one
- Containers on different user-defined bridges can't reach each other by default; Docker inserts isolation rules between bridges.
- Containers on the same bridge can reach each other on any port, not just published ones. Publishing controls exposure to the outside, not between neighbours.
--internalnetworks have no external route — useful for databases and backends that should never originate outbound connections.- Inter-container communication on a bridge can be disabled (
enable_icc=falsefor the network), forcing explicit links through other means.
For multi-tenant hosts, give each tenant its own network, never share a bridge between tenants, and treat "on the same network" as "can talk freely." Combine with egress controls if the workload is untrusted — a container on a bridge with default masquerading can reach anything the host can, including cloud metadata endpoints (169.254.169.254) unless blocked. (Agent Egress Control.)
Host networking and other modes
--network host: no namespace separation — the container shares the host stack. Fast, and no isolation. Avoid for untrusted workloads.--network none: loopback only. Ideal for sandboxed computation that needs no network.- macvlan/ipvlan: containers get addresses on the physical network — useful for appliances, rarely for apps.
- Rootless runtimes use user-space networking (slirp4netns, pasta), with different performance and source-IP behaviour. (Rootless Containers and User Namespaces.)
Debugging toolkit
docker network inspect <net> # subnets, containers, options
sudo nsenter -t $PID -n ip route # container's routes
sudo nsenter -t $PID -n cat /etc/resolv.conf # its DNS config
sudo nsenter -t $PID -n tcpdump -ni eth0 port 53 # watch DNS from inside
sudo tcpdump -ni br-1a2b # watch the bridge
sudo iptables -t nat -L -n -v # NAT rules with hit counters
sudo iptables -L DOCKER-USER -n -v
conntrack -L | grep 8080 # NAT'd connections
A MTU mismatch (common with VPNs, overlays, and some clouds) shows up as small requests working and large responses hanging — check ip link MTUs along the path.
Checklist for a shared host
- Internal services not published; local-only ports bound to
127.0.0.1 - Filtering in
DOCKER-USERand/or an upstream firewall — not ufw alone - One user-defined network per tenant/app;
--internalfor backends - Metadata endpoints and internal ranges blocked for untrusted egress
- No
--network hostfor untrusted workloads
EasySpawn gives each server its own isolated virtual machine and network, and publishes only your app on your domain with automatic SSL — no bridge, NAT, or firewall rules for you to manage. See how it works or join the waitlist.
Related: Reverse Proxies Explained · Containers vs Virtual Machines · How Container CPU and Memory Limits Actually Work
Keep reading
How Container CPU and Memory Limits Actually Work
docker run --cpus 2 --memory 4g looks simple. Underneath, it's cgroup v2 files with behaviour that surprises people: CPU limits that throttle rather than slow, memory limits that count page cache, and tools inside the container that report the host's resources. How to read the real numbers.
Docker vs Linux Users for Multi-Tenant Workspace Isolation
Separate Linux users look like a cheap way to isolate tenants until you try to enforce a CPU limit. A walkthrough of why containers win for multi-tenant development workspaces — and how to verify the limits are real.