OnCallReady

Lesson 11.15 · Docker Runtime & Networking · 20 min read

Networks: bridges, DNS, published ports, and localhost

In plain words

Picture an office building. Each flat (a container) has its own internal phone line, and dialling "0" (localhost) only ever rings inside your own flat. Flats on the same floor (a user-defined network) have a directory at reception, so you can call a neighbour by name. The old default floor has phones but no directory: you would need to know the exact extension. Different floors cannot call each other at all. And the building's front door number (a published port) is only for visitors from the street, not for neighbours.

In Docker terms: a user-defined bridge gives containers DNS by name through 127.0.0.11, the default bridge does not. -p 8080:80 publishes a port on the host. Containers talk to each other on the container port, and localhost inside a container means that container.

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.1 vs 0.0.0.0 and reading ss -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

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

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

Why it helps

Most "the app cannot reach the database" tickets in container land are one of four things from this lesson: DB_HOST=localhost inside a container, containers on the default bridge so names do not resolve, containers on two different networks, or an app bound to 127.0.0.1 so nobody outside its own container can reach it. Each produces a specific error, and recognising it saves an hour of guessing.

Publishing ports correctly is also a security review item: -p 5432:5432 exposes the database on every interface of the host, -p 127.0.0.1:5432:5432 only locally. And the rules are short enough to keep in your head: talk to names, use the container port, bind 0.0.0.0, localhost means this container.

Commands in this lesson

docker

FAQ

Why can my containers ping each other by IP but not by name?

They are on the default bridge network, which gives containers IPs and routing but no name resolution between them. Create a user-defined network with docker network create appnet and put both containers on it: Docker's embedded DNS at 127.0.0.11 then resolves container names, short IDs and --network-alias names. Compose always creates a user-defined network, which is why names work there and fail when the same containers are started by hand.

Why does DB_HOST=localhost not work from my app container?

Every container has its own network namespace with its own loopback. Inside the app container, localhost is the app container itself, and no database is listening there, so you get Connection refused. Use the database container's name on a shared network, like db. The only time localhost works across containers is when they share a network namespace, like a container started with --network container:app.

Should the app use the published port to reach another container?

No. A published port such as 15432:5432 exists on the host, through docker-proxy and iptables DNAT rules, for traffic coming from outside Docker. Between containers on a shared network, you connect to the container name and the container port: db:5432. Using the host port from inside another container either fails or takes a detour through the host, and breaks as soon as someone changes the published mapping.

What is the difference between -p 8080:80 and -p 127.0.0.1:8080:80?

-p 8080:80 binds host port 8080 on all interfaces, IPv4 and IPv6, so anyone who can reach the host can reach the container. -p 127.0.0.1:8080:80 binds only the host's loopback, so only processes on the host can connect, which is right for admin tools and databases behind a local reverse proxy. Also note that Docker's iptables rules for published ports can bypass host firewall tools like ufw.

When would I use --network host?

When you need the host's network stack directly: very high packet rates where NAT overhead matters, tools that must see host interfaces, or software that needs many dynamic ports. The container then has no network isolation, -p is ignored, and ports clash with host services for real, like a second nginx on port 80. For normal services, a bridge network with published ports is the safer default.

In an interview Junior

How do two containers talk to each other in Docker?

Put them on the same user-defined network and use the container name:

docker network create appnet
docker run -d --name db --network appnet -e POSTGRES_PASSWORD=x postgres:16
docker run -d --name api --network appnet api:1      # connects to db:5432

A user-defined bridge runs Docker's embedded DNS at 127.0.0.11, so names resolve. The default bridge does not (bad address 'db'), and two different networks cannot reach each other at all (timeout).

Three classic mistakes:

Container IPs change on restart; the name follows, a hard-coded IP does not.

Also asked: What does -p 8080:80 do, and what does -p 127.0.0.1:8080:80 change? · Why does a published port give "connection reset" when the app is up? · What is host networking, and when would you use it?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.