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.

Diagnostic

Run the triage triad to surface unhealthy containers, closed ports, and failing health endpoints in one pass:

#!/usr/bin/env bash
set -euo pipefail
docker compose ps -a --format '{{.Name}} {{.State}}'
nc -zv localhost 5432 6379 8080 || true
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/health || true
# Ask Docker's embedded resolver directly
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)

A SERVFAIL or empty answer confirms the service is not on a shared network, or its dependency never started.

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. Tracing that graph is exactly what detecting circular dependencies in local builds extends when the missing edge is a cycle rather than an omission.

Resolution

Replace static endpoints with environment-driven injection and declare the graph explicitly.

  1. Audit the codebase for hardcoded endpoints:
    #!/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:
    # .env.local
    DB_HOST=postgres
    CACHE_HOST=redis
    AUTH_HOST=auth-service
  3. Declare dependencies and a shared network in Compose:
    # 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:
    #!/usr/bin/env bash
    set -euo pipefail
    docker compose --env-file .env.local config --quiet
    docker compose --env-file .env.local up -d --wait

Expected Output

With the graph declared, names resolve and inter-service calls succeed:

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

Prevention

  1. Add a pre-commit hook that runs docker compose config --quiet and rejects unresolved variables or malformed YAML.
  2. Lint for hardcoded URLs in CI; require all endpoints to come from SERVICE_HOST/SERVICE_PORT style variables.
  3. Keep the declared graph in sync with the rendered artifact from dependency tree visualization so reviewers can see new edges.

macOS (Docker Desktop): containers reach the host via host.docker.internal, but sibling services must use their Compose service name, never localhost. WSL2: keep the repo on the Linux filesystem so file-watch healthchecks fire reliably. Apple Silicon (ARM64): if an upstream image lacks an arm64 manifest, pin platform: linux/amd64 so the service starts rather than failing the healthcheck.

Rollback

#!/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