Docker & Container Fundamentals

Container Networking

A team runs two containers on a host: an API and a Postgres. The API's config says DB_HOST=localhost. It cannot connect. They change it to the host's IP. Still cannot connect. They change it to the Postgres container's IP. It works — until the container restarts and gets a new IP. They Google "Docker container IP static" and go down a three-hour rabbit hole of IPAM settings and --ip flags. The actual fix is one line: put both containers on the same user-defined Docker network and refer to Postgres by its container name. Docker's built-in DNS does the rest, and the hostname is stable forever.

Container networking is one of the most misunderstood parts of Docker because the defaults are deliberately weird for compatibility. This lesson starts from the kernel primitives (network namespaces, veth pairs, bridges, iptables) and builds up through Docker's network modes, service-to-service communication patterns, and the DNS / port mapping quirks you will hit in real deployments.


What Docker Gives Each Container

Every container gets:

  • Its own network namespace (Linux course Module 5 Lesson 1), meaning its own lo, its own routing table, its own iptables rules, and — if configured — its own eth0.
  • A veth pair wiring the container's namespace to the host. One end is inside the container (showing up as eth0); the other is on the host (often attached to a bridge).
  • An IP address from Docker's IPAM for the network the container joined.
  • A resolv.conf pointing at Docker's embedded DNS resolver (when on a user-defined network) or the host's DNS.

That's it. Everything else — port publishing, service discovery, TLS termination — is built on top of those four primitives.


The Default Networks

Run docker network ls on a fresh install and you see three built-in networks:

docker network ls
# NETWORK ID     NAME      DRIVER    SCOPE
# abc123def456   bridge    bridge    local
# def456abc123   host      host      local
# 111222333444   none      null      local
NetworkDriverWhat it gives you
bridgebridgeA veth + bridge + NAT setup (the default if you do not specify)
hosthostNo isolation — container shares the host's network namespace
nonenullNo networking at all; only lo inside

The default bridge (old and weird)

docker run -d --name a nginx                 # joins default bridge
docker run -d --name b --link a alpine sleep 1000   # (deprecated) link to a

# But without --link, container b cannot resolve "a" by name
docker exec b ping -c 1 a
# ping: a: Name or service not known

The default bridge network does not provide service discovery between containers. This is a historical quirk. You have to use deprecated --link flags or raw IPs. This is why docker run without --network is rarely what you actually want for multi-container setups.

User-defined bridge networks (what you should actually use)

docker network create mynet
docker run -d --name db --network mynet postgres:16
docker run -d --name api --network mynet -e DATABASE_URL=postgres://db:5432 myapi

# Container-to-container resolution by name just works
docker exec api ping -c 1 db
# PING db (172.20.0.2) 56(84) bytes of data.
# 64 bytes from db.mynet (172.20.0.2) icmp_seq=1 ttl=64 time=0.123 ms

