Configuring Local DNS for Microservice Routing
Microservices that call each other by custom FQDN (api-gateway.internal) fail with Could not resolve host because Docker's embedded resolver only knows container names on the active network. This page configures a deterministic local resolver that mirrors Kubernetes service discovery; it extends local network and port mapping within the broader containerized local environment patterns.
The goal is parity: a service address that resolves the same way on a laptop, in CI, and in a staging cluster, so integration code never carries a "works locally, breaks in the pipeline" branch. Getting there means understanding exactly what the built-in resolver does and does not do, then inserting a small, version-pinned resolver that you control and commit alongside the Compose files.
Diagnostic
A frontend or API consumer reaching a downstream service fails at name resolution, not connection refusal. The distinction matters: a refused connection means DNS worked and the target port was closed, while a resolution failure means the name never became an IP address at all. Reproduce the failure from inside the calling container so you observe the container's resolver, not the host's:
#!/usr/bin/env bash
set -euo pipefail
docker compose exec frontend curl -sI http://api-gateway.internal:8080/health \
|| echo "DNS resolution failed"
Expected BAD output:
curl: (6) Could not resolve host: api-gateway.internal
DNS resolution failed
The (6) exit family in curl is specifically "couldn't resolve host", separating this from (7) "failed to connect". Confirm the network is running on the default embedded resolver with no overrides:
#!/usr/bin/env bash
set -euo pipefail
docker network inspect myapp_default --format '{{json .Options}}' | jq '.dns'
null
A null result confirms the network relies on the default 127.0.0.11 resolver, which does not handle custom TLDs. Cross-check the resolver a container actually consults by reading its generated resolv.conf; Docker rewrites this file at container start and it is the single source of truth for name resolution order inside the namespace:
#!/usr/bin/env bash
set -euo pipefail
docker compose exec frontend cat /etc/resolv.conf
nameserver 127.0.0.11
options ndots:0
If the only nameserver line is 127.0.0.11, every custom FQDN that is not a container name or network alias will return NXDOMAIN. That is the symptom this page removes.
Root Cause
Docker's embedded DNS at 127.0.0.11 resolves only container names and aliases defined within the active Compose network — it does not handle arbitrary FQDNs, custom TLDs, or external routing. The embedded resolver is intentionally minimal: it answers A and AAAA queries for names it knows from the network's service registry and forwards everything else to the host's upstream resolvers. It has no zone file, no static host mapping you can extend, and no notion of a project-specific suffix.
Host /etc/hosts entries are isolated from container namespaces and never propagate, so the reflex of editing the host file has no effect inside a container. Worse, .local is intercepted by mDNS/Bonjour on macOS and many Linux distributions, and corporate forwarders frequently hijack .internal, so the same name resolves differently on every developer's machine. A name that returns NXDOMAIN on one laptop may return a corporate wildcard IP on another, producing failures that are impossible to reproduce from a bug report.
The result is non-determinism at the exact layer your service mesh depends on. Because the embedded resolver forwards unknown names upstream, the behaviour of payment.internal is decided by whatever DNS the developer's corporate VPN, home router, or ISP happens to run — none of which you control or can pin in version control. The fix is to stop forwarding those names upstream and answer them locally from a resolver you ship inside the Compose project.
Resolution
Standardize on a reserved, non-routable suffix (
.svc.cluster.localor a project-specific.docker.internal) across all Compose files to avoid mDNS/forwarder collisions. Pick one suffix and enforce it everywhere; mixing.internaland.localguarantees that at least one will collide on someone's machine. A suffix that no public registry will ever delegate is the only safe class.Add a CoreDNS resolver to the Compose network. Create a
Corefilethat answers your service zone from a statichostsblock and forwards everything else back to the embedded resolver so container-name lookups still work:.:53 { hosts { 172.20.0.3 auth-service.internal 172.20.0.4 payment-worker.internal 172.20.0.5 api-gateway.internal fallthrough } forward . 127.0.0.11 log }The
fallthroughdirective is the load-bearing line: without it, CoreDNS answersNXDOMAINfor any name absent from thehostsblock instead of passing the query to the next plugin. With it, an unmatched name flows toforward . 127.0.0.11, preserving Docker's native container-name discovery.Declare the resolver as a service with a fixed address and point the network at it. A static
ipv4_addressis required so the resolver's IP is knowable before it starts, which is what lets you reference it in thedns:overrides below:# docker-compose.yml services: coredns: image: coredns/coredns:1.11.3 command: ["-conf", "/etc/coredns/Corefile"] volumes: - ./Corefile:/etc/coredns/Corefile:ro networks: app_net: ipv4_address: 172.20.0.2 networks: app_net: driver: bridge ipam: config: - subnet: 172.20.0.0/16Override DNS for consumers so they query CoreDNS first, then fall back to the embedded resolver. Listing both means a CoreDNS restart never fully blackholes name resolution; the second entry keeps container names working during the gap:
# docker-compose.yml services: api-gateway: dns: - 172.20.0.2 - 127.0.0.11Validate propagation from inside a consumer container, which exercises the exact resolver chain the application code will use:
#!/usr/bin/env bash set -euo pipefail docker compose exec api-gateway getent hosts auth-service.internal
For bridge isolation and ensuring UDP/53 is not blocked by iptables DROP rules, cross-check the bridge network configuration. A resolver that starts cleanly but never receives queries is almost always a firewall or subnet-mismatch problem, not a Corefile problem.
Expected Output
After applying the resolver, custom-TLD names resolve deterministically and return the IP you declared in the Corefile:
172.20.0.3 auth-service.internal
A loop across services confirms parity. Running it as the final step of your bootstrap turns a silent resolution regression into a hard, early failure instead of a confusing integration-test timeout later:
#!/usr/bin/env bash
set -euo pipefail
for svc in api-gateway auth-service payment-worker; do
if ! docker compose exec -T "$svc" getent hosts "${svc}.internal" >/dev/null 2>&1; then
echo "FAIL: ${svc}.internal did not resolve" >&2
exit 1
fi
done
echo "DNS parity verified across all microservices"
The -T flag disables pseudo-TTY allocation, which is required when the check runs in CI where no terminal is attached; omitting it produces a the input device is not a TTY error that masks the real result. To confirm the resolver is being exercised rather than a stale cache, tail the CoreDNS logs while the loop runs — the log plugin prints one line per query, so you should see three NOERROR responses appear in real time:
#!/usr/bin/env bash
set -euo pipefail
docker compose logs -f coredns &
docker compose exec -T api-gateway getent hosts payment-worker.internal
The following figure quantifies why this matters: with the embedded resolver alone, custom-FQDN queries fail outright, while the CoreDNS zone answers them without adding measurable latency to the container-name lookups that already worked.
Prevention
- Commit the
Corefileand thedns:overrides so resolution is reproducible on every clone. Treat theCorefileas source: a change to a service IP is a code review, not a tribal-knowledge patch applied by hand on one machine. - Add a pre-flight DNS check to your
Makefileor bootstrap that fails fast when a service FQDN returnsNXDOMAINbefore integration tests run. Failing at bootstrap costs seconds; failing three minutes into a test suite costs the whole run and an ambiguous stack trace. - Validate the Compose schema in CI with
docker compose config --quietto catch malformeddns:directives before merge. A stray tab in theCorefileor a mistypedipv4_addressoutside the declared subnet will start CoreDNS but leave it unreachable, and the schema check surfaces the structural class of those errors early. - Keep the static IPs in the
hostsblock aligned with theipamsubnet. If you widen or renumber the subnet, the zone entries must move with it, so co-locate both in the same file and review them together.
The decision of which suffix to standardise on is worth making once and enforcing, because reversing it later means editing every service definition. The tree below captures the safe path.
macOS (Docker Desktop):
.localis claimed by Bonjour; never use it for service routing. The injectedhost.docker.internalcan mask a broken internal resolver — test with explicit FQDNs. WSL2: Thelocalhostresolver bypasses Docker's embedded DNS; use service names or the CoreDNS address inside the distro, and ensure thedockerCLI runs natively in WSL2. Apple Silicon (ARM64): x86-only tooling running under Rosetta 2 can hit glibc resolver quirks; pin the CoreDNS image to anarm64manifest.
Rollback
If the resolver misbehaves and you need the plain embedded setup back, tear down cleanly, strip the additions, and bring the stack back up. Removing the coredns service and the dns: blocks restores the exact default resolution behaviour with no residual state:
#!/usr/bin/env bash
set -euo pipefail
docker compose down
# Remove the coredns service and dns: blocks from docker-compose.yml, then:
docker compose up -d --wait
Because the resolver is a stateless container backed only by a read-only Corefile, there is no volume to prune and no data to lose — the rollback is purely a Compose-file edit followed by a recreate. If you would rather keep the resolver running while disabling it for one service, remove only that service's dns: block and recreate that single container; the rest of the stack continues to use CoreDNS unaffected. This makes it safe to bisect a suspected resolver problem one service at a time rather than tearing down the whole project on every hypothesis.
Frequently Asked Questions
Why not just add entries to the host /etc/hosts file?
Because host file entries live in the host's network namespace and never propagate into a container's namespace. Docker generates a fresh /etc/resolv.conf and /etc/hosts for each container at start, so a name you add on the host is invisible to the resolver a container actually consults. The CoreDNS approach places the mapping inside the network the containers share, which is the only place they can read it.
What does the fallthrough directive in the Corefile do?
fallthrough tells the hosts plugin to pass a query to the next plugin when the name is not in its static block, instead of answering NXDOMAIN. Without it, CoreDNS would claim authority over every name and any lookup absent from your hosts list — including ordinary container names — would fail. With it, unmatched names flow to forward . 127.0.0.11 and Docker's native discovery keeps working.
Do I need CoreDNS, or can I use the Compose aliases key instead?
Network aliases give a container extra names on its own network, but they are still handled by the embedded resolver and are limited to simple labels, not arbitrary FQDNs with custom TLDs or a shape that mirrors a Kubernetes zone. Use aliases for a second short name on the same network; use CoreDNS when you need a stable, committed zone that matches production naming and survives across every developer's machine.
Will pointing dns: at CoreDNS break external name resolution?
No, as long as the Corefile includes forward . 127.0.0.11 and the consumer lists 127.0.0.11 as a secondary in its dns: block. External names such as a package registry are not in the static hosts zone, so they fall through and are forwarded to the embedded resolver, which in turn forwards them to the host's upstream DNS. Only the names you explicitly declare are answered locally.