Skip to content

The Docker bridge, and what -p really does

docker run -d -p 8080:80 nginx

Nearly everyone who uses Docker types this before understanding it, and it works, so the understanding gets postponed indefinitely. Then something doesn't work — a container can't reach a database on the host, a published port is unexpectedly open to the internet, two containers can't find each other by name — and the postponement stops being free.

There's no magic in that command. It creates a virtual Ethernet link, attaches one end to a software bridge, and writes a NAT rule. All three are things you can look at.

The default bridge, seen from the host

ip -br addr show docker0
docker0          UP             172.17.0.1/16

docker0 is a software switch — the same switch behaviour you already know, implemented in the kernel instead of a box in a rack. It forwards frames between whatever is plugged into it, and it holds the address 172.17.0.1, which makes it also the default gateway for every container attached to it.

Start a container and a second interface appears on the host:

docker run -d --name web nginx
ip -br link show type veth
veth9a3f1c2@if8  UP             1a:5e:cc:03:9f:44 <BROADCAST,MULTICAST,UP,LOWER_UP>

That's one end of a veth pair — a virtual cable with two ends, where anything sent into one end comes out the other. The host end (veth9a3f1c2) is plugged into docker0; the other end lives inside the container's network namespace, where it's called eth0. The @if8 suffix names the interface index of the far end, which is how you match a host-side veth to the container it belongs to.

From inside the container, the view is completely ordinary:

docker exec web ip -br addr
lo               UNKNOWN        127.0.0.1/8
eth0@if9         UP             172.17.0.2/16
docker exec web ip route
default via 172.17.0.1 dev eth0
172.17.0.0/16 dev eth0 scope link src 172.17.0.2

A /16 of private address space, a default route to the bridge, and nothing else. Every rule from the routing and addressing articles applies unchanged — the container just has a stack of its own.

Outbound traffic: it's NAT, the same NAT as your home router

A container with 172.17.0.2 can reach the internet, which shouldn't be possible: that's a private address, unroutable outside this host. Docker solves it exactly the way your home router does — source NAT — by writing a masquerade rule:

