Resolving DNS Resolution Failures Between Local Containers
Your app container fails to reach db with getaddrinfo ENOTFOUND db or could not translate host name "db", even though both are defined in the same Docker Compose file — a recurring local failure point that surfaces on a new machine within minutes of the first docker compose up.
This guide walks through the diagnostic that pins down why the name does not resolve, the single root cause behind the most common variants, and a resolution that makes service-name lookups deterministic across restarts. The symptom is almost never a broken DNS server — Docker's embedded resolver is running fine. The fault is that the two containers are not sharing a resolution scope, or the dependent starts querying before the peer has registered a record.
Diagnostic
Start from inside the failing container. The error text comes from your application's resolver, so reproduce the lookup with the same tools the container has, then confirm which networks each container actually joined.
#!/usr/bin/env bash
set -euo pipefail
# 1. Reproduce the lookup the app performs
docker compose exec app getent hosts db || echo "resolution failed"
# 2. Confirm the app is pointed at Docker's embedded DNS
docker compose exec app cat /etc/resolv.conf
# 3. List the networks each container is attached to
docker inspect -f '{{.Name}} -> {{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' \
"$(docker compose ps -q app)" "$(docker compose ps -q db)"
Expected BAD output — the name does not resolve, resolv.conf looks correct, and the two containers sit on different networks:
resolution failed
nameserver 127.0.0.11
options ndots:0
/app-app-1 -> app_frontend
/app-db-1 -> app_backend
The nameserver 127.0.0.11 line is correct — that is Docker's embedded DNS. The real fault is that app and db are on different user-defined networks, so the embedded resolver has no record for db in app's scope. When getent returns nothing but resolv.conf still points at 127.0.0.11, network membership is the first thing to rule out — not a misconfigured resolver.
If both containers do share a network and the lookup still fails, widen the diagnostic. Query the embedded server directly and inspect the network's registered endpoints:
#!/usr/bin/env bash
set -euo pipefail
# Ask the embedded resolver by hand (bypasses app-side caching)
docker compose exec app nslookup db 127.0.0.11 || true
# Show every name/alias the shared network knows about
docker network inspect appnet -f '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}'
docker inspect -f '{{json .NetworkSettings.Networks}}' "$(docker compose ps -q db)"
An empty nslookup answer with both containers listed under docker network inspect points at a name mismatch — the code is asking for a hostname the resolver never registered (a stale alias, a typo, or the container name instead of the service key). A record that exists but points at a container that is restarting points at a readiness race, covered under Root cause below.
The options ndots:0 line in resolv.conf is worth understanding while you are here. It tells the resolver to treat a bare name like db as a fully qualified query and hit the embedded server directly, rather than first appending a search domain. That is why unqualified service names work at all inside Compose. If you see a non-zero ndots or an unexpected search line, something has overridden Docker's managed resolv.conf — usually a custom dns_search, a bind-mounted file, or a WSL2 host config leaking in — and the resolver may be appending a suffix that turns db into db.some.domain and misses the record entirely.
Root cause
Docker's embedded DNS server runs at 127.0.0.11 inside every container on a user-defined network and resolves service names to container IPs — but only for containers attached to the same network. Service-name DNS silently fails in three distinct ways. First, the two services are placed on different user-defined networks (often by per-service networks: lists that do not overlap), so their resolution scopes never intersect. Second, a service is reached before it has registered — a timing gap that depends_on alone does not close, because bare depends_on waits for the container to start, not for the process inside to be ready to answer. Third, the code targets the wrong name: Compose registers both the service key (db) and the container name (app-db-1), and any custom aliases add still more, so an application configured with a hostname that matches none of them gets ENOTFOUND even though the container is healthy and on the right network.
There is a fourth, subtler variant worth calling out: a service scaled to multiple replicas. When you run docker compose up --scale app=3, the service name resolves to all three replica IPs as separate A records, and a naive client that reads only the first answer may pin every request to one replica or hit a replica that is still warming up. Round-robin across replicas is the resolver's job, but connection pools that cache the first record defeat it — another reason to re-resolve rather than cache. For single-replica local stacks this never bites, but it explains intermittent "sometimes it connects, sometimes it does not" reports on scaled services.
In every case the embedded resolver is working correctly; it simply has nothing to answer with in the scope it was asked, or it hands back a set of records the client mishandles. That reframing matters because the instinct is to reach for --dns flags or to edit /etc/resolv.conf, both of which make things worse — they can shadow 127.0.0.11 and break the service discovery that would otherwise work. This is the local-container view of the routing covered in configuring local DNS for microservice routing; here the scope is a single Compose project rather than a routed mesh.
Resolution
Work the three causes in order — scope first, then names, then readiness — because a name or readiness fix is invisible until both containers can see each other at all.
- Put both services on a shared user-defined network so the embedded DNS scope overlaps.
- Add explicit
aliasesif code references a hostname other than the service key. - Gate the dependent on readiness with
depends_on: condition: service_healthy, not baredepends_on. - Recreate the stack so network membership and DNS records are rebuilt.
# docker-compose.yml
services:
app:
build: .
depends_on:
db:
condition: service_healthy
networks:
- appnet
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: localdev
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 2s
timeout: 3s
retries: 10
networks:
appnet:
aliases:
- database # app can use db OR database
networks:
appnet:
driver: bridge
Each step maps directly to one of the three failure modes. The single appnet gives both containers an overlapping scope, so the resolver has something to answer with. The aliases list registers database as a second name for the same container, which lets an application whose connection string was written against a legacy hostname resolve without a code change. The service_healthy condition holds app in a waiting state until pg_isready returns success ten times or less, closing the readiness race so the very first query hits a live record rather than a half-started process. Then rebuild membership and confirm the resolver answers:
#!/usr/bin/env bash
set -euo pipefail
docker compose down
docker compose up -d --wait
docker compose exec app getent hosts db
docker compose down is deliberate here: recreating rather than restarting forces Docker to tear down the old network attachments and re-register every DNS record on appnet. A plain docker compose restart reuses the existing network membership, so a container that was on the wrong network stays on the wrong network and the lookup keeps failing. The --wait flag makes up block until every service with a healthcheck reports healthy, which means the final getent probe runs against a fully registered, ready peer.
Expected output
The peer name now resolves to the embedded DNS record (an IP on the shared bridge), and both containers list the same network:
$ docker compose exec app getent hosts db
172.20.0.3 db
$ docker inspect -f '{{.Name}} -> {{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' app-app-1 app-db-1
/app-app-1 -> appnet
/app-db-1 -> appnet
Both containers now report appnet, and the alias resolves to the same address:
$ docker compose exec app getent hosts database
172.20.0.3 database
Any of the three names — db, database, or app-db-1 — now returns the same IP, so a connection string written against any of them connects. If getent still returns nothing here, the container did not re-create; run docker compose down again and confirm no stale project network lingers with docker network ls | grep app_.
One subtlety to note about the returned address: the IP is not stable across a full recreate. Docker assigns container IPs from the bridge subnet at attach time, so 172.20.0.3 today may be 172.20.0.4 after the next down/up cycle. This is exactly why you resolve by name rather than hardcoding the address — the name is the stable contract, the IP is an implementation detail the embedded resolver keeps current. Any code, migration script, or wait-for helper that caches the resolved IP will break the next time the stack is rebuilt; always re-resolve the name on each connection attempt.
Prevention
- Default to a single shared network and only segment when isolation is a real requirement — document the topology alongside mapping microservice dependencies for local dev. A flat network is the correct default for local development; premature segmentation is the most common source of these failures.
- Always pair cross-service calls with
depends_on: condition: service_healthyso the name exists and the service is ready — see resolving service startup-order and healthcheck races. - Add a
getent hosts <peer>probe to your onboarding health-check script so a broken lookup fails the setup loudly on the first run instead of surfacing as a confusing runtime error later. - Keep hostnames in one place. Reference the service key in every connection string and environment variable rather than scattering container names and aliases across configs — one canonical name means one thing to keep in sync.
Platform caveats
macOS / Windows (Docker Desktop): the embedded resolver lives inside the Linux VM; you cannot resolve service names from the host. Use
localhost:<published-port>from the host and service names only between containers. WSL2: a stale/etc/resolv.conf(fromgenerateResolvConf=false) can shadow127.0.0.11; let Docker manage container resolv.conf and avoid injecting host DNS into containers. Apple Silicon (ARM64): no DNS behavior difference, but a service that exits on startup never registers a DNS record — confirm the peer is actually running first, then debug the exit with diagnosing containers that exit immediately on startup.
Rollback
#!/usr/bin/env bash
set -euo pipefail
git checkout -- docker-compose.yml && docker compose up -d --force-recreate # restore prior networks
--force-recreate matters on rollback for the same reason down mattered on the fix: it rebuilds network attachments from the reverted file rather than reusing the current ones, so the containers land back on exactly the networks the old compose file declared.
Frequently Asked Questions
Why does ping db work but my app still gets ENOTFOUND?
ping and your application may not be using the same name. Compose registers the service key, the container name, and any aliases as separate records; if your connection string points at a hostname that matches none of them, the resolver returns nothing even though a different name for the same container resolves. Check what the code actually requests, then add an alias or align the config to the service key.
Does depends_on guarantee the peer name resolves before my app starts?
No. Bare depends_on waits only for the container to start, not for the process to be ready, so your app can query before the peer has finished registering. Use depends_on: condition: service_healthy together with a healthcheck on the peer, and start the stack with docker compose up -d --wait so the dependent is held until the record exists and the service answers.
Should I set a custom dns: or edit /etc/resolv.conf to fix this?
Almost never. The nameserver 127.0.0.11 entry is Docker's embedded resolver and it is what makes service-name discovery work. Overriding it with a custom dns: or a mounted resolv.conf shadows 127.0.0.11 and breaks the exact lookup you are trying to fix. Put both containers on one network instead — the resolver is not the problem, the scope is.
Why does the lookup fail again after docker compose restart?
restart reuses the existing container and its current network membership, so a container attached to the wrong network stays there. Use docker compose down followed by docker compose up -d --wait (or --force-recreate) to tear down and rebuild network attachments and DNS records from the compose file.