Ch. 13 · Docker

Docker Networks and Container DNS

Why containers can't resolve each other on the default bridge, how the 127.0.0.11 resolver works, and why nginx keeps a stale upstream IP.

~5 min readintermediateupdated Oct 7, 2026

Two containers on the same machine, one running an API, one that needs to call it. How does the second find the first? The answer is “by name, through Docker’s DNS”, but only if a few conditions hold, and the failures when they don’t are confusing enough that this is a standard interview topic.

Everything below was run with nginx:alpine as the server and alpine:3.20 as the client. Names are shortened.

The default bridge has no service discovery

Start nginx with no network options and try to reach it by name:

$ docker run -d --name web1 nginx:alpine
$ docker run --rm alpine:3.20 wget -qO- -T 2 http://web1/
wget: bad address 'web1'
Text

Both containers are on the default network called bridge, and they can reach each other by IP address. But the default bridge doesn’t provide name resolution between containers. Its /etc/resolv.conf just points at the host’s resolver (on Docker Desktop, nameserver 192.168.65.7), which has never heard of web1. The old workaround was --link, which is deprecated. The fix is a user-defined network.

User-defined networks and the embedded resolver

docker network create appnet
docker run -d --name api --network appnet nginx:alpine
Terminal

A client on the same network sees a different resolver:

$ docker run --rm --network appnet alpine:3.20 cat /etc/resolv.conf
nameserver 127.0.0.11
options ndots:0
$ docker run --rm --network appnet alpine:3.20 nslookup api
Name:	api
Address: 172.19.0.2
$ docker run --rm --network appnet alpine:3.20 wget -qO- http://api/ | grep -o '<title>.*</title>'
<title>Welcome to nginx!</title>
Text

127.0.0.11 is Docker’s embedded DNS server. It runs inside the daemon and is reachable from each container’s network namespace. It answers for container names, --network-alias values and, in Compose, service names, all scoped to the networks the asking container shares with the target. Anything it doesn’t know, it forwards to the host’s upstream resolvers.

That scoping is a security boundary, not just a convenience. From appnet, web1 (still only on the default bridge) didn’t resolve. Networks are how you segment containers: a database on a backend network, an API on both backend and frontend, a reverse proxy only on frontend. A container can join several networks with docker network connect, after which it has an interface and an IP on each:

$ docker network connect appnet web1
$ docker inspect web1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}={{$v.IPAddress}} {{end}}'
bridge=172.17.0.2 appnet=172.19.0.4
Text

Compose does this for you. Every project gets a <project>_default network, and every service joins it with its service name as an alias, which is why postgres://db:5432 works in a compose.yaml without any network configuration.

Aliases and simple round robin

Several containers can share an alias. The resolver returns all their addresses:

$ docker run -d --name api2 --network appnet --network-alias api nginx:alpine
$ docker run --rm --network appnet alpine:3.20 nslookup api
Address: 172.19.0.2
Address: 172.19.0.3
Text

Clients that try each address, or pick one per lookup, get crude load spreading. It’s not a load balancer, though: there are no health checks, and many clients cache the first answer. Compose services scaled with --scale api=3 work the same way.

localhost is the container itself

Each container has its own network namespace, so localhost means that container’s loopback interface:

$ docker run --rm --network appnet alpine:3.20 wget -qO- http://localhost/
wget: can't connect to remote host: Connection refused
Text

This is probably the single most common Docker networking bug report. An app configured with DB_HOST=localhost worked when the database ran on the laptop and fails once both are containers. Use the service or container name. To reach something on the host from a container, Docker Desktop provides host.docker.internal; on Linux, add --add-host=host.docker.internal:host-gateway. And a server inside a container must listen on 0.0.0.0, not 127.0.0.1, or nothing outside its own namespace (including published ports) can reach it. The port publishing note covers that side.

Stale IPs: the nginx upstream problem

DNS gives you the current address at lookup time. Container IPs aren’t stable; recreate a container and it may get a different one, and the old one may be handed to a different container. A client that resolves once and caches forever will eventually be wrong.

nginx’s default proxy_pass http://api:80; is exactly that kind of client. It resolves the name when it loads the config. If api doesn’t exist yet, it won’t even start:

nginx: [emerg] host not found in upstream "api" in /etc/nginx/conf.d/default.conf:3
Text

If api exists at start-up, nginx works, until api is recreated. In this test the proxy cached 172.19.0.2, the API was recreated and came back as 172.19.0.5, and meanwhile another container had been given .2:

$ docker run --rm --network appnet alpine:3.20 wget -qO- http://proxy/
wget: server returned error: HTTP/1.1 502 Bad Gateway
$ docker logs proxy
... connect() failed (111: Connection refused) while connecting to upstream, ... upstream: "http://172.19.0.2:80/"
Text

The proxy is sending traffic to whichever container now owns the old IP. Here that was a container with nothing listening; in a less lucky case it’s a different service that answers with the wrong data. The fix is to make nginx resolve at request time through the embedded DNS, with a short cache:

server {
  listen 80;
  resolver 127.0.0.11 valid=10s;
  set $upstream http://api:80;
  location / { proxy_pass $upstream; }
}
nginx

Using a variable in proxy_pass forces a runtime lookup via the resolver. With this config the proxy kept working after the API was recreated with a new address. The same lesson applies to connection pools, JVM DNS caching (networkaddress.cache.ttl) and any client that resolves once.

Note

Restarting a container (docker restart) usually keeps its IP; recreating it (docker compose up after a change, docker rm and run) usually doesn’t. Don’t rely on either.

Interview practice

A Compose stack has web (a Node app) and db (Postgres). web logs connect ECONNREFUSED 127.0.0.1:5432. A colleague “fixes” it by publishing 5432:5432 on db and setting DB_HOST=host.docker.internal, which works. What was wrong, why is the fix poor, and what’s the right configuration?

Answer

The app was configured with localhost, which inside the web container is web’s own loopback interface. Nothing listens on 5432 there, hence the refused connection on 127.0.0.1.

The workaround sends traffic out of the container to the host and back in through a published port. It works on Docker Desktop, but it exposes Postgres on the host’s network interfaces (to the whole LAN, by default, since publishing binds 0.0.0.0), adds a NAT hop, and breaks on Linux hosts where host.docker.internal isn’t defined unless configured.

The right setup is DB_HOST=db. Both services are on the project’s default network, Docker’s embedded resolver at 127.0.0.11 maps db to the Postgres container’s current IP, and no port needs publishing at all. If something on the host really needs to reach Postgres, publish it on loopback only (127.0.0.1:5432:5432). It’s also worth making the app retry its connection at start-up, because db being resolvable doesn’t mean it’s ready (see Compose dependencies).

Keep going

More in Docker

read ✓Docker · mid

Docker Compose Dependencies and Readiness

Plain depends_on only orders container starts. Use healthchecks, service_healthy and service_completed_successfully to wait for a ready database.

~5 min readread →
read ✓Docker · mid

Docker Health Checks and Useful Probes

How a HEALTHCHECK moves from starting to unhealthy, why plain Docker never restarts an unhealthy container, and what a good probe checks.

~5 min readread →
esc