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 workspace its own isolated Docker environment behind a managed Traefik proxy — only the routes you publish on your domain are exposed, and databases stay private to the project. 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
Rootless Containers and User Namespaces: What They Actually Protect
Root in a container is root on the host unless something remaps it. How user namespaces work, subuid/subgid ranges, Docker userns-remap vs rootless mode vs Podman, Kubernetes hostUsers: false, the file-ownership and networking costs, and where rootless fits.
Containers vs Virtual Machines: The Difference, Simply Explained
Both let one physical computer act like many. A virtual machine pretends to be a whole computer; a container is an isolated group of processes sharing one operating system. How each works, the trade-offs in speed, size, and isolation, and when to use which.