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'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:alpineA 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>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.4Compose 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.3Clients 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 refusedThis 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:3If 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/"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; }
}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 upafter a change,docker rmandrun) 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).