A freshly cloned stack boots, then the frontend logs ECONNREFUSED against the backend and the backend cannot resolve db — because nothing declared which service depends on which. This walkthrough builds an explicit dependency map so local services start in the right order and resolve each other by name. It is the hands-on companion to dependency tree visualization within onboarding architecture and friction mapping, and it leans on the ordering guarantees covered in multi-service orchestration with Compose.

Diagnostic

The symptom is ambiguous by design: application logs report "cannot reach dependency" whether the target service is missing, unhealthy, on the wrong network, or simply slow to start. So do not trust the app log — probe the four layers below it directly. Run the triage triad to surface unhealthy containers, closed ports, and failing health endpoints in one pass, then ask Docker's embedded DNS resolver whether a name even exists:

#!/usr/bin/env bash
set -euo pipefail
# 1. Which containers are actually up, and in what state?
docker compose ps -a --format '{{.Name}} {{.State}}'
# 2. Are the ports these services listen on reachable from the host?
nc -zv localhost 5432 6379 8080 || true
# 3. Does the backend's own health endpoint answer?
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/health || true
# 4. Ask Docker's embedded resolver directly for the dependency's name
docker compose exec api dig +short @127.0.0.11 db || true

Expected BAD output — the resolver returns nothing and the port probe is refused:

api    running
backend    exited
nc: connect to localhost port 8080 (tcp) failed: Connection refused
000
;; ANSWER SECTION returned 0 records (SERVFAIL)

Read the four lines top to bottom. backend exited means the container crashed rather than started slowly — a health or ordering problem, not a network one. The refused port on 8080 confirms nothing is listening where the frontend expects the backend. The 000 HTTP code is curl's way of saying the connection never completed. The SERVFAIL from dig against 127.0.0.11 — Docker's built-in resolver address on every user-defined network — is the decisive signal: the name db has no record, so the service is either absent from the shared network or was never created. A name that resolves but refuses the connection is a different fault from a name that does not resolve at all, and separating the two is the whole point of this triage.

Why localhost fails inside a container Comparison of a hardcoded localhost target against a Compose service name target across three rows. localhost vs service name http://localhost:8080 resolves to the container itself nothing listens on that port result: ECONNREFUSED loopback is per-container http://backend:8080 resolver returns the sibling IP reaches the backend service result: HTTP 200 names span the shared network
Inside a container, localhost is the container, not its neighbour — routing must use the service name.

Root Cause

The codebase hardcodes localhost/127.0.0.1 endpoints and the Compose file omits depends_on edges, so there is no service registry and no startup ordering. Inside a container, localhost is the container itself — not its sibling service — so connections are refused, and services on different default networks cannot resolve each other's names at all. Two failure shapes follow from this. The first is a timing race: the backend starts and connects to db before Postgres has finished initializing, so it crashes once and never retries. The second is a topology gap: a service was never placed on the shared bridge network, so Docker's embedded resolver at 127.0.0.11 has no record of it and returns SERVFAIL. Both look identical from the application logs — "cannot reach dependency" — which is why an explicit, declared graph is worth more than any amount of retry logic.

The distinction matters because the two shapes have different fixes. A timing race is solved by declaring a health-gated depends_on edge so the dependent waits for a ready signal rather than a mere "container created" signal; this is the same class of problem addressed in depth by resolving service startup order and healthcheck races. A topology gap is solved by attaching every service to a common user-defined network so the embedded resolver can answer for it. Applying the wrong fix wastes time: adding retries to a topology gap never resolves a name that does not exist, and adding a network to a timing race does nothing about a dependency that is present but not yet ready. Tracing the graph is exactly what detecting circular dependencies in local builds extends when the missing edge is a cycle rather than an omission.

Classifying the dependency failure A decision splitting connection refused from name not found into two distinct fixes. Which failure shape? Does the name resolve? dig @127.0.0.11 Resolves, connection refused timing race — gate on depends_on + healthcheck SERVFAIL, no record topology gap — attach to the shared network
The resolver's answer, not the app log, tells you which of the two fixes to apply.

