Why this matters
"The app cannot reach the database - but both containers are up." That ticket is almost always one of four things in this lesson: the wrong network, a missing DNS name, the wrong port, or localhost. Each gives a different error, so once you know them you can name the cause from the error text alone.
What you need to know already: IP addresses, subnets, routes and DNS (8.1, 8.3, 8.11, 8.16); ports, sockets, listening on
127.0.0.1vs0.0.0.0and readingss -tlnp(9.5, 9.8); a container has its own network namespace - its own interfaces, IP and loopback (10.3).
The network modes
Every container joins a network that decides what it can talk to. Docker has several kinds:
bridge (default) a private subnet (172.17.0.0/16) behind NAT. No DNS between containers.
user-defined a bridge network you created. The same, PLUS container names resolve.
host no network namespace: the host's interfaces and ports. -p is ignored.
none loopback only - no network at all.
container:<name> share another container's network namespace (same IP, same localhost).
A bridge is a virtual network switch inside the Linux kernel. Docker creates one (docker0 on the host, 172.17.0.1) and plugs each container into it with a virtual cable; containers on it get addresses from its subnet, and the host routes and NATs their traffic out to the world (network address translation: the host rewrites the container's private address to its own on the way out).
docker network ls lists networks:
$ docker network ls
NETWORK ID NAME DRIVER SCOPE
1f2e3d4c5b6a bridge bridge local
7a8b9c0d1e2f host host local
3c4d5e6f7a8b none null local
NAME is what you type after --network; DRIVER is the kind; SCOPE local = this host only.
The DNS difference
$ docker run -d --name db -e POSTGRES_PASSWORD=x postgres:16
$ docker run --rm alpine:3.20 ping -c1 db
ping: bad address 'db'
(ping -c1 = send one ping. busybox, alpine's toolset, says "bad address" for a name it cannot resolve.)
The default bridge gives containers IPs and routes between them, but no name resolution. A bridge you create yourself runs Docker's embedded DNS server at 127.0.0.11 inside each container:
$ docker network create appnet
$ docker network connect appnet db
$ docker run --rm --network appnet alpine:3.20 nslookup db
Server: 127.0.0.11
Address: 127.0.0.11:53
Name: db
Address: 172.18.0.2
$ docker run --rm --network appnet alpine:3.20 cat /etc/resolv.conf
# Generated by Docker Engine.
...
nameserver 127.0.0.11
docker network create appnet- make a user-defined bridge (it gets the next subnet, 172.18.0.0/16)docker network connect appnet db- plug an existing container into it--network appnetondocker run- start a container on itnslookup db- ask DNS fordb: Server is who answered (Docker's DNS), Address under Name is the answer./etc/resolv.confis the resolver config from 8.16, pointing at it.
Names that resolve on a user-defined network: the container name, its short ID, and any --network-alias (an extra name you give it). Compose (11.31) adds each service name as an alias, which is why db works in Compose and fails when someone runs the same containers by hand. Names not on the network are forwarded to the host's normal DNS, so github.com still resolves.
Container IPs are not stable - a restarted container can come back on a different address. The name follows it; a hardcoded 172.18.0.2 does not.
Network isolation: networks cannot see each other
Two containers on different bridges cannot reach each other at all - Docker adds firewall (iptables) rules that drop traffic between bridges:
# frontnet / backnet: two user-defined bridges (the networking mission)
docker run --rm --network frontnet nicolaka/netshoot curl -m 3 http://172.19.0.2:8080
curl: (28) Connection timed out after 3000 milliseconds
(nicolaka/netshoot is an image full of network tools - curl, dig, ss, nc, tcpdump - that you run next to a container to debug it; curl -m 3 = give up after 3 seconds.) A timeout, not "refused": packets are dropped, nobody answers (9.1). Fix it by putting both on a shared network (a container can be on several: docker network connect backnet web), not by opening ports.
Published ports
Nothing outside the host can reach a container's port until you publish it: tell Docker to forward a port on the host to a port in the container.
-p 8080:80 host port 8080 on every interface -> container port 80
-p 127.0.0.1:8080:80 the same, but only reachable from the host itself
-p 80 container port 80 on a random free host port
-P every port the image EXPOSEs (10.8) on random host ports
# web started with -p 8080:80
docker port web
80/tcp -> 0.0.0.0:8080
80/tcp -> [::]:8080
sudo ss -tlnp | grep 8080
LISTEN 0 4096 0.0.0.0:8080 0.0.0.0:* users:(("docker-proxy",pid=4012,fd=4))
docker port NAME shows its mappings: container port -> host address:port ([::] is the IPv6 "every address"). ss -tlnp (9.5: TCP, listening, numeric, with process) shows who holds the host port: docker-proxy, a small Docker process that accepts connections on the host port and passes them into the container (plus NAT rules doing the same in the kernel). A second container asking for the same host port fails at start:
docker: Error response from daemon: driver failed programming external connectivity on endpoint web2 (...): Bind for 0.0.0.0:8080 failed: port is already allocated.
and a host process already listening on it (this box's nginx on 80) gives Error starting userland proxy: listen tcp4 0.0.0.0:80: bind: address already in use
- the same "address already in use" as in 9.8.
Container-to-container traffic does not use published ports. On a shared network, api talks to db:5432 - the container port - even if db is published as 15432:5432 for your laptop. Using the host port from another container is a classic misconfiguration.
localhost means "this container"
Every container has its own loopback interface. Inside the api container, localhost is api - not the host, and not the database next to it. DB_HOST=localhost in a container means "a database inside this same container" - there is none: Connection refused.
And the mirror image: an app that binds (listens) on 127.0.0.1 inside its container is reachable only from inside that container. The port is published, docker-proxy accepts your connection, has nothing to forward it to, and drops it:
# api = a container whose app binds 127.0.0.1, published on 8081
curl -sS localhost:8081
curl: (56) Recv failure: Connection reset by peer
docker exec api ss -tlnp
LISTEN 0 511 127.0.0.1:8080 0.0.0.0:* users:(("java",pid=1,fd=12))
(curl -sS = silent, but still Show errors.) The Local Address column says 127.0.0.1:8080: loopback only. Bind 0.0.0.0 (every interface, 9.5) in containers. Python's Flask dev server and gunicorn (a Python web server) default to 127.0.0.1; Spring's server.address does if someone set it.
Reaching the host from a container
On Linux there is no automatic host.docker.internal name (Docker Desktop on a Mac adds one). Ask for it:
docker run --add-host=host.docker.internal:host-gateway ...
--add-host NAME:IP adds a line to the container's /etc/hosts (8.16); host-gateway means the bridge's gateway address (172.17.0.1), which is the host. The service on the host must listen on that interface (or 0.0.0.0).
host networking
--network host removes the network namespace: the container uses the host's interfaces and ports directly. Fast (no NAT), no isolation, -p does nothing, and a port clash is a real clash - nginx in a host-network container on this box fails with bind() to 0.0.0.0:80 failed (98: Address already in use), because the host's nginx has it.
What you can now do
- make two containers reach each other by name (a user-defined network)
- publish a port, and choose who can reach it (
0.0.0.0vs127.0.0.1) - recognise the four classic mistakes: default bridge, split networks, published port between containers,
localhost/ 127.0.0.1 inside a container