The Docker bridge, and what -p really does
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
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:
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:
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:
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
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:
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:
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:
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
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.
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:
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:
Routing is normal. Next, does the packet actually leave the host? Watch the host's external interface while the container retries:
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
- Start a container, then find its host-side veth interface by matching the
@ifNindex shown inside the container againstip -br linkon the host. - Publish a port with
-p 8080:80and find the exact DNAT rule it created. Delete the container and confirm the rule disappears. - 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. - 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.
- Run a service listening on
127.0.0.1on the host, then try to reach it from a container usinghost.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
- Docker Docs, Networking overview
- Docker Docs, Bridge network driver
- Docker Docs, Packet filtering and firewalls
- Linux man-pages, veth(4)