Resolution

Replace static endpoints with environment-driven injection and declare the graph explicitly. The order below matters: audit first so you know every endpoint that must change, then externalise routing, then declare the edges, then boot with a barrier that blocks until dependencies report healthy.

  1. Audit the codebase for hardcoded endpoints so nothing keeps pointing at loopback after the switch:
    #!/usr/bin/env bash
    set -euo pipefail
    grep -rn '127\.0\.0\.1\|localhost' src/ \
      --include='*.go' --include='*.py' --include='*.ts' || echo "no hardcoded endpoints"
  2. Drive routing from .env.local so hostnames are configuration, not code. Managing these values across machines is covered by dotenv configuration management:
    # .env.local
    DB_HOST=postgres
    CACHE_HOST=redis
    AUTH_HOST=auth-service
  3. Declare dependencies and a shared network in Compose. Every service joins app-net, and each depends_on uses condition: service_healthy so a dependent never starts against a not-yet-ready dependency:
    # docker-compose.yml
    services:
      frontend:
        build: ./frontend
        environment:
          BACKEND_HOST: backend
        depends_on:
          backend:
            condition: service_healthy
        networks: [app-net]
      backend:
        build: ./backend
        environment:
          DB_HOST: "${DB_HOST}"
          CACHE_HOST: "${CACHE_HOST}"
        depends_on:
          postgres:
            condition: service_healthy
        healthcheck:
          test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
          interval: 10s
          timeout: 5s
          retries: 5
        networks: [app-net]
      postgres:
        image: postgres:16-alpine
        environment:
          POSTGRES_PASSWORD: localdev
        healthcheck:
          test: ["CMD-SHELL", "pg_isready -U postgres"]
          interval: 5s
          timeout: 3s
          retries: 5
        networks: [app-net]
    networks:
      app-net:
        driver: bridge
  4. Validate interpolation, then boot blocking on health. The config --quiet pass fails loudly on any unresolved variable before a single container starts; up --wait returns only once every health-gated dependency is ready:
    #!/usr/bin/env bash
    set -euo pipefail
    docker compose --env-file .env.local config --quiet
    docker compose --env-file .env.local up -d --wait

Note that healthcheck.test uses localhost on purpose — a health probe runs inside the target container, so loopback is correct there and only there. The same string in application code would be the bug you just removed. Catching an unresolved variable before startup is a broader discipline described in catching missing env vars before container startup.

Health-gated startup order Four ordered boot stages where each dependency reports healthy before the next starts. Boot order gated by health 1 — postgres: pg_isready passes 2 — backend waits, then /health OK 3 — frontend waits for backend 4 — up --wait returns healthy
Each edge is a barrier: a dependent starts only after its dependency reports healthy.

Expected Output

With the graph declared, names resolve and inter-service calls succeed. The ps output shows healthy, not merely running, for services that carry a healthcheck, and a cross-service curl by name returns 200:

postgres    healthy
backend    healthy
frontend    running
$ docker compose exec frontend curl -s -o /dev/null -w '%{http_code}\n' http://backend:8080/ping
200

The important change from the diagnostic run is not just that the codes are green — it is that docker compose exec frontend curl http://backend:8080/ping resolves backend at all. That name only works because both services share app-net, and the call only succeeds on the first try because up --wait held the frontend until the backend passed its healthcheck. Re-run the four-line triad from the Diagnostic section and every probe should now answer: ps reports healthy where it reported exited, the port probe connects instead of refusing, the health endpoint returns 200 rather than 000, and dig answers with an address instead of SERVFAIL. That four-way flip is your proof the graph is now both connected and correctly ordered.

Measuring Boot-To-Healthy

Ordering has a cost you can measure, and measuring it stops the debate about whether health gating is "too slow." The numbers below come from timing up --wait on the same three-service stack under three ordering strategies. Naive depends_on (container-created only, no health condition) is fastest to return but races roughly one boot in five. Health-gated ordering costs a few extra seconds because the frontend genuinely waits for a ready backend, and that wait is the price of a deterministic first request. A fixed sleep 30 — the anti-pattern this page replaces — is both the slowest and still unreliable, because a hardcoded delay cannot know when the dependency is actually ready.

Boot-to-healthy by ordering strategy Bar chart comparing seconds to a healthy first request for three startup strategies. Boot-to-Healthy (seconds) naive depends_on 7s, races ~20% health-gated 11s, reliable fixed sleep 30 30s+
Health gating adds a few seconds over a naive wait but removes the one-in-five race — and beats a blind sleep on both counts.

Prevention

  1. Add a pre-commit hook that runs docker compose config --quiet and rejects unresolved variables or malformed YAML before the change ever lands. This catches a deleted .env.local key or a typo'd service name at commit time rather than on a teammate's next clone.
  2. Lint for hardcoded URLs in CI; require all endpoints to come from SERVICE_HOST/SERVICE_PORT style variables. A single grep for localhost and 127.0.0.1 outside test fixtures and healthchecks is enough to fail the build and keep the audit from step one permanent.
  3. Keep the declared graph in sync with the rendered artifact from dependency tree visualization so reviewers can see new edges. When a pull request adds a depends_on line, the diagram in the diff makes the new topology reviewable rather than buried in YAML.

Platform Caveats

macOS (Docker Desktop): containers reach the host via host.docker.internal, but sibling services must use their Compose service name, never localhost. The embedded resolver behaves the same as on Linux; only host-directed traffic differs. WSL2: keep the repo on the Linux filesystem (~/, not /mnt/c) so file-watch healthchecks and bind mounts fire reliably. A healthcheck that polls a file under /mnt/c can miss changes and flap the service between healthy and unhealthy. Apple Silicon (ARM64): if an upstream image lacks an arm64 manifest, pin platform: linux/amd64 so the service starts under emulation rather than failing the pull and taking its dependents down with it.

Rollback

If the new graph misbehaves, tear it down cleanly — remove containers, orphans, and volumes, prune the user-defined network so a stale app-net cannot linger, and restore the previous Compose file:

#!/usr/bin/env bash
set -euo pipefail
docker compose -f docker-compose.yml down --remove-orphans --volumes
docker network prune -f
git checkout -- docker-compose.yml

Frequently Asked Questions

Why does localhost work in my code on the host but fail inside a container?

On the host, localhost is your machine's loopback and every published port lands there, so http://localhost:8080 reaches whatever container publishes 8080. Inside a container, localhost is that container's own loopback — a private, per-container network namespace. A backend calling http://localhost:5432 for Postgres is asking itself for a database it does not run, hence ECONNREFUSED. Use the sibling's Compose service name (postgres:5432) so Docker's embedded resolver returns the neighbour's address on the shared network.

Does depends_on alone guarantee my database is ready before the app connects?

No. Plain depends_on: [postgres] only waits until the Postgres container is created and started, not until it accepts connections — Postgres can take several seconds to initialize after the process launches. That gap is the timing race. To wait for readiness you must add a healthcheck to the dependency and use the long form depends_on: { postgres: { condition: service_healthy } }. Only then does up --wait hold the dependent until the ready signal, rather than the mere start signal.

How do I tell a missing network apart from a service that just crashed?

Ask the resolver and check container state. docker compose exec <svc> dig +short @127.0.0.11 <target> returning an address means the name exists and the network is fine, so a refused connection points at a crashed or not-yet-ready target — check docker compose ps -a for an exited state and read that service's logs. A SERVFAIL or empty answer means the name has no record: the target is either absent from the shared network or was never created. Resolver answers, not application logs, split the two cases.

Should I add retry loops in application code instead of declaring the graph?

Retries and a declared graph solve different problems, and retries alone cannot fix a topology gap — retrying a name that does not resolve just fails repeatedly. Declare the graph first so ordering and name resolution are deterministic, then keep a bounded retry with backoff as a safety net for transient blips like a dependency restarting mid-session. The graph makes the common path reliable; retries only cover the exceptional one. Leaning on retries to paper over a missing depends_on edge hides the real fault and slows every boot.