Debugging Network & DNS Issues in Containers
When a service is "unreachable" inside a devcontainer, the cause is almost always one of two things: a name that will not resolve, or a route that will not connect. This guide is a systematic teardown for both, aimed at engineers who need to stop guessing and start isolating. It complements the orchestration guidance in Docker Compose integration by giving you the diagnostic ladder for when the topology should work but doesn't.
Networking is the area where developers most often abandon systematic debugging and start changing things at random, because the symptoms are opaque and the layers are invisible. A connection times out, and the instinct is to restart the database, tweak a connection string, add a retry, bump a timeout — a scattershot of changes that occasionally stumbles onto the fix and usually just adds confusion. The premise of this guide is that container networking is not actually mysterious; it has a small number of layers, each of which can be tested with a single command, and the failures fall into a handful of well-understood categories. Replace the guessing with a fixed diagnostic sequence and an "unreachable service" becomes a two-minute, repeatable diagnosis rather than an afternoon of thrashing.
The single most important habit is to separate resolution from routing. dig db failing and curl db:5432 failing look identical from the application's stack trace, but they have completely different fixes. Establish which layer is broken first, and the remediation follows directly.
The reason a stack trace is so unhelpful here is that it reports the symptom — "could not connect to db" — while hiding the layer. A connection failure at the application level could originate from a name that never resolved, a route that was refused, a service that was not ready, or a genuine application misconfiguration, and the trace looks nearly identical in every case. Developers who treat the trace as the diagnosis end up guessing, changing the application config, restarting services, and generally thrashing. Developers who treat the trace as merely the starting point and then run two commands — one for resolution, one for routing — locate the actual layer in under a minute. The whole discipline of this guide is refusing to let the application's error message dictate where you look; you decide where to look by testing the layers from the bottom up.
Prerequisites
You need DNS and HTTP diagnostic tools available inside the container (dig or nslookup, plus curl), a mental map of which networks your services sit on, the ability to docker exec or devcontainer exec into the relevant containers, and a baseline of the hostnames you expect to resolve.
The single most important preparation is not a tool but a discipline: commit to diagnosing from inside the container, using the same names and ports the application uses, rather than from the host. A network problem looks completely different from the two vantage points — a host can reach a published port that a peer container cannot resolve by name, and vice versa — so debugging from the host tells you little about why the application, which runs inside the container, cannot connect. Every diagnostic in this guide is run with devcontainer exec or docker exec into the affected container precisely because that is the only vantage point that sees what the application sees. Install dig (or nslookup) and curl into your debug image so these checks are always available, because a container without diagnostic tools forces you to debug blind.
The other preparation is knowing what "correct" looks like before something breaks. Write down the service names you expect to resolve and the ports you expect to reach — usually the Compose service keys and their listening ports — so that when a lookup fails you can tell instantly whether the name is wrong or the resolution is broken. Debugging is far faster when you are comparing observed behaviour against a known baseline than when you are simultaneously trying to remember what the correct state was and figure out why the current state differs from it.
bind-tools/dnsutilsandcurlinstalled in the debug image.- A list of your networks (
docker network ls) and which services join each. - Exec access into both the client and the target container.
- The expected service names — usually the Compose service keys.
A quick way to build the network map is docker network ls to see the networks and docker network inspect <name> to see which containers are attached to each. Comparing that against your intent — "app, db, and cache should all be on devnet" — often reveals the problem before you run a single DNS query, because a container that is missing from the shared network, or attached to the default bridge instead, jumps out. Having this map in hand turns the resolution checks below from a blind search into a confirmation of what the topology already told you, and it is worth capturing before anything is broken so you have a baseline to compare against.
Architecture & Configuration Deep Dive
Understanding the resolution path makes most bugs obvious. Inside a container, a name lookup starts at /etc/resolv.conf, which on a user-defined network points at Docker's embedded DNS server at 127.0.0.11. That server resolves other service names on the same user-defined bridge network to their current container IPs, and forwards external names to the host's upstream resolvers. Crucially, the default bridge network does not provide name-based DNS between containers — only user-defined bridges do.
This single fact explains a large fraction of "it can't find the database" reports: the services are on the default bridge (or different networks) rather than a shared user-defined one, so there is no embedded DNS to resolve db. The fix is topological, not a DNS tweak. The Compose-specific manifestation and its resolution live in resolving DNS failures in Compose networks.
The distinction between the default bridge and a user-defined bridge is the single most important piece of knowledge for container networking, and it is worth stating flatly: the default bridge network does not provide name-based DNS between containers, while a user-defined bridge does. Two containers on the default bridge can reach each other only by IP address, which is fragile because IPs change on recreate. Two containers on a shared user-defined bridge can reach each other by name, resolved by Docker's embedded DNS to the current container. Compose creates a user-defined network by default, which is why most Compose stacks resolve names correctly out of the box — but a container attached to the default bridge, or two containers on different user-defined networks, will silently lack name resolution. When names do not resolve, the question is almost never "what is wrong with DNS?" and almost always "are these containers actually on the same user-defined network?"
Understanding why the embedded DNS behaves this way helps you trust the diagnosis. Docker runs a small DNS resolver at 127.0.0.11 inside each container on a user-defined network; that resolver knows the names of the other containers on the same network and forwards everything else to the host's upstream resolvers. So a name lookup for a peer service is answered locally by that embedded resolver, while a lookup for an external host is forwarded out. This is why a service name and an external domain can both work from the same container, and why a service name can fail while external names succeed — the two are resolved by different parts of the same resolver, and only the peer-name part depends on the shared user-defined network.
Step-by-Step Implementation
Debug by climbing an isolation ladder, one rung at a time, from inside the client container. Resolve the name first; only if that succeeds do you test connectivity; only if that succeeds do you suspect the application. The word "inside" is load-bearing: every one of these commands is run with docker exec or devcontainer exec into the container that is failing to connect, not from the host, because only from inside the container do you see the same network the application sees. A test from the host answers a different question and can mislead you into thinking the network is fine when the container's view of it is broken.
# From inside the workspace container:
# 1. Name resolution — does the service name map to an IP?
dig +short db # or: nslookup db
# 2. Routing — can you open a TCP connection to the port?
curl -sv telnet://db:5432 2>&1 | head -n 5
# 3. Which networks am I actually on?
cat /etc/resolv.conf
getent hosts db
If step 1 returns nothing, it is a resolution problem (wrong network or wrong name). If step 1 resolves but step 2 refuses, it is a routing problem (port not listening, service unhealthy, or firewall). This ordering turns a vague failure into a specific one.
The reason to climb the ladder in strict order — resolution before routing before application — is that each rung's answer determines whether the next rung is even worth checking. There is no point investigating why a connection is refused if the name does not resolve, because until the name resolves there is no address to connect to. Skipping straight to "the database must be down" when the real problem is that the name never resolved is the most common way network debugging goes in circles. The ladder is a forcing function: it makes you establish, at each level, whether the layer works before you suspect the layer above it, so you never chase an application bug that is really a topology bug in disguise.
Each rung also has a canonical command that gives an unambiguous answer, which keeps the diagnosis objective. dig +short <name> or getent hosts <name> answers "does the name resolve?" — a non-empty result means yes. curl -sv telnet://<name>:<port> answers "can I open a connection?" — a successful handshake means yes. And running your application's own connection logic answers "does the application connect?" Because each command targets exactly one layer, the answers compose into a precise location for the fault: name resolves but connection refused points squarely at the target service or a firewall, not at DNS and not at your application's configuration.
Performance & Resource Optimization
Networking bugs are often intermittent, and intermittency is a performance-and-reliability problem. A service addressed by a stale IP works until the container is recreated and the IP changes; a race where the app connects before the database's health check passes fails only under load. The durable fix for both is to address services by name on a user-defined bridge (so recreation is transparent) and to gate startup on health checks rather than sleeps.
Framing this under "performance" is deliberate, because the same design choices that make networking fast also make it reliable. Name-based addressing on a user-defined network is both faster to reason about and immune to the recreate-and-break failure that a cached IP suffers. Health-gated startup both avoids the wasted time of a fixed sleep and eliminates the race that the sleep was masking. There is no trade-off here between a fast setup and a correct one — the correct design is also the efficient one, which is why "address by name, gate on health" is the recommendation regardless of whether your immediate concern is speed or flakiness.
It is worth noting that healthy container networking scales without special effort once the fundamentals are right. Adding a service to a stack that already uses a shared user-defined bridge and health-gated startup is just one more service on the same network with its own health check — it resolves by name and orders correctly like the others, with no per-service networking tuning. The effort is entirely front-loaded into getting the topology and health model right once; after that, the network is not something you think about until a specific symptom sends you back to the resolution-versus-routing ladder.
The chart makes the categorical point: on the default bridge there is no name DNS at all, while a user-defined bridge resolves service names reliably and forwards external names through the host. If you find yourself adding sleep 10 to "fix" a network flake, replace it with a real readiness check instead — the intermittency is a symptom of missing synchronization, not slow DNS.
Intermittent network failures are the hardest to diagnose precisely because they are not really network failures — they are timing or lifecycle failures wearing a network costume. The two classic causes are a stale IP and a startup race. A stale IP happens when the application resolves a peer's address once and caches it; the peer is later recreated with a new address, and the cached IP now points at nothing, so the connection fails until the app restarts. A startup race happens when the app tries to connect the instant it starts, before its dependency is ready to accept connections; on a fast machine the dependency wins the race and it works, on a slow machine or under load it loses and the connection fails. Both masquerade as flaky networking, and both have non-network fixes: address services by name so recreation is transparent, and gate startup on health checks so readiness is guaranteed rather than raced.
This is why reaching for sleep to fix an intermittent connection failure is a trap. A sleep appears to work because it happens to give the dependency enough time on the machine you tested, but it is tuned to that machine's timing and will fail on a slower one, or when the dependency takes longer than usual. Worse, it slows every startup unconditionally to paper over a race that a real readiness check would eliminate. The durable fix is always a synchronization primitive — a health check with depends_on: condition: service_healthy, or an application-level retry-until-ready — not a fixed delay that is right on exactly one machine at exactly one moment.
Validation & Testing
Prove the fix along the same ladder you debugged it: name resolves, TCP connects, the application handshake succeeds, and — because these bugs love to be intermittent — the sequence is stable across repeated CI runs. Running the checks in a loop in CI catches the flake that a single manual run hides.
The stability check is what distinguishes a real fix from a lucky one. A network problem that reproduces intermittently can appear "fixed" after a single successful run when nothing was actually addressed — you just happened to win the race that time. Running the resolve-and-connect check twenty times in a loop, and requiring all twenty to pass, forces the flake to reveal itself: if the underlying stale-IP or startup-race problem remains, some iterations will fail. Building that loop into CI means a regression that reintroduces the intermittency is caught by a red pipeline rather than by a developer hitting a mysterious, unreproducible failure days later. For inherently intermittent bugs, proving stability across many runs is the only honest verification.
# Prove stability, not just a single success
for i in $(seq 1 20); do getent hosts db >/dev/null && curl -s db:5432 >/dev/null \
&& echo "ok $i" || echo "FAIL $i"; done
Common Pitfalls
The failures below are either resolution or routing — the triage below tells you which before you touch anything.
Notice how cleanly the table sorts into the two families. The top rows — a name that returns ENOTFOUND, a connection that breaks after recreate, external names failing — are all resolution problems, fixed by topology (a shared user-defined bridge) or by addressing services by name. The lower rows — a resolved name that refuses the connection, intermittent failures — are routing or readiness problems, fixed at the target service or with a health gate. Because the two families have disjoint fixes, correctly classifying a failure is most of the work; the triage graph above is simply the two-question test (does the name resolve? does the port connect?) that performs the classification. Run those two questions before changing anything, and you will never again apply a routing fix to a resolution problem or vice versa.
| Symptom | Root Cause | Remediation |
|---|---|---|
getaddrinfo ENOTFOUND db | Services on the default bridge, no name DNS | Move them to a shared user-defined bridge network |
| Works, then breaks after recreate | App pinned a container IP that changed | Address the service by name, not IP |
| Resolves but connection refused | Target port not listening or service unhealthy | Verify the port and gate on a health check |
| External names fail inside container | Broken upstream DNS forwarding | Fix the host resolver or set --dns explicitly |
| Intermittent connect failures | App starts before dependency is ready | Replace sleeps with depends_on: service_healthy |
Conclusion
Container networking failures come in two families — resolution and routing — and the entire discipline is refusing to conflate them. Resolve the name first; if that fails, fix the topology (a user-defined bridge, the right service name). Only once the name resolves do you test the route, and only once the route connects do you suspect the application. That ladder turns an "unreachable service" from a mystery into a two-command diagnosis.
The single fact that resolves the most cases is that name-based DNS between containers requires a shared user-defined bridge network. Internalize that, and "it can't find the database" stops being mysterious: the services are not on the same user-defined network, so there is no embedded DNS to resolve the name, and the fix is topological rather than a DNS setting. Compose gives you a user-defined network by default, so most stacks work; the failures happen when something lands a container on the default bridge or splits services across networks. Check the topology before you touch anything else.
Finally, resist the sleep. Intermittent connection failures are almost always a stale IP or a startup race, not slow DNS, and both have real fixes — address services by name so recreation is transparent, and gate startup on health checks so readiness is guaranteed. A fixed delay is tuned to one machine's timing and will betray you on another; a synchronization primitive is correct everywhere. When a network problem is intermittent, treat the intermittency itself as the clue: it points at timing and lifecycle, not at the network being fundamentally broken, and the durable fix lives in how you address services and gate their startup.
FAQ
Why can't my containers find each other by name? Because they are on Docker's default bridge network, which does not provide name-based DNS between containers, or because they are on different networks entirely. Only a user-defined bridge network gives you Docker's embedded DNS that resolves service names. Put every service that must talk on one shared user-defined bridge. Compose creates such a network by default, so if names are not resolving in a Compose stack, check whether a service was explicitly attached to the default bridge or split onto a separate network — the default behaviour is correct, and a name-resolution failure usually means something overrode it.
How do I tell whether an external domain or an internal service name is the problem?
Test them separately, because they are resolved by different parts of the same resolver. An internal service name is answered by Docker's embedded DNS from the shared user-defined network; an external domain is forwarded to the host's upstream resolvers. So a service name can fail while google.com resolves fine (the shared network is misconfigured), or every external name can fail while service names work (upstream forwarding is broken). Run dig db and dig example.com from inside the container and compare — the pattern of which succeeds tells you whether to fix the container network or the host's DNS forwarding.
Why does my connection work and then start failing intermittently?
Two causes dominate, and neither is really a network fault. A stale IP: the application resolved a peer's address once, the peer was recreated with a new address, and the cached IP no longer points anywhere — fixed by addressing services by name so every lookup resolves the current container. Or a startup race: the app connects before its dependency is ready, which wins on a fast machine and loses on a slow one — fixed by gating startup on a health check. Treat the intermittency as a clue that the problem is timing or lifecycle, not connectivity, and reach for name-based addressing and health gates rather than a sleep.
How do I tell a DNS problem from a connectivity problem?
Run dig <name> first. If it returns no address, it is DNS — check the network and the name. If it resolves but curl <name>:<port> refuses, it is routing — check that the port is listening, the service is healthy, and no firewall blocks it. The two have different fixes, so always establish which layer failed.
Why is the failure intermittent? Intermittency usually means either a stale IP (the app cached an address that changed when a container was recreated) or a startup race (the app connected before the dependency was ready). Address services by name to survive recreation, and gate startup on health checks instead of fixed sleeps.
Related
- DevContainer Architecture & Core Tooling — the parent guide framing network topology within the pipeline.
- Resolving DNS Failures in Compose Networks — the Compose-specific manifestation and fix.
- Docker Compose Integration for Multi-Service Apps — declaring the bridge network and health checks that prevent these failures.
- Understanding the DevContainer Specification — where port and network properties live in the schema.
- devcontainer.json Property Reference — the
forwardPortsand network-related keys in detail.