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.

Embedded resolver versus dedicated CoreDNS resolver Comparison of the default embedded resolver against a committed CoreDNS resolver across three behaviours. Default Resolver vs CoreDNS Embedded 127.0.0.11 container names only custom TLD to NXDOMAIN forwards to host DNS non-deterministic CoreDNS 172.20.0.2 static host zone custom FQDN resolves fallthrough to embedded committed and reproducible
The embedded resolver forwards unknown names upstream; a committed CoreDNS resolver answers them locally.

Resolution

  1. Standardize on a reserved, non-routable suffix (.svc.cluster.local or a project-specific .docker.internal) across all Compose files to avoid mDNS/forwarder collisions. Pick one suffix and enforce it everywhere; mixing .internal and .local guarantees that at least one will collide on someone's machine. A suffix that no public registry will ever delegate is the only safe class.

  2. Add a CoreDNS resolver to the Compose network. Create a Corefile that answers your service zone from a static hosts block 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 fallthrough directive is the load-bearing line: without it, CoreDNS answers NXDOMAIN for any name absent from the hosts block instead of passing the query to the next plugin. With it, an unmatched name flows to forward . 127.0.0.11, preserving Docker's native container-name discovery.

  3. Declare the resolver as a service with a fixed address and point the network at it. A static ipv4_address is required so the resolver's IP is knowable before it starts, which is what lets you reference it in the dns: 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/16
  4. Override 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.11
  5. Validate 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.

Resolution query path through CoreDNS Left to right flow of a lookup from the consumer through CoreDNS to a matched host or the embedded fallback. Lookup Query Path Consumer dns: 172.20.0.2 CoreDNS hosts + fallthrough Static hosts custom FQDN 127.0.0.11 container names Matched names hit the static zone; the rest fall through.
A consumer queries CoreDNS first; matched FQDNs use the static zone and everything else falls through to the embedded resolver.

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.

Custom FQDN resolution success by resolver setup Bar chart comparing the percentage of custom FQDN lookups that resolve under three resolver configurations. Custom FQDN Lookups Resolved (%) embedded only 0% host /etc/hosts 0% CoreDNS zone 100%
Only the committed CoreDNS zone resolves custom FQDNs; host-file edits never reach the container namespace.

Prevention

  • Commit the Corefile and the dns: overrides so resolution is reproducible on every clone. Treat the Corefile as 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 Makefile or bootstrap that fails fast when a service FQDN returns NXDOMAIN before 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 --quiet to catch malformed dns: directives before merge. A stray tab in the Corefile or a mistyped ipv4_address outside 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 hosts block aligned with the ipam subnet. 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.

Choosing a safe service suffix Decision path for selecting a non-colliding DNS suffix for local service routing. Pick a Service Suffix Does it match k8s? mirror the namespace Yes use .svc.cluster.local No use .docker.internal
Mirror the namespace suffix when you want staging parity; otherwise pick a reserved project suffix that no forwarder claims.

macOS (Docker Desktop): .local is claimed by Bonjour; never use it for service routing. The injected host.docker.internal can mask a broken internal resolver — test with explicit FQDNs. WSL2: The localhost resolver bypasses Docker's embedded DNS; use service names or the CoreDNS address inside the distro, and ensure the docker CLI runs natively in WSL2. Apple Silicon (ARM64): x86-only tooling running under Rosetta 2 can hit glibc resolver quirks; pin the CoreDNS image to an arm64 manifest.

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.