Resolving DNS Failures in Compose Networks

Your app throws getaddrinfo ENOTFOUND db even though the database is running. This page fixes the most common Compose networking failure — services that can't resolve each other by name — by getting them onto a shared user-defined bridge where Docker's embedded DNS works, and confirming with dig and getent.

This task matters because in a Compose-backed devcontainer, service-to-service addressing is the load-bearing contract that ties the stack together: the app finds Postgres, Redis, and every other backing service by its Compose service name, and if that name does not resolve the whole environment looks broken even though every container is healthy. You reach for this fix the moment a connection string that uses a hostname like db or redis fails with ENOTFOUND, Name does not resolve, or Temporary failure in name resolution, while the same target answers fine when you address it by container IP. That gap between "IP works, name fails" is the signature of a DNS-topology problem, and it is exactly what a shared user-defined bridge repairs.

The mental model to hold is that Docker runs a small embedded DNS resolver at 127.0.0.11 inside every container attached to a user-defined network, and that resolver is the only thing that maps a Compose service name to the current container address. It exists only for user-defined networks; the legacy default bridge has no such resolver. So resolving DNS failures is rarely about editing /etc/resolv.conf, restarting dockerd, or pointing at a different upstream nameserver — it is about making sure both the caller and the callee sit on the same user-defined bridge and are addressed by name. Once you internalize that the embedded resolver lives with the network, the fix becomes a two-line topology change rather than a guessing game.

Prerequisites

You need exec access to the containers and the service names you expect to resolve.

  • dig/getent available in the app container (install dnsutils).
  • The Compose file and the service names it defines.
  • Exec access to the app and database containers.

The prerequisites look trivial, but each one exists to keep the diagnosis honest. You need dig or getent inside the app container specifically, not on the host, because the host resolves names through its own /etc/resolv.conf and would happily fail — or succeed — for reasons that have nothing to do with what the containerized process sees. getent hosts db is the safer of the two to reach for first: it walks the same NSS resolution path the application's own runtime uses, so its answer matches what your Node or Python process would get, whereas dig talks straight to the DNS resolver and can occasionally disagree when NSS is configured with extra sources. Install dnsutils (or bind-tools on Alpine) into the container image rather than exec-installing it every session, so the tooling is reproducible.

The one detail people consistently get wrong is confusing the Compose service name with the container name. Docker's embedded DNS resolves the service name (db), plus any explicit aliases you declare, and the container's own hostname — but not the auto-generated container name like myproject-db-1 unless it happens to coincide. If your connection string points at a container name or a stale IP, moving services onto a shared bridge will not help until the string itself references the service name the resolver actually knows about.

DNS-fix prerequisitesYou need DNS tools, the service names, and exec access to diagnose and fix resolution.SymptomENOTFOUNDCheck networksame bridge?Fixshared user bridgeVerifydig db

Step-by-Step Implementation

  1. Confirm it's resolution, not connectivity, from the app container.
dig +short db || getent hosts db   # empty output = resolution failure

This first command separates a naming problem from every other kind of failure before you touch a single Compose file. dig +short db asks the embedded resolver for an address and prints nothing on failure; the || fallback runs getent hosts db only if dig is missing or returns non-zero, so you get an answer even in a minimal image. Empty output — no A record, no host line — means the name did not resolve, which points squarely at network topology. If instead you get an address back, DNS is working and the real fault is connectivity or the peer process. Running this from inside the app container, not the host, is what makes the result trustworthy: it reflects exactly what the application runtime experiences.

  1. Check the networks the services are actually on.
docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}} {{end}}' <app_container>

The docker inspect template iterates .NetworkSettings.Networks and prints the name of every network the container is attached to, which is the single most decisive piece of evidence in this diagnosis. Run it for both the app and the database container and compare: if they list different network names, or if one shows bridge (the default bridge, where name DNS is deliberately absent), you have found the fault. The Go template form avoids the noise of full JSON so you can eyeball the network set at a glance. A common surprise is a container attached to two networks where only one is shared with the peer; the embedded resolver works only for peers on a common network, so a partial overlap is enough to break resolution in one direction.

  1. Put both services on one user-defined bridge so embedded DNS resolves names.
