Skip to main content

Docker networking: bridges, iptables and UFW

· 5 min read
Ashik Mostofa Tonmoy
Senior DevOps Engineer & Platform Engineering Consultant

Most sessions that start with "the container can't reach the database" end at one of four or five misunderstandings. Docker networking is not magic — it is Linux bridges, veth pairs and iptables with a convenient API on top. Once you can see those three things directly, the guessing stops.

The default bridge is rarely what you want

A container started with no --network attaches to docker0. It works, and it costs you the one feature you will miss immediately: name resolution.

On the default bridge, containers find each other by IP only. On a user-defined bridge, Docker runs an embedded DNS resolver and container names work:

docker network create app-net
docker run -d --name api --network app-net my-api
docker run -d --name db --network app-net my-db
# api can now reach db:5432 by name

User-defined bridges also isolate: containers on separate bridges cannot reach each other without explicit routing, which is a useful default rather than an obstacle.

Finding the veth pair

Every container interface is one end of a veth pair. The other end sits on the host bridge. Being able to match them turns "it should be connected" into "it is connected, to this".

Inside the container:

docker exec api cat /sys/class/net/eth0/iflink
# 42

That number is the host-side interface index:

ip link | grep '^42:'
# 42: veth9c3f1a@if41: <BROADCAST,MULTICAST,UP,LOWER_UP> master br-8f2e1c ...

master br-8f2e1c is the bridge it is attached to. Confirm that matches the network you meant:

docker network inspect app-net --format '{{.Id}}'
# 8f2e1c... — the bridge name is br- plus the first 12 chars

If the bridge does not match, the container is on a different network and nothing else you check matters.

The firewall gotcha: -p goes around UFW

This is the one worth knowing even if you skip everything else.

You configure a default-deny firewall. You publish a port:

docker run -d -p 5432:5432 postgres

Your database is now reachable from the internet, and ufw status still says deny (incoming).

UFW manages rules in the filter table's INPUT chain. Docker inserts its own rules into the nat table's PREROUTING chain, which runs first, and then into FORWARD — neither of which UFW is looking at. Traffic is DNAT'd to the container before UFW's rules are ever consulted.

See it directly:

sudo iptables -t nat -L DOCKER -n --line-numbers
# DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:5432 to:172.18.0.2:5432

Two ways to fix it:

Bind to the interface you actually mean. The most reliable fix, and it needs no firewall knowledge:

docker run -d -p 127.0.0.1:5432:5432 postgres

An unqualified -p 5432:5432 means 0.0.0.0 — every interface on the host.

Use the DOCKER-USER chain. Docker jumps to it before its own rules and never flushes it, so it survives daemon restarts:

sudo iptables -I DOCKER-USER -i eth0 ! -s 10.0.0.0/8 -j DROP

Note this filters on the host's external interface, not the bridge. Rules you add to INPUT will not be consulted for published ports, no matter how correct they look.

On nodes with no private network between them — where every host sits on a shared public segment — this is not a hardening nicety. A published port is a port on the public internet, and the host firewall you configured is not in the path.

DNS: 127.0.0.11, and what breaks it

Docker's embedded resolver listens on 127.0.0.11 inside the container namespace. The classic symptom is: from the host I can curl the service by IP, but from another container the hostname does not resolve.

Check the three things that cause it:

docker exec api cat /etc/resolv.conf # is 127.0.0.11 still the nameserver?
docker inspect api --format '{{json .NetworkSettings.Networks}}' | jq keys
docker exec api getent hosts db
  1. Different networks. Name resolution only works within a user-defined bridge.
  2. The default bridge. No embedded DNS, so names never resolve there.
  3. An overridden resolv.conf. A --dns flag or a base image that writes its own file removes the path to 127.0.0.11.

Listening on 127.0.0.1 inside a container

A service bound to 127.0.0.1 inside a container is bound to that container's loopback. Nothing outside the namespace can reach it, including the published port — the DNAT rule delivers traffic to the container's eth0 address, where nothing is listening.

docker exec api ss -tlnp
# LISTEN 0 128 127.0.0.1:8080 ← unreachable
# LISTEN 0 128 0.0.0.0:8080 ← correct

Connection refused on a correctly published port is almost always this.

A mental model that holds up

Each Docker bridge is a virtual switch with its own subnet. A veth pair is a cable: one end in the container's network namespace, one end plugged into that switch. Published ports are DNAT rules that rewrite the destination before routing.

Cross-host overlay networking extends the same model with VXLAN tunnels between hosts. Under Kubernetes you are not using Docker bridge networking at all — a CNI plugin owns the pod network, and the debugging moves to that layer.

Checklist

  • Same docker network? docker network inspect on both.
  • Ping by IP works but name fails? DNS — check resolv.conf and whether it is the default bridge.
  • Connection refused on a published port? The process is bound to 127.0.0.1 inside the container.
  • Port reachable that should not be? iptables -t nat -L DOCKER, then bind to an explicit interface.
  • Cross-bridge traffic failing? That is intended isolation, not a bug.