Skip to main content

Container Networking: Docker Bridges, Ports and Kubernetes

Build a Docker-style bridge network by hand, then see what Docker and Kubernetes do: container DNS, published ports, Services and NetworkPolicies.

Containers do not have a special kind of network. A container is a process with its own network namespace, connected to the host by a veth pair, plugged into a bridge, and given access to the outside world with NAT. That is exactly what you have been doing with the lab since Part 1. Docker and Kubernetes automate it and add two things on top: DNS names for containers, and load balancing for groups of them.

In this part you first build a small container network by hand on one of the lab's servers, so that you know what Docker's commands do underneath. Then you look at the real thing: Docker's bridge networks, embedded DNS and published ports, and a Kubernetes cluster's pod network, Services and NetworkPolicies.

Prerequisites

  • A fresh lab from Part 1: run sudo ./netlab.sh down && sudo ./netlab.sh up before you start, because every part changes the lab.
  • NAT from Part 2.
  • To repeat the Docker and Kubernetes sections: Docker Engine (see the Docker installation part) and kind for a local cluster. Their output in this part comes from the guide's companion repository, whose GitHub Actions workflow ran every command on a fresh Ubuntu 26.04 runner (Docker Engine and kind v0.33.0 with Kubernetes 1.37).

A Container Network by Hand

Pretend web1 is a Docker host. Docker would create a bridge called docker0, give each container a namespace with an address from a private range, and add NAT rules. Do the same with two "containers", ctr1 and ctr2. Run this script on your machine (not inside a lab host), as root:

#!/usr/bin/env bash
# containers.sh: what Docker does when it starts two containers on web1
set -euo pipefail

# the bridge: Docker's docker0, with the gateway address for the containers
ip -n web1 link add br0 type bridge
ip -n web1 addr add 172.18.0.1/16 dev br0
ip -n web1 link set br0 up
ip netns exec web1 sysctl -qw net.ipv4.ip_forward=1

# one namespace per container, a veth pair into the bridge, an address and a default route
n=2
for c in ctr1 ctr2; do
  ip netns add "$c"
  echo "$c" >> /var/lib/netlab/namespaces   # so that "netlab.sh down" removes it too
  ip -n "$c" link set lo up
  ip -n web1 link add "veth-$c" type veth peer name eth0 netns "$c"
  ip -n web1 link set "veth-$c" master br0 up
  ip -n "$c" addr add "172.18.0.$n/16" dev eth0
  ip -n "$c" link set eth0 up
  ip -n "$c" route add default via 172.18.0.1
  n=$((n + 1))
done

# NAT on web1: containers reach the outside as web1, and port 8081 is published to ctr1:80
ip netns exec web1 nft -f - <<'NFT'
table ip containers {
    chain prerouting {
        type nat hook prerouting priority dstnat; policy accept;
        tcp dport 8081 dnat to 172.18.0.2:80
    }
    chain postrouting {
        type nat hook postrouting priority srcnat; policy accept;
        ip saddr 172.18.0.0/16 oifname != "br0" masquerade
    }
}
NFT

# the "container" process: the lab app on port 80 in ctr1
ip netns exec ctr1 setsid python3 /etc/netlab/app.py http ctr1 80 >>/run/netlab/log/ctr1-app.log 2>&1 </dev/null &

Every Docker networking feature is in those few lines:

  • The bridge br0 is a virtual switch; containers on it reach each other directly by IP, like hosts on one subnet (Part 2).
  • Outbound access is source NAT: ctr1's packets leave web1 with web1's address, and then the lab router translates them again to 203.0.113.1, which is what remote logged. Two layers of NAT are normal for a container on a cloud VM.
  • Publishing a port (docker run -p 8081:80) is destination NAT: connections to port 8081 on the host go to the container's port 80.

Two consequences surprise people when they debug containers. First, a published port has no listening socket on the host when it is plain DNAT:

ss on web1 shows the app on 8080 and nothing on 8081: the kernel rewrites the packets before any socket sees them. (Docker also starts a small docker-proxy process for each published port, so on a Docker host ss -tlnp does show it. Kubernetes NodePorts usually show nothing.) To find out where a port goes, read the NAT rules.

Second, the published traffic goes through the host's forward path, not input. That is why host firewalls such as ufw, which filter input, do not protect published container ports (Part 4).

The Most Common Container Networking Bug

A program inside a container that listens on 127.0.0.1 cannot be reached from outside the container, published port or not. The container's loopback is its own, not the host's. Start a second app in ctr2 that listens on loopback only, publish it, and try:

The packet reached ctr2 and found nothing on 172.18.0.3:80, so the kernel answered with a reset. The fix is in the application, not the network: listen on 0.0.0.0 (all addresses) inside containers. Frameworks that default to localhost in development (Flask, Vite, Rails and others) need --host 0.0.0.0 in a container. The reverse case, a container that needs a service on the host's localhost, has its own fix.