services:
  app:
    networks: [devnet]
  db:
    networks: [devnet]
networks:
  devnet:
    driver: bridge

Declaring a top-level devnet network with driver: bridge and attaching both app and db to it forces Compose to create a user-defined bridge and put both services on it, which is precisely the condition the embedded resolver needs. Naming the network explicitly, rather than leaning on Compose's implicit default network, prevents the subtle case where different Compose invocations or an overridden network_mode scatter services onto separate bridges. The driver: bridge line is the default and could be omitted, but stating it documents intent and guards against a future edit accidentally switching the network to host or none, both of which strip the per-network DNS. After applying this, recreate the services with docker compose up -d — editing the YAML alone changes nothing until the containers actually join the new network.

  1. Verify the name now resolves and connects.
getent hosts db && curl -s db:5432 >/dev/null && echo "db reachable"

This verification chains two independent checks so a pass proves both halves of the path. getent hosts db confirms the name now resolves to an address through the same NSS path the app uses; only if that succeeds does the && allow curl -s db:5432 to test that a TCP connection to the resolved address actually opens. Printing db reachable only when both steps pass gives you a scriptable signal you can drop into a smoke test or a devcontainer postCreateCommand. Note that curl against a raw Postgres port will not speak the protocol, but the connection either opens or is refused — the >/dev/null discards the body so you are testing reachability, not a valid HTTP exchange. If getent resolves but curl refuses, you have proven DNS is fixed and moved the investigation to connectivity or the database's own readiness.

DNS fix pathConfirm resolution failed, check networks, share a bridge, then verify.ENOTFOUNDname did not resolveNetwork checkwhich bridge each service is onShared user bridgeembedded DNS resolves namesVerifieddig + connect succeed

Common Pitfalls

Compose DNS failures are almost always a network-topology problem, not a DNS-server problem.

The most expensive trap is the stale-IP failure that hides behind a healthy-looking start-up. An application that resolves db once, caches the returned address in a connection pool, and reuses it survives fine until Compose recreates the database container — a rebuild, a docker compose up after an image change, or an OOM restart — at which point the peer comes back with a new address on the bridge and every cached connection points at nothing. The symptom is insidious because dig db from a shell resolves correctly while the long-lived process keeps dialing the dead IP. The remedy is to let the client re-resolve on reconnect and never pin an IP in configuration; on a user-defined bridge the embedded resolver always returns the current container, so resolving by name on each new connection is what keeps the link durable across recreates.

A second, quieter pitfall is an override file that sets network_mode: host or joins a container to another container's namespace, which quietly removes that service from devnet. No amount of correct top-level network declaration will reunite the two until the override is dropped, so when one service refuses to resolve a peer that everyone else can reach, check the merged Compose config for a stray network_mode before touching anything else.

DNS triageA triage path from a name-resolution failure to reliable service-name DNS.Does dig db return an address?NOServices not on a shared user bridgeAre both on the same user-definednetwork?YESUse the service name, not an IPNames resolve reliably

SymptomRoot CauseRemediation
getaddrinfo ENOTFOUND dbDefault bridge has no name DNSPut services on a shared user bridge
Resolves then breaks after recreateApp cached a container IPAddress the service by name
External names fail tooBroken upstream DNS forwardingFix host resolver or set --dns
Works in one compose, not anotherDifferent network names/scopesUse one explicit shared network

Conclusion

Service-name DNS in Compose only works on a user-defined bridge, so the fix for ENOTFOUND is almost always topological: put the services on one shared bridge and address peers by name, never by a cached IP. Verify with dig/getent before blaming the application.