sudo nft list table ip nat | grep -A3 'chain POSTROUTING'
    chain POSTROUTING {
        type nat hook postrouting priority srcnat; policy accept;
        oifname != "docker0" ip saddr 172.17.0.0/16 counter packets 3812 bytes 228720 masquerade
        jump DOCKER

Read it as: any packet leaving by an interface other than docker0, from the container subnet, gets its source address rewritten to the host's address. To the outside world, the traffic came from the host. Replies come back to the host, and connection tracking rewrites them back to 172.17.0.2.

Which explains a property people find surprising: containers can reach out, but nothing outside can reach in on its own. There's no rule for unsolicited inbound traffic, and 172.17.0.2 means nothing to any router on your network. That's not a security feature anyone designed; it's the same consequence of NAT the course covered earlier.

Publishing a port is a DNAT rule

docker run -d --name web -p 8080:80 nginx
sudo nft list chain ip nat DOCKER
table ip nat {
    chain DOCKER {
        iifname "docker0" return
        iifname != "docker0" tcp dport 8080 counter packets 14 bytes 840 dnat to 172.17.0.2:80
    }
}

There it is. -p 8080:80 is a destination NAT rule: traffic arriving at the host on port 8080 gets its destination rewritten to 172.17.0.2:80 and forwarded into the container. The syntax reads host-side first — -p <host>:<container> — and getting that order backwards is a rite of passage.

Two consequences follow directly, and both are exam material.

The port is published on every host address by default. -p 8080:80 is shorthand for -p 0.0.0.0:8080:80. On a cloud VM with a public IP, that container is on the internet the moment it starts. Bind it explicitly instead:

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

Now the database is reachable only from the host itself — from another container via the host's bridge address, or from an SSH tunnel — and no firewall rule is doing the work, so no firewall misconfiguration can undo it.

Your host firewall probably isn't filtering it. Because published traffic is forwarded into the container rather than delivered to the host, it never touches the input hook where ufw's rules live. This is documented Docker behaviour, not a bug, and it's the single most common way a "firewalled" server ends up with an open database. The firewall article covers the mechanics; the practical rule is short:

Bind narrowly with -p 127.0.0.1:… rather than relying on a host firewall to close a published port.

Check what's actually exposed, rather than what you think is:

docker ps --format 'table {{.Names}}\t{{.Ports}}'
NAMES     PORTS
web       0.0.0.0:8080->80/tcp, [::]:8080->80/tcp
db        127.0.0.1:5432->5432/tcp

0.0.0.0-> means the whole world. 127.0.0.1-> means this host only. Reading that column is a ten-second security audit.

The default bridge has no DNS, and user-defined bridges do

Two containers on the default bridge:

docker run -d --name api nginx
docker run --rm alpine ping -c1 api
ping: bad address 'api'

Names don't resolve. The default bridge network deliberately provides no service discovery, so containers on it can only find each other by IP address — which changes on every restart, making it useless for anything real.

Create a network instead, and the behaviour changes:

docker network create appnet
docker run -d --name api --network appnet nginx
docker run --rm --network appnet alpine ping -c1 api
PING api (172.18.0.2): 56 data bytes
64 bytes from 172.18.0.2: seq=0 ttl=64 time=0.089 ms

On a user-defined bridge, Docker runs an embedded DNS resolver at 127.0.0.11 inside each container's namespace, and it answers with container names and network aliases. This is why every Compose file works — Compose creates a user-defined network for the project, so a service can reach db:5432 by name — and why a hand-rolled docker run that skips --network then fails to resolve anything.

docker exec api cat /etc/resolv.conf
nameserver 127.0.0.11
options ndots:0

The other difference is isolation. Containers on appnet can reach each other on any port without publishing anything, and containers on a different user-defined network can't reach them at all. That gives you real segmentation for free: put the database on a back-end network with the application, put the application on a front-end network with the reverse proxy, and the database is unreachable from the proxy by construction rather than by rule.

172.17.0.0/16 will eventually collide with something

Docker's default pool overlaps with private ranges used in plenty of corporate networks and VPNs. When it does, the symptom is bizarre: a host that suddenly can't reach an internal service, because the container subnet's route is more specific than the VPN's and wins.

Check before it bites you (ip route show | grep 172.17), and if there's a conflict, set a different pool in /etc/docker/daemon.json with default-address-pools. Restarting the Docker daemon after that change restarts container networking, so do it in a maintenance window.

Reaching the host from inside a container

An application in a container needs a database running on the host itself. localhost won't do it — inside the namespace, localhost is the container.

On Linux, the host is reachable at the bridge's own address, 172.17.0.1 — the container's default gateway. Hardcoding that address works but is brittle, since a user-defined network gets a different subnet. The portable form asks Docker to fill it in:

docker run --add-host=host.docker.internal:host-gateway --rm alpine \
  ping -c1 host.docker.internal
PING host.docker.internal (172.17.0.1): 56 data bytes
64 bytes from 172.17.0.1: seq=0 ttl=64 time=0.061 ms

host-gateway is a special value Docker substitutes with the host's address on that container's network. On Docker Desktop for macOS and Windows, host.docker.internal resolves without the --add-host flag, which is why examples copied from Desktop documentation fail on a Linux server. And note what has to be true on the host side for this to work at all: the service must be listening on 0.0.0.0 or on 172.17.0.1, not on 127.0.0.1. A database bound to loopback on the host is invisible to every container, for exactly the reason the ss article gave.

Investigation: the container that can't reach the database

An application container reports Connection refused against 10.20.0.31:5432. The same command works from the host.

Start inside the namespace, because that's where the failing packet originates:

docker exec app ip route
default via 172.18.0.1 dev eth0
172.18.0.0/16 dev eth0 scope link src 172.18.0.5

Routing is normal. Next, does the packet actually leave the host? Watch the host's external interface while the container retries:

sudo tcpdump -i eth0 -nn 'host 10.20.0.31 and port 5432' -c 5
10:52:03.118422 IP 10.20.5.9.42118 > 10.20.0.31.5432: Flags [S], seq 771823945, win 64240, length 0
10:52:03.118661 IP 10.20.0.31.5432 > 10.20.5.9.42118: Flags [R.], seq 0, ack 771823946, win 0, length 0

Two facts, immediately. The packet left with source 10.20.5.9 — the host's address, because of masquerade, not the container's 172.18.0.5. And the database sent a reset.

That reframes the whole question. The database is refusing a connection from the host's address, which is the same address the host itself uses — and the host's own psql works. So the difference isn't the source address. Checking the destination's pg_hba.conf reveals it accepts connections only on port 5432 from that subnet for a specific database user, and the container is configured with a different one.

The point isn't the specific bug. It's that a container's traffic reaches the outside world wearing the host's address, so every access rule based on source IP treats containers and the host identically — a fact that makes "restrict access by IP" much weaker than it looks in a containerised environment, and one that surprises people when they try to grant a single container access to something.

Practice

  1. Start a container, then find its host-side veth interface by matching the @ifN index shown inside the container against ip -br link on the host.
  2. Publish a port with -p 8080:80 and find the exact DNAT rule it created. Delete the container and confirm the rule disappears.
  3. Run the same container twice — once with -p 8080:80, once with -p 127.0.0.1:8080:80 — and try to reach each from a second machine. Explain the difference in terms of which address the rule matches.
  4. Put two containers on the default bridge and two on a user-defined network. Try to resolve each by name from the other, and account for all four results.
  5. Run a service listening on 127.0.0.1 on the host, then try to reach it from a container using host.docker.internal. Predict the failure before running it, then fix it by changing only the host service's bind address.

Everything above is one host: one bridge, one NAT table, one namespace per container. The moment you have two hosts, 172.17.0.2 stops being unique and the whole model needs replacing — which is where the other network drivers come in.

Sources