Docker Networks

Docker creates three networks on install:

  • bridge is the default docker0 network. Containers on it get addresses from 172.17.0.0/16 but no DNS names for each other. They reach the host itself at the bridge's gateway, 172.17.0.1 by default (how to find the host IP from a container).
  • host puts the container in the host's network namespace: no isolation, no NAT, no port publishing needed. Useful for network tools and some high-throughput services.
  • none gives the container only a loopback interface.

Always create your own networks. A user-defined bridge gets its own subnet and an embedded DNS server at 127.0.0.11 that resolves container names:

The network got the next free range from Docker's default pools (172.18.0.0/16). The container's resolv.conf points at 127.0.0.11, Docker's resolver, which answers container names on the network and forwards everything else to the host's DNS servers. whoami prints what the container sees: its hostname (the container ID), its addresses and the request headers.

On the default bridge, the same lookup fails:

The old fix for that, --link, is a legacy feature; use a user-defined network instead.

Publishing Ports

-p publishes a container port on the host. Without an address, Docker publishes on every host address, which on a server with a public IP means the internet. Publish internal services on 127.0.0.1 and put a proxy in front:

The same pieces as in the hand-built network: a DNAT rule for the published port (Docker writes it with iptables, which on Ubuntu 26.04 is the nftables backend) and the docker-proxy process listening on the port. RemoteAddr in the whoami output is the bridge gateway, 172.17.0.1: the request came in through NAT on the host.

Internal Networks and Compose

A network created with --internal has no way out. Containers on it can talk to each other but not to the internet, which suits databases:

Both ways out are closed: Docker's DNS server does not resolve external names for an internal network, and there is no route for a direct address either.

Docker Compose creates a network per project, and you can split a stack into tiers the same way the lab splits its subnets. The proxy can reach the API, the API can reach the database, and the proxy cannot reach the database:

services:
  proxy:
    image: nginx:1.30-alpine
    ports:
      - '127.0.0.1:8081:80'
    networks: [front]
  api:
    image: traefik/whoami:v1.12.0
    networks: [front, back]
  db:
    image: postgres:18-alpine
    environment:
      POSTGRES_PASSWORD: example
    networks: [back]

networks:
  front: {}
  back:
    internal: true

Compose names networks after the project directory (shop_front, shop_back), and each service is reachable by its service name on the networks it joins. There is no version: line at the top: the Compose Specification ignores it, and current Compose warns that it is obsolete.

If Docker's default ranges clash with your VPC or VPN (Part 2 found exactly that), move them in /etc/docker/daemon.json before you create networks. Write the file there (merge it with any settings it already has), and check it before you restart Docker:

{
  "bip": "192.168.240.1/24",
  "default-address-pools": [{ "base": "192.168.248.0/21", "size": 24 }]
}

bip moves the default bridge, and default-address-pools gives new networks /24 blocks from 192.168.248.0/21 (eight networks). Restart Docker after changing it; existing networks keep their old ranges until you recreate them.

Kubernetes Networking

Kubernetes sets three rules, and the network plugin (CNI) implements them:

  1. Every pod gets its own IP address.
  2. Pods can reach every other pod by that address, on any node, without NAT.
  3. Agents on a node (such as the kubelet) can reach every pod on that node.

How the plugin does it differs: kind's kindnet and Flannel route a subnet per node, Calico routes with BGP or tunnels, Cilium uses eBPF, and the AWS VPC CNI hands out real VPC addresses (Part 7). A three-node kind cluster shows the result. Create it from this config (one control plane, two workers):

kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
  - role: control-plane
  - role: worker
  - role: worker
kind create cluster --name net --config kind.yaml --wait 180s

Then deploy three replicas of whoami with a Service in front of them:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  namespace: shop
spec:
  replicas: 3
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: whoami
          image: traefik/whoami:v1.12.0
          ports:
            - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: web
  namespace: shop
spec:
  selector:
    app: web
  ports:
    - port: 80
      targetPort: 80

The nodes are containers on a Docker network (172.18.0.0/16 on the runner), and each node gives its pods addresses from its own slice of 10.244.0.0/16. A pod on one node reaches a pod on another directly, because kindnet adds a route per node.

Services: a Stable Name and Address

Pods come and go, and their addresses change. A Service gives a group of pods (selected by labels) one stable virtual address, the ClusterIP, and a DNS name. kube-proxy on every node programs NAT rules that send connections for the ClusterIP to one of the pods, which makes a Service a layer 4 load balancer (Part 5). The current list of pod addresses lives in EndpointSlices:

  • The Service's ClusterIP comes from the service range (10.96.0.0/16 in kind), separate from the pod range. It is virtual: no interface has it, and you cannot ping it.
  • The EndpointSlice lists the three pod addresses that back the Service right now. (The older Endpoints API is deprecated since Kubernetes 1.33.)
  • The pod's resolv.conf points at the cluster DNS Service (CoreDNS) and has three cluster search domains (plus the node's own, here the runner's cloud domain) with ndots:5, so the short name web works inside the namespace. Part 3 explained what that costs for external names.
  • The requests spread over the pods, chosen by kube-proxy's rules. kind still runs kube-proxy in iptables mode by default; the nftables mode, generally available since Kubernetes 1.33, does the same with nftables and scales better with many Services.

Other Service types build on the ClusterIP: NodePort opens the same port on every node, LoadBalancer asks the cloud for a load balancer that points at those node ports, and a headless Service (clusterIP: None) skips the virtual address and returns the pod addresses straight from DNS. For HTTP traffic from outside the cluster, use the Gateway API (the successor of Ingress); the Kubernetes Services and networking part of the Kubernetes guide sets one up.

NetworkPolicy: a Firewall for Pods

By default every pod can reach every other pod. A NetworkPolicy selects pods by label and lists who may connect to them; once a pod is selected by any policy for a direction, everything not allowed is denied. The usual pattern is a default deny for the namespace plus one allow rule per real connection, the same idea as the router rules in Part 4:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-ingress
  namespace: shop
spec:
  podSelector: {}
  policyTypes:
    - Ingress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: web-from-client
  namespace: shop
spec:
  podSelector:
    matchLabels:
      app: web
  policyTypes:
    - Ingress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: client
      ports:
        - protocol: TCP
          port: 80

The client pod still gets an answer; the intruder pod, with a different label, times out. NetworkPolicies only work if the CNI enforces them: kindnet does since kind v0.24, Calico and Cilium do, and Flannel alone does not, in which case the policies are accepted and silently ignored. Test a deny rule once after you install a cluster. If you restrict egress too, remember to allow DNS (UDP and TCP port 53 to the cluster DNS pods), or every lookup fails.

Debugging Container Networks

Container images are small and usually have no ss, dig or tcpdump. Three ways around that:

  • Run host tools inside the container's network namespace with nsenter. You keep the host's tools and see the container's interfaces and sockets:
  • Start a debug container that shares the network namespace: docker run --rm -it --network container:web nicolaka/netshoot for Docker, and kubectl debug -it <pod> --image=nicolaka/netshoot --target=<container> for Kubernetes, which adds an ephemeral container to a running pod.
  • Capture on the host side: every container's traffic crosses its veth interface and the bridge on the host, so tcpdump -i docker0 (or the pod's veth or lxc interface on a Kubernetes node) sees it.

Overlay networks (VXLAN, Geneve, WireGuard) wrap each packet in another header, which lowers the usable MTU from 1500 to around 1450 or 1420 bytes. If small requests work and large responses hang, suspect the MTU; Part 8 shows how to find it with tracepath.

Clean Up

The Docker and Kubernetes examples keep running until you remove them. In the shop directory, and then anywhere:

docker compose down
docker rm -f api api-default web
docker network rm shop backend
kind delete cluster --name net

sudo ./netlab.sh down removes the lab, including ctr1 and ctr2, which containers.sh registered.

Common Mistakes

  • Listening on 127.0.0.1 inside a container. Listen on 0.0.0.0; the container's loopback is not the host's.
  • Using the default bridge. It has no container DNS. Create a network per application.
  • Publishing databases on all host addresses. -p 5432:5432 exposes the database to every network the host is on, and Docker's NAT rules skip ufw. Publish on 127.0.0.1, or not at all.
  • Overlapping address ranges between Docker, Kubernetes, the VPC and VPNs. Plan them (Part 2) and set default-address-pools.
  • Assuming NetworkPolicies work. Test a deny once; with a CNI that does not enforce them, they do nothing.
  • Hardcoding pod IPs. Use Service names; pod addresses change on every restart.

Summary

  • A container is a network namespace with a veth pair into a bridge; outbound traffic uses source NAT, published ports use destination NAT.
  • Docker's user-defined networks add DNS at 127.0.0.11; --internal networks have no way out; Compose builds tiers from networks.
  • Kubernetes gives every pod an address reachable from every other pod; Services add a stable virtual IP, DNS name and load balancing through kube-proxy, backed by EndpointSlices.
  • NetworkPolicies are pod firewalls selected by labels, enforced only by CNIs that support them.
  • Debug with nsenter, a netshoot container, or a capture on the host side of the veth.

Next, Part 7 maps the lab onto a cloud VPC.

Keep Learning

Updated:

Tags

NetworkingTCP/IPDNSSubnettingFirewallsLoad BalancingKubernetesDevOps

Found an issue?