User-defined bridges give you:

  • Automatic DNS resolution between containers (Docker's embedded resolver).
  • Network isolation — containers on different user-defined networks cannot talk to each other unless explicitly attached to both.
  • Better control — you choose the subnet, gateway, MTU.
KEY CONCEPT

Always create a user-defined network for related containers. docker network create myapp-net, then attach every service that needs to talk to another in the app. You get DNS-based service discovery for free, the network is isolated from unrelated containers, and the hostnames are stable across restarts. This single habit eliminates a huge class of networking confusion.

host network: zero isolation

docker run --rm --network=host nginx
# Listens on port 80 of the HOST — no NAT, no veth, no namespace.

The container uses the host's network namespace directly. Its eth0 IS the host's interface. Any port it binds is bound on the host. Any connection it initiates has the host's source IP.

Use cases:

  • Maximum performance — no NAT, no iptables overhead. Useful for very high packet rates.
  • Network tools — containers running tcpdump, netstat, nmap against the host's interfaces.
  • Legacy apps with dozens of dynamic ports — when -p 8080-9080 would be clumsy.

Trade-off: no port isolation. If the container binds port 5432, that IS the host's port 5432 — you cannot run two of them, and you cannot run a host service on the same port.

none: for sandboxing

docker run --rm --network=none alpine ip link
# 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noop state UNKNOWN qlen 1000
#     link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

Only loopback. The container cannot reach anything outside itself. Useful for sandboxing untrusted code that does computation but should not make network calls.


Under the Hood: How bridge Works

When you docker run -d nginx on a fresh Docker install:

  1. Docker sees no container is on a network yet; it creates (or reuses) the docker0 bridge interface on the host (ip link show docker0).
  2. It creates a veth pair: one end (e.g., veth1234abc@if5) attached to docker0, the other inside the container's new network namespace and renamed to eth0.
  3. Docker assigns an IP from 172.17.0.0/16 to the container's eth0 and sets the container's default route to 172.17.0.1 (the bridge's IP).
  4. It inserts iptables rules: SNAT (MASQUERADE) for outbound traffic, FORWARD allow rules for container-to-container and container-to-external.
  5. If -p host-port:container-port was set, it inserts a DNAT rule to redirect inbound host-port traffic to the container's IP.

You can see all of this:

# The bridge
ip -br addr show docker0
# docker0  UP   172.17.0.1/16

# Show the veth endpoints attached to docker0
bridge link show
# 5: veth1234abc@if4: ... master docker0 state forwarding

# NAT table (Docker's rules are in DOCKER chain)
sudo iptables -t nat -L DOCKER -n -v
# Chain DOCKER (2 references)
#  pkts bytes target     prot opt in     out     source      destination
#     0     0 RETURN     all  --  docker0 *       0.0.0.0/0   0.0.0.0/0
#    12   720 DNAT       tcp  --  !docker0 *      0.0.0.0/0   0.0.0.0/0    tcp dpt:8080 to:172.17.0.2:80

# SNAT for outbound
sudo iptables -t nat -L POSTROUTING -n -v
# Chain POSTROUTING (policy ACCEPT)
#  pkts bytes target     prot opt in     out     source         destination
#     2   120 MASQUERADE all  --  *       !docker0 172.17.0.0/16  0.0.0.0/0

Inside the container, just the namespace-scoped view:

docker exec nginx ip -br addr
# lo    UP       127.0.0.1/8
# eth0@if5 UP    172.17.0.2/16

docker exec nginx ip route
# default via 172.17.0.1 dev eth0
# 172.17.0.0/16 dev eth0 proto kernel scope link src 172.17.0.2

This is all kernel networking — Docker is just automating it.


Port Publishing Deep-Dive

docker run -d --name web -p 8080:80 nginx

What that does:

  1. docker0 gets a new veth into the container; container gets 172.17.0.X.
  2. Docker appends an iptables NAT rule: "destination 8080 on any non-docker0 interface → DNAT to 172.17.0.X:80".
  3. A small docker-proxy process is also started on the host listening on 8080, as a fallback for traffic that somehow bypasses iptables (rare; mostly for loopback connections).

From the outside: connect to host-IP:8080 → iptables rewrites dest to container IP:80 → arrives at nginx.

Forms of -p

-p 8080:80                 # publish to all host interfaces on port 8080
-p 127.0.0.1:8080:80       # only bind on loopback — not reachable externally
-p 192.168.1.5:8080:80     # only bind on a specific host IP
-p 8080:80/udp             # UDP
-p 8080:80/tcp             # explicit TCP
-p 8080-8090:8080-8090     # range
-p 80                      # container port only; host port is random (check with `docker port`)
-P                         # (uppercase) publish all EXPOSE'd ports to random host ports

The userland-proxy gotcha

When iptables is working, docker-proxy is redundant. Some environments (where iptables rules get flushed by other tools, or where kernel modules are missing) rely on it. You can disable it in daemon.json:

{
  "userland-proxy": false
}

Production fleets usually do this — better performance and fewer mysterious processes — but test first, because if your iptables setup has quirks, disabling the proxy fallback breaks connectivity.

WARNING

docker run -p 8080:80 publishes to 0.0.0.0 by default. Your application is exposed on every host interface, including the public internet if the host is a public cloud VM with a public IP. For anything that should only be reachable from localhost or from a specific VPC, always bind to a specific IP: -p 127.0.0.1:8080:80 or -p <private-ip>:8080:80.


DNS Inside Containers

Default bridge network

The container inherits the host's /etc/resolv.conf. No service discovery between containers.

User-defined networks

Docker runs an embedded DNS resolver at 127.0.0.11 inside every container attached to a user-defined network. The container's /etc/resolv.conf points at it. The resolver:

  • Resolves other container names on the same network to their IPs.
  • Resolves network aliases (set via --network-alias) to the container's IP.
  • Forwards everything else to the host's DNS (which Docker reads from the host's resolv.conf).
docker network create mynet
docker run -d --name db --network mynet postgres:16
docker run --rm --network mynet alpine cat /etc/resolv.conf
# nameserver 127.0.0.11
# options ndots:0

docker run --rm --network mynet alpine getent hosts db
# 172.20.0.2  db

Common DNS issues:

  • Resolving db on the default bridge fails. Use a user-defined network.
  • DNS inside containers is slow. Often because the host's resolv.conf points at a failing resolver; the container inherits this. Check cat /etc/resolv.conf inside the container.
  • Kubernetes-specific ndots issues do not apply to standalone Docker — that is a kubelet-set option in the pod's resolv.conf.

Overlay Networks (Multi-Host)

For containers spanning multiple Docker hosts, the overlay driver builds a VXLAN tunnel between them. This is what Docker Swarm uses natively. Kubernetes uses CNI plugins (Flannel, Calico, Cilium) that implement similar concepts with more features.

On a single host, you will almost never touch overlay networks directly. If you need multi-host container networking, you are probably using Kubernetes — which has its own Service / Ingress / CNI model, covered in a Kubernetes-focused course.


Network Aliases and Multiple Networks

# Give a container extra DNS names
docker network create mynet
docker run -d --name api --network mynet --network-alias backend --network-alias service myapi

docker run --rm --network mynet alpine sh -c 'nslookup api; nslookup backend; nslookup service'
# All resolve to the same IP

A container can be attached to multiple networks:

docker network create frontend-net
docker network create backend-net
docker run -d --name api --network frontend-net myapi
docker network connect backend-net api    # attach to a second network

docker exec api ip -br addr
# lo    UP
# eth0@if5  UP  172.20.0.X/16   (frontend-net)
# eth1@if7  UP  172.21.0.X/16   (backend-net)

A common pattern: put frontends on frontend-net, backends on backend-net, and the API tier on both. Then your DB is not directly reachable from frontends, but the API can talk to everything.


Inspecting and Debugging

# Which networks exist? Which containers are on each?
docker network ls
docker network inspect mynet

# Show the IP a container got
docker inspect demo --format='{{range $net, $cfg := .NetworkSettings.Networks}}{{$net}}: {{$cfg.IPAddress}}{{println}}{{end}}'

# What ports are published?
docker port demo
# 80/tcp -> 0.0.0.0:8080

# Live packet capture (from the host, on the container's veth)
VETH=$(docker inspect demo --format='{{.NetworkSettings.SandboxKey}}')
sudo ip link
# Find the vethXXX that matches the container's IF_INDEX
sudo tcpdump -i veth123abc -n

# Better: use nsenter to drop into the container's netns and use host tools
PID=$(docker inspect --format='{{.State.Pid}}' demo)
sudo nsenter -t $PID -n ip addr
sudo nsenter -t $PID -n ss -tlnp
sudo nsenter -t $PID -n tcpdump -i eth0 -n
PRO TIP

nsenter -t <pid> -n <tool> is the key move for debugging container networking from the host. You get the host's tools (ip, ss, tcpdump, ping) with the container's network view — no need to install anything inside the minimal image. When "the container cannot reach the database," this is the first command to reach for.


Docker Desktop: What's Different on macOS / Windows

Docker Desktop runs the Docker daemon in a Linux VM. Network modes behave subtly differently:

  • -p 8080:80 publishes to the Mac/Windows host through a proxy. You can curl http://localhost:8080 from the host OS.
  • host.docker.internal inside a container resolves to the host OS (macOS/Windows), not the Linux VM. This is useful for "a container that needs to reach a service running on the Mac."
  • --network=host on Docker Desktop joins the VM's network namespace, not the Mac's. This is usually not what you want on Desktop; the workaround is to publish ports normally (-p) which forwards them all the way to the Mac.

For Linux servers, everything in this lesson applies cleanly. Docker Desktop adds VM-hopping magic that can surprise you.


Common Patterns

Reverse proxy in front of apps

docker network create web
docker run -d --name app1 --network web --network-alias app1 myapp:v1
docker run -d --name app2 --network web --network-alias app2 myapp:v2
docker run -d --name proxy --network web -p 80:80 \
    -v $(pwd)/nginx.conf:/etc/nginx/nginx.conf:ro \
    nginx:alpine
# nginx.conf routes /app1 → app1:8080 and /app2 → app2:8080

Only the proxy publishes ports. Apps are reachable only via the proxy. Standard pattern for single-host multi-app setups.

Shared services

docker network create data-net
docker run -d --name db --network data-net postgres:16
docker run -d --name redis --network data-net redis:7

# Multiple apps that use these services
docker run -d --name api-v1 --network data-net \
    -e DATABASE_URL=postgres://db:5432 \
    -e REDIS_URL=redis://redis:6379 \
    myapi:v1
docker run -d --name api-v2 --network data-net \
    -e DATABASE_URL=postgres://db:5432 \
    -e REDIS_URL=redis://redis:6379 \
    myapi:v2

db and redis are just hostnames now — clean configs, stable across restarts.

WAR STORY

A team wrote a deploy script that hardcoded container IPs (172.17.0.2 for Postgres) into app configs. It worked for months — until a node reboot brought containers up in a different order, giving Postgres a different IP. The entire stack failed. Fix: drop the IPs, switch to a user-defined network, refer to containers by name. The app configs became simpler, the script lost 30 lines, and the fragility disappeared. Hardcoded IPs in a container world are a smell; service names via Docker DNS are the right answer.


Key Concepts Summary

  • Every container gets its own network namespace. veth pair + bridge + iptables rules make it reachable.
  • The default bridge does not provide DNS. Always create a user-defined bridge for related containers.
  • host = no isolation; none = no network. Use sparingly.
  • -p host:container inserts an iptables DNAT rule and a fallback docker-proxy. Rules are in the NAT table; they bypass UFW/firewalld filter rules.
  • Bind published ports to 127.0.0.1 unless you want public exposure. -p 127.0.0.1:8080:80.
  • Docker's embedded DNS (127.0.0.11) resolves container names on the same user-defined network.
  • Containers can be on multiple networks — the classic "frontend / backend" isolation pattern.
  • Overlay networks span hosts but for most multi-host setups you end up with Kubernetes.
  • nsenter -t <pid> -n <tool> is the debug key for container networking.
  • Docker Desktop has VM-hopping quirkshost.docker.internal, --network=host semantics — that Linux hosts do not.

Common Mistakes

  • Using the default bridge and being surprised containers cannot resolve each other by name. Create a user-defined network.
  • Hardcoding container IPs. Docker assigns dynamically; use container names + user-defined networks.
  • Thinking UFW blocks Docker-published ports. It does not — Docker's NAT rules run before UFW's filter rules.
  • Publishing to 0.0.0.0 by accident. Always specify 127.0.0.1: or <private-ip>: for non-public services.
  • Using --network=host in production for "performance" without understanding the port-isolation cost. Two containers can't both bind port 80 on host network.
  • Debugging container networking from inside minimal images with no tools. Use nsenter from the host instead.
  • Trying to ping a container across Docker networks without first attaching them to a common network.
  • Leaving unused user-defined networks around. docker network ls will show dozens. Clean them with docker network prune.
  • Assuming Docker Desktop's --network=host behaves like Linux. On Docker Desktop it joins the VM's host namespace, not the Mac's; use -p and host.docker.internal instead.
  • Forgetting that docker-proxy processes live on the host. On a Docker host with 200 containers and 2 ports each, there are 400 docker-proxy processes if userland-proxy is enabled. Consider disabling it for fleets.

KNOWLEDGE CHECK

You start two containers with `docker run -d --name api myapi` and `docker run -d --name db postgres`. In the api container, you try to connect to `postgres://db:5432` and get 'connection refused' / 'name not resolved.' Both containers are running. What is the problem, and what is the minimum fix?