The strategic payoff is that once the shared devnet bridge is declared explicitly and every service addresses its peers by name, the environment stops depending on incidental Compose defaults and becomes something a teammate can clone and run without inheriting a networking mystery. That is the same reproducibility discipline that underlies pinning image tags and caching dependencies: you remove a source of drift by making the contract explicit in the file rather than leaving it to whatever bridge Docker happened to create. A named network in the Compose file is a pinned assumption, and a service name in a connection string is a stable handle that survives every container recreate — both are the networking equivalent of a locked dependency.

Tied back to the broader theme, DNS reliability is what lets the rest of the pin-and-cache workflow pay off. A perfectly pinned Postgres image is worth little if the app cannot find it by name after a rebuild. By making name resolution deterministic — shared bridge, service names, no hardcoded addresses — you give the devcontainer a stable substrate on which the reproducible pieces can actually connect, so the environment behaves the same on the tenth docker compose up as it did on the first.

Resolution do and don'tName resolution needs a shared user bridge; never depend on the default bridge or IPs.Resolution needsUser-defined bridgeCorrect service nameSame networkNever rely onDefault bridge DNSHardcoded IPsStart-order luck

FAQ

Why does the default bridge not resolve service names? Docker's automatic name-based DNS only runs on user-defined networks. The legacy default bridge deliberately omits it, so containers there can't resolve each other by name. Compose creates a user-defined network by default, but a misconfiguration or an explicit default-bridge attachment reintroduces the problem. Historically the default bridge only supported the deprecated --link flag for cross-container name access, which Compose no longer uses, so a container that ends up on bridge has no naming mechanism at all. Declaring your own network sidesteps the entire legacy path and gives you the embedded resolver at 127.0.0.11 for free.

It resolves at first, then fails after a while — why? The application probably cached the resolved IP, and the target container was recreated with a new address. Address services by their name every time so Docker's DNS resolves the current container, rather than caching an IP that goes stale on recreate. Connection pools are the usual culprit because they hold sockets open and only re-resolve when a connection is re-established, so the fix is often to enable a pool's reconnect-on-failure behavior rather than to change anything about Docker. If a full application restart is the only thing that recovers the link, in-process DNS caching or a pinned address in configuration is almost certainly to blame.

How do I prove it's DNS and not the database being down? Run dig +short db or getent hosts db from the app container. Empty output means the name didn't resolve — a networking problem. If it resolves but curl db:5432 refuses, the name is fine and the issue is connectivity or the service itself. Keep the two questions strictly separate: resolution answers "where is db?" and connectivity answers "will db accept a socket?", and conflating them is what sends people restarting a perfectly healthy database. Running both checks from inside the app container, in that order, gives you a clean boundary between a naming fault you fix with topology and a service fault you fix elsewhere.

Do I need to set a custom --dns or edit resolv.conf to fix this? Almost never for service-name resolution. Inside a user-defined network every container's /etc/resolv.conf already points at Docker's embedded resolver, which handles Compose service names automatically. Custom --dns entries and manual resolv.conf edits are for external name resolution, and touching them to fix an internal ENOTFOUND usually just adds a second problem. Fix the network topology first and leave the resolver configuration alone unless external lookups are also failing.

Can containers on two different user-defined networks resolve each other? No, not by default. The embedded resolver only answers for peers that share at least one common network, so two services on separate bridges are invisible to each other by name even though both networks have working DNS. The fix is either to attach both services to one shared network, or to add the second network to the container that needs cross-network reach so the two overlap on a bridge the resolver can see.

Does depends_on guarantee the name will resolve at startup? No. depends_on controls start order, not readiness, and does nothing for DNS specifically — the name resolves as soon as the target container exists on the shared network. What it cannot promise is that the service inside that container is accepting connections yet, which is why a name can resolve while curl still refuses. Use depends_on: condition: service_healthy when you need the peer truly ready, and keep DNS reliability a separate concern solved by the shared bridge.