"The stack is broken" is rarely true; what is true is that one service depends on another that never came up, came up out of order, or formed a circular wait. Making that graph explicit turns a vague outage into a precise missing edge. This guide extracts the dependency graph from your manifests and Compose file, renders it into a shareable artifact, and gates it against the staging topology so local environments stop diverging silently. It supports the wider effort on onboarding architecture and friction mapping, and the two most common downstream tasks — mapping microservice dependencies for local dev and detecting circular dependencies in local builds — have dedicated walkthroughs.

A dependency graph is the single artifact that answers three questions a new engineer asks in their first week: what has to be running before I can start service X, which services will break if I change this one, and why does the stack behave differently on my laptop than in staging. Without a rendered graph, those answers live in tribal knowledge and half-remembered Slack threads. This guide treats the graph as a first-class, version-controlled artifact — generated deterministically from the same manifests Docker already reads — so the answer is the same on every workstation, in CI, and in the diagram on the wiki.

Prerequisites

  • Docker Engine 24+ with the Compose v2 plugin.
  • jq and npx on the host (or run them through docker run --rm node:20-alpine).
  • A docker-compose.yml and at least one lockfile (package-lock.json, go.sum, poetry.lock).
  • Read access to the staging topology export (a committed staging_deps.json) if you intend to run the parity gate.
  • A POSIX shell; the snippets below assume bash 4+ so set -euo pipefail and process substitution behave predictably.

The graph you build here has two independent layers, and conflating them is the most common source of confusion. The runtime layer is the set of depends_on, network, and healthcheck edges Compose enforces when it boots the stack — this is what determines startup order. The build layer is the set of import and package edges inside each service's source tree, which is what produces circular-import failures and slow, cache-busting rebuilds. A service can have a clean runtime graph and a tangled build graph, or the reverse. Everything below keeps the two layers in separate files (normalized_deps.json for runtime, cycles.json for build) so a failure points at exactly one of them.

Dependency Graph Extraction and Lockfile Parsing

Turn raw manifests into a machine-readable adjacency list so downstream steps have a deterministic baseline. The extraction must be repeatable byte-for-byte: if two engineers run it against the same commit and get different JSON, every downstream drift check becomes noise. That is why the pipeline reads from docker compose config — the fully-resolved, merged, variable-interpolated view — rather than parsing the raw docker-compose.yml by hand. The config subcommand applies profiles, extends, override files, and .env interpolation exactly as the runtime will, so the adjacency list reflects the stack you actually boot, not the one the YAML appears to describe.

  1. Extract the service-to-dependency edges straight from Compose:
    #!/usr/bin/env bash
    set -euo pipefail
    docker compose config --format json \
      | jq '[.services | to_entries[]
             | {source: .key, targets: (.value.depends_on // {} | keys)}]' \
      > normalized_deps.json
    jq 'length' normalized_deps.json
  2. Validate the structure and look for cycles:
    #!/usr/bin/env bash
    set -euo pipefail
    jq -e 'type == "array"' normalized_deps.json >/dev/null && echo "valid graph"
    npx madge --circular --json src/ > cycles.json
    jq 'length == 0' cycles.json && echo "no import cycles"
  3. Drift check — alert when the graph changes versus the committed baseline:
    #!/usr/bin/env bash
    set -euo pipefail
    BASELINE_HASH=$(cat .cache/baseline.sha256)
    CURRENT_HASH=$(sha256sum normalized_deps.json | awk '{print $1}')
    if [ "$BASELINE_HASH" != "$CURRENT_HASH" ]; then
      echo "Dependency graph changed since baseline; review before commit." >&2
    fi

The depends_on // {} guard matters more than it looks. In Compose v2 the depends_on key can be absent, a bare list (short form), or a map of conditions (long form). The // {} fallback keeps jq from throwing on services that declare no dependencies, and keys normalizes both the map form and — because jq treats a list's indices as keys — degrades gracefully rather than crashing the whole extraction. Normalizing to a single shape here means the visualization and drift stages never have to special-case a service that happens to have no edges; it simply carries an empty targets array.

For the build layer, madge walks the import graph statically without executing your code, which is what makes it safe to run in CI on an untrusted branch. The --circular flag returns only the strongly-connected components — the actual cycles — as a JSON array of arrays, so jq 'length == 0' is a clean pass/fail. Run it against your real source root (src/), not the compiled output, or you will miss cycles that a bundler has already flattened. If your service is polyglot, run madge per language sub-tree and concatenate the arrays before the length check; a single mixed run will silently skip files it cannot parse.

A subtle failure mode here is edge ordering. jq preserves object key order in most builds, but keys sorts its output alphabetically, so two runs that produce logically identical graphs also produce byte-identical JSON regardless of the order Compose happened to emit services in. That property is what lets the sha256sum comparison in step three be trustworthy: without a canonical ordering, a harmless reordering of services in the YAML would flip the hash and cry drift on every unrelated edit. If you extend the extractor to capture more than depends_on — say, network membership or profile assignment — pipe the final structure through jq -S (sort keys recursively) before hashing so the canonical form survives the schema change. Treat the digest as the graph's fingerprint, commit it as .cache/baseline.sha256 in the same change that updates the topology, and the drift check becomes a one-line assertion that the fingerprint on disk matches the one the team agreed to.

Extraction pipeline from manifests to drift check Compose config and lockfiles are normalized into an adjacency JSON, which feeds a baseline hash comparison. Deterministic Extraction Flow Compose + locks merged manifests jq normalize adjacency JSON sha256 hash canonical digest drift? vs baseline Same commit in, same digest out — divergence is always real.
Extraction reads the fully-resolved Compose view so the adjacency list matches the booted stack.

WSL2: run extraction on the Linux filesystem (~/project); /mnt/c I/O makes lockfile parsing crawl. Apple Silicon (ARM64): if host jq/npx conflict, run them in docker run --rm -v "$(pwd):/work" -w /work node:20-alpine. macOS (Docker Desktop): under heavy parsing, VirtioFS can throw transient EBUSY; rerun on a named volume rather than a bind mount.

Containerized Topology and Startup Ordering

Map the adjacency list to a Compose manifest with health-gated ordering so dependent services never start against a cold database. This is where the runtime graph becomes executable. A bare depends_on: [db] only guarantees that Compose starts the container before the dependent — it says nothing about whether Postgres has finished crash recovery and is accepting connections. The gap between "container started" and "service ready" is where the classic flaky boot lives: the API process races ahead, opens a socket to a database that is still replaying its WAL, and dies with a connection-refused. Health-gated ordering closes that gap by making the edge mean "ready", not merely "launched". For the deeper patterns behind multi-service boot, see multi-service orchestration with Compose.

  1. Declare healthchecks and depends_on conditions:
    # docker-compose.yml
    services:
      api:
        image: "${REGISTRY}/api:latest"
        depends_on:
          db:
            condition: service_healthy
          cache:
            condition: service_healthy
        networks:
          - dev-mesh
        environment:
          DB_HOST: db
          CACHE_HOST: cache
      db:
        image: postgres:16-alpine
        healthcheck:
          test: ["CMD-SHELL", "pg_isready -U app"]
          interval: 5s
          timeout: 3s
          retries: 5
        networks:
          - dev-mesh
      cache:
        image: redis:7-alpine
        healthcheck:
          test: ["CMD", "redis-cli", "ping"]
          interval: 5s
          timeout: 3s
          retries: 5
        networks:
          - dev-mesh
    networks:
      dev-mesh:
        driver: bridge
  2. Verify the topology and that names resolve on the network:
    #!/usr/bin/env bash
    set -euo pipefail
    EXPECTED_DEPS=2
    ACTUAL_DEPS=$(docker compose config | grep -c 'condition: service_healthy')
    [ "$ACTUAL_DEPS" -eq "$EXPECTED_DEPS" ] && echo "topology matches" || { echo "missing healthcheck conditions" >&2; exit 1; }
    docker compose exec api getent hosts db
    docker compose exec api getent hosts cache

The interval, timeout, and retries triple defines the worst-case wait before Compose gives up: with interval: 5s and retries: 5, a slow database has roughly twenty-five seconds to report healthy before the dependent is abandoned. Tune those numbers to the slowest cold start you observe in CI, not the warm start on your laptop, or the gate that protects you locally will flap on the runner. Prefer a CMD-SHELL probe that exercises the real readiness contract — pg_isready confirms the postmaster accepts connections, which is stricter than a TCP port check and cheaper than a full query. For services with a warm-up phase (JIT caches, index loads), point the healthcheck at an application /ready endpoint that flips only after warm-up completes, so service_healthy means genuinely serving traffic.

When names do not resolve, follow resolving DNS resolution failures between local containers. The getent hosts check is deliberately blunt: it asks the container's own resolver, through the embedded Docker DNS at 127.0.0.11, whether the service name maps to an address on dev-mesh. If it returns nothing, the problem is the network topology — a missing shared network, a typo in the alias, or a service on the default bridge that never joined the mesh — not the application. Running the check from inside api rather than from the host is what makes it trustworthy; host-side name resolution uses a completely different path and will happily lie to you.

Health-gated startup sequence Database and cache reach a healthy state before the API is allowed to start and resolve names. Health-Gated Boot Order 1 — db: pg_isready passes 2 — cache: redis-cli ping 3 — api: condition met, start 4 — mesh: names resolve
Each stage gates the next; the API only starts once its dependencies report healthy, not merely launched.

macOS (Docker Desktop): default 2-core/4GB limits make healthchecks time out under concurrent boot; raise resources in Settings. WSL2: host.docker.internal behaves differently; add extra_hosts if a service needs a host-loopback callback.

Automated Seed Data and State Injection

Inject deterministic fixtures during initialization so every workstation and CI runner boots identical state. A dependency graph that boots cleanly but against empty tables is only half a working environment — the new engineer still hits foreign-key errors the moment they exercise a real flow. Deterministic seeding turns "works after I manually import the dump someone DM'd me" into a reproducible step that runs the same way in the devcontainer lifecycle and on the CI runner. The verification step is the point: seeding without a row-count assertion is just hope, because a partially-applied fixture (a migration that half-ran, a seed script that swallowed an error) leaves the database in a state that looks populated but fails in subtle, per-machine ways.

  1. Run a fixture seed from the dev container's lifecycle hook:
    // .devcontainer/devcontainer.json
    {
      "name": "dev-container-workspace",
      "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
      "features": {
        "ghcr.io/devcontainers/features/docker-in-docker:2": {}
      },
      "postCreateCommand": "./scripts/seed-db.sh --mode=local --fixtures=baseline"
    }
  2. Verify row counts against the fixture manifest and fail on divergence:
    #!/usr/bin/env bash
    set -euo pipefail
    EXPECTED_ROWS=$(jq '.core_tables' .cache/fixture_manifest.json)
    ACTUAL_ROWS=$(docker compose exec -T db psql -t -A -U app -d app_db -c "SELECT COUNT(*) FROM core_tables;")
    if [ "$EXPECTED_ROWS" -ne "$ACTUAL_ROWS" ]; then
      echo "Row count delta: expected $EXPECTED_ROWS, got $ACTUAL_ROWS." >&2
      exit 1
    fi

postCreateCommand runs once, after the container is created but before the editor attaches, which makes it the correct hook for one-time seeding — as opposed to postStartCommand, which fires on every restart and would re-run the seed against an already-populated database. The -T flag on docker compose exec disables pseudo-TTY allocation; omit it in a non-interactive script and psql output arrives wrapped in control characters that break the numeric comparison. The -t -A pair strips the header and alignment padding so ACTUAL_ROWS is a bare integer, which is exactly what the -ne arithmetic test requires — a stray space or newline turns [ "$EXPECTED" -ne "$ACTUAL" ] into a fatal integer expression expected.

Seed runs frequently stall on a port collision; reconcile that against common local failure points first. Treat the fixture manifest as the contract and the live count as the observation: when they diverge, the fixture set changed and the manifest was not updated, or a migration silently failed halfway. Committing fixture_manifest.json alongside the seed script means a reviewer sees the expected-count change in the same diff as the data change, so the assertion cannot drift out from under the fixtures it guards.

Seeding also has an ordering dependency of its own that the runtime graph does not capture. The seed script must run after the schema migration but before any application service that reads the seeded rows at startup — a service that caches a lookup table on boot will hold a stale, empty snapshot if it starts before the fixtures land. Gate the seed behind the same service_healthy condition the application uses, and run migrations as a one-shot depends_on service that exits zero, so the boot order is migrate, seed, then serve. Encoding that order in Compose rather than in a README is what keeps the sequence identical on a fresh clone and on a warm cache.

Fixture row-count verification decision If the live row count equals the manifest the boot proceeds, otherwise the seed fails the run. Seed Verification Gate live count == manifest? core_tables rows Match boot proceeds Delta exit 1, inspect fixture
The row-count assertion turns a silent partial seed into a loud, actionable failure.

WSL2: ensure seed-db.sh has chmod +x and LF endings, or postCreateCommand fails with bad interpreter. Apple Silicon (ARM64): client binaries in init containers must match the host arch or pin platform: linux/amd64.

Visualization Pipeline

Render the normalized graph into a version-controlled artifact engineers can actually read. A JSON adjacency list is machine-truth but human-hostile; nobody spots a fan-out bottleneck or an accidental hub by scanning nested arrays. The rendered artifact is the difference between "the data exists" and "the team understands the data". Commit the rendered output — not just the source JSON — so a reviewer can open docs/dep-graph.html from a pull request and see the topology change without cloning, installing, and running the renderer. That is the pattern that makes the graph an onboarding asset rather than a script only its author remembers how to run.

  1. Generate a static graph from the adjacency list:
    # Makefile
    .PHONY: visualize
    visualize:
    	npx @antv/g6-cli render \
    		--input=normalized_deps.json \
    		--output=docs/dep-graph.html \
    		--theme=dark \
    		--layout=force
    	@echo "Rendered docs/dep-graph.html"
  2. Verify the artifact exists and node counts match the source:
    #!/usr/bin/env bash
    set -euo pipefail
    test -f docs/dep-graph.html || { echo "render failed" >&2; exit 1; }
    SRC_NODES=$(jq 'length' normalized_deps.json)
    ARTIFACT_NODES=$(grep -o 'data-node-id' docs/dep-graph.html | wc -l | tr -d ' ')
    [ "$SRC_NODES" -eq "$ARTIFACT_NODES" ] && echo "node parity verified" || echo "node mismatch" >&2

The node-parity check exists because a renderer failing silently is worse than one failing loudly: a force-directed layout that drops an unreachable node produces a diagram that looks complete but omits exactly the service you most need to see. Counting data-node-id occurrences in the output and comparing against jq 'length' on the source is a cheap invariant that catches truncated renders, filtered orphans, and encoding bugs before the artifact reaches a reviewer. The force layout suits densely connected meshes because it lets tightly-coupled groups self-organize by pull strength, but for a strictly acyclic pipeline a dagre (layered) layout reads far better — the left-to-right rank of a layered graph is the startup order, so the picture and the boot sequence become the same thing.

Choosing between a committed static artifact and an ad-hoc live query is a real trade-off, not a formality. A committed HTML file diffs in review, works offline, and pins the topology to a commit, but it goes stale the moment someone edits the manifest without re-rendering. An on-demand docker compose config | jq | render invocation is always current but invisible in review and unavailable to anyone who has not cloned the repo. The pragmatic answer is both: regenerate the static artifact in CI on every change so the committed file can never lag the manifest, and keep the one-liner handy for local spelunking.

Committed artifact versus ad-hoc query Comparison of a committed rendered graph against an on-demand live render across three properties. Rendered Artifact vs Live Query Committed artifact diffs in pull request works offline can lag the manifest Ad-hoc live query always current invisible in review needs the full toolchain
Regenerate the static artifact in CI so the committed graph gets both currency and reviewability.

Apple Silicon (ARM64): @antv/g6-cli needs native canvas bindings; install libcairo2-dev and pkg-config before npm install. WSL2: serve the HTML from inside the Linux filesystem (python3 -m http.server 8080) rather than a network drive.

Drift Detection and Parity Gates

Catch local topology diverging from staging before a pull request merges. Drift is the slow failure mode this whole pipeline exists to prevent: staging grows a new cache tier, a queue, or a healthcheck condition, and local Compose files fossilize around the topology from three sprints ago. Nobody notices until a feature that works locally deadlocks in staging because the boot order it assumed no longer holds. A parity gate reduces that class of surprise to a mechanical diff — the local graph and the staging graph are both adjacency lists, so comparing them is set arithmetic, not judgment.

  1. Compare local and baseline graphs in CI:
    # .github/workflows/parity-check.yml
    name: Dependency Parity
    on:
      pull_request:
      schedule:
        - cron: "0 2 * * 1"
    jobs:
      diff:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - name: Extract local topology
            run: ./scripts/extract-graph.sh --output=local_deps.json
          - name: Compare against staging baseline
            run: |
              ./scripts/compare-graph.sh \
                --baseline=staging_deps.json \
                --target=local_deps.json \
                --threshold=0.05 \
                --report=parity_report.md
          - uses: actions/upload-artifact@v4
            with:
              name: parity-compliance
              path: parity_report.md
  2. Fail the gate when divergence exceeds the threshold:
    #!/usr/bin/env bash
    set -euo pipefail
    DIVERGENCE=$(grep -oP 'divergence: \K[0-9.]+' parity_report.md)
    if awk "BEGIN { exit !($DIVERGENCE > 0.05) }"; then
      echo "Local topology diverges from staging by >5%." >&2
      exit 1
    fi
    echo "Parity validated against staging baseline."

The threshold exists so the gate tolerates intentional, local-only differences — a mock mail-catcher, a debug proxy — without failing every pull request. Divergence here is best computed as the symmetric difference of the two edge sets divided by their union (a Jaccard distance): edges present in staging but missing locally are the dangerous ones, so a stricter policy weights those higher than the reverse. Running the gate both on pull_request and on a weekly schedule matters because staging drifts even when no local change triggers a build; the Monday cron catches a topology that grew a dependency over the weekend, so the first person to open a branch on Monday is not the one who discovers it the hard way.

There is a question of what counts as the source of truth for the baseline. Exporting staging_deps.json from the running staging stack — the same docker compose config | jq pipeline, pointed at the staging Compose files — keeps the comparison honest, because it captures the topology as deployed rather than as documented. Regenerate that export on every staging deploy and commit it, so the baseline the gate reads is never older than the environment it claims to represent. A stale baseline is the one way this gate lies: it passes a local file against a snapshot of staging from a month ago, misses the real drift, and gives false confidence exactly when the two environments have diverged most.

The numbers below are illustrative of a typical run, but they show why a percentage threshold beats a raw edge count: as the stack grows, one added edge is a smaller fraction of the whole, so a fixed "no new edges" rule becomes progressively more brittle while a 5% band stays meaningful.

Topology divergence by environment Bar chart of percentage divergence from the staging baseline for three environments against a five percent gate. Divergence From Staging (%) CI runner 0.3% laptop A 3.5% laptop B 8.1% — fails dashed line = 5% gate
Laptop B has drifted past the 5% band and the parity gate blocks its pull request until reconciled.

CI runners vs local: GitHub Actions runs x86_64; keep compare-graph.sh architecture-agnostic and avoid hardcoded /usr/local/bin paths. Corporate proxy: set NO_PROXY=127.0.0.1,localhost,.local so local mesh syncs are not intercepted.

Rollback - Recovery

If a topology change wedges the stack, tear it down and prune dangling artifacts before retrying. The order matters: remove containers and their orphans first, then reclaim the networks they held, then restore the manifests from the last known-good commit. Skipping the network prune is the usual reason a "clean" retry still fails — a stale dev-mesh from the previous boot lingers with the old subnet, and the fresh stack either refuses to recreate it or attaches services to a network the new topology no longer expects.

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

--volumes is intentional here and destructive: it drops the named volumes, which is correct for a local dev database you can re-seed in seconds but catastrophic if you were holding irreplaceable state. If the volume matters, omit --volumes and prune only the containers and networks, then re-run the seed step to repopulate. Restoring normalized_deps.json alongside docker-compose.yml keeps the extracted graph and the manifest it came from in lockstep, so the next drift check compares against a baseline that actually matches the file on disk.

Frequently Asked Questions

Why parse docker compose config instead of the raw docker-compose.yml?

Because docker compose config emits the fully-merged, variable-interpolated view that the runtime actually uses. It applies override files, extends, active profiles, and .env interpolation, so the adjacency list reflects the stack you boot rather than the YAML you wrote. Parsing the raw file by hand misses every one of those resolutions and produces a graph that silently disagrees with reality.

Does depends_on: service_healthy wait for the application, or just the container?

It waits for the healthcheck to pass, which is only as meaningful as the probe you write. pg_isready confirms the database accepts connections; a bare depends_on with no condition waits only for the container to start, not to be ready. For services with a warm-up phase, point the healthcheck at an application /ready endpoint so service_healthy means genuinely serving traffic, not merely launched.

How do I tell a runtime dependency cycle from an import cycle?

They live in different files. Runtime cycles are circular depends_on edges in normalized_deps.json — service A waits on B while B waits on A — and Compose will refuse to start such a stack. Import cycles are circular module references inside a service's source, caught by madge --circular and written to cycles.json. Keeping the two graphs separate means a failure points at exactly one layer instead of both.

What threshold should the parity gate use?

Start at 5% Jaccard divergence and weight edges present in staging but missing locally more heavily, since those are the ones that cause staging-only deadlocks. A percentage band beats a raw edge count because it stays meaningful as the stack grows — one new edge is a shrinking fraction of a larger graph. Widen the threshold only for deliberate, local-only additions like a mail-catcher or debug proxy.