Dependency Tree Visualization for Local Stacks
"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.
jqandnpxon the host (or run them throughdocker run --rm node:20-alpine).- A
docker-compose.ymland 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
bash4+ soset -euo pipefailand 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.
- 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 - 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" - 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.
WSL2: run extraction on the Linux filesystem (
~/project);/mnt/cI/O makes lockfile parsing crawl. Apple Silicon (ARM64): if hostjq/npxconflict, run them indocker run --rm -v "$(pwd):/work" -w /work node:20-alpine. macOS (Docker Desktop): under heavy parsing, VirtioFS can throw transientEBUSY; 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.
- Declare healthchecks and
depends_onconditions:# 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 - 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.
macOS (Docker Desktop): default 2-core/4GB limits make healthchecks time out under concurrent boot; raise resources in Settings. WSL2:
host.docker.internalbehaves differently; addextra_hostsif 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.
- 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" } - 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.
WSL2: ensure
seed-db.shhaschmod +xand LF endings, orpostCreateCommandfails withbad interpreter. Apple Silicon (ARM64): client binaries in init containers must match the host arch or pinplatform: 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.
- 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" - 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.
Apple Silicon (ARM64):
@antv/g6-clineeds native canvas bindings; installlibcairo2-devandpkg-configbeforenpm 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.
- 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 - 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.
CI runners vs local: GitHub Actions runs
x86_64; keepcompare-graph.sharchitecture-agnostic and avoid hardcoded/usr/local/binpaths. Corporate proxy: setNO_PROXY=127.0.0.1,localhost,.localso 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.