A new engineer asks which services they need to run to work on checkout, and nobody can answer without reading 600 lines of YAML; docker compose up checkout starts nine containers and still fails because checkout calls the pricing service through an environment variable that depends_on never mentions; and last month someone added a dependency that made two services wait on each other until Compose reported dependency cycle detected: api -> worker -> api. A generated graph of the stack's dependencies answers the first question in seconds and exposes the other two. This page builds one from docker compose config, as part of dependency tree visualization.

The graph is generated, never drawn by hand, so it stays correct as the Compose file changes.

Diagnostic

Extract the declared edges and look for services referenced in configuration but not declared as dependencies:

#!/usr/bin/env bash
set -euo pipefail
docker compose config --format json > /tmp/compose.json
jq -r '.services | to_entries[] | .key as $s | (.value.depends_on // {}) | to_entries[] | "\($s) -> \(.key) [\(.value.condition)]"' /tmp/compose.json | sort
services=$(jq -r '.services | keys[]' /tmp/compose.json)
jq -r '.services | to_entries[] | .key as $s | (.value.environment // {}) | to_entries[] | "\($s)\t\(.value)"' /tmp/compose.json \
  | while IFS=$'\t' read -r svc val; do
      for t in $services; do
        case "$val" in *"//$t:"*|*"@$t:"*) jq -e --arg s "$svc" --arg t "$t" '.services[$s].depends_on[$t]' /tmp/compose.json >/dev/null || echo "hidden: $svc -> $t";; esac
      done
    done | sort -u

Expected bad output:

api -> db [service_healthy]
checkout -> api [service_started]
worker -> api [service_started]
hidden: checkout -> pricing
hidden: worker -> cache

Two services talk to others through URLs in their environment without declaring the dependency, so starting them alone brings up an incomplete stack.

Declared and Hidden Dependencies The checkout service in the centre with its declared and hidden dependencies. Declared and Hidden Dependencies checkout api declared, started db via api, healthy pricing hidden, URL only cache hidden via worker
Hidden edges come from connection strings in environment variables that depends_on does not list.

Root cause

Compose knows only the dependencies you declare in depends_on. Services also depend on each other through connection strings — PRICING_URL=http://pricing:8080, REDIS_URL=redis://cache:6379 — which Compose treats as opaque text. Over time the two drift: a new integration adds a URL without a depends_on entry, so docker compose up checkout does not start pricing, and the service fails at its first call. Cycles appear when two services each declare the other as a dependency, usually because one of them only needs the other at runtime (a callback URL), not at startup. Without a picture of the graph, neither problem is visible until something fails, and onboarding questions like "what do I need to run for checkout?" have no quick answer.

Conditions matter as much as edges. A depends_on with the default service_started condition only waits for the dependency's container to start, not for the process inside to be ready, so a declared edge can still produce connection-refused errors at startup. Labelling each edge with its condition in the graph makes those weak edges visible: a database dependency marked started rather than healthy is almost always a race waiting to happen, and the fix is a healthcheck plus the stronger condition, as covered in resolving service startup order and healthcheck races.

Resolution

  1. Generate a graph from the merged configuration, including hidden URL edges, as Graphviz DOT:
#!/usr/bin/env bash
set -euo pipefail
docker compose config --format json > /tmp/compose.json
{
  echo 'digraph compose { rankdir=LR; node [shape=box, style=rounded];'
  jq -r '.services | to_entries[] | .key as $s | (.value.depends_on // {}) | to_entries[] |
    "  \"\($s)\" -> \"\(.key)\" [label=\"\(.value.condition | sub("service_"; ""))\"];"' /tmp/compose.json
  for svc in $(jq -r '.services | keys[]' /tmp/compose.json); do
    jq -r --arg s "$svc" '.services[$s].environment // {} | .[]' /tmp/compose.json \
      | grep -oE '//[a-z0-9-]+:' | tr -d '/:' | sort -u | while read -r t; do
          jq -e --arg s "$svc" --arg t "$t" '.services[$s].depends_on[$t]' /tmp/compose.json >/dev/null \
            || echo "  \"$svc\" -> \"$t\" [style=dashed, label=\"hidden\"];"
        done
  done
  echo '}'
} > docs/compose-graph.dot
dot -Tsvg docs/compose-graph.dot -o docs/compose-graph.svg
echo "wrote docs/compose-graph.svg"

Solid edges are declared dependencies labelled with their condition; dashed edges are hidden URL dependencies.

  1. Declare the hidden edges that matter at startup, with the right condition:
services:
  checkout:
    environment:
      PRICING_URL: http://pricing:8080
    depends_on:
      api:
        condition: service_healthy
      pricing:
        condition: service_healthy
  1. Break cycles by removing startup dependencies that are really runtime calls. If worker needs api at startup but api only calls worker's callback later, drop worker from api's depends_on and make api retry that call.

  2. Answer "what do I need?" directly from the graph with Compose itself, which follows declared dependencies:

#!/usr/bin/env bash
set -euo pipefail
docker compose up -d --wait checkout
docker compose ps --format '{{.Service}}' | sort | tr '\n' ' '; echo

With every edge declared, this starts exactly the services checkout needs and no others.

From Compose File to Picture Ordered steps from the merged Compose configuration to a rendered dependency graph. From Compose File to Picture 1 — docker compose config as JSON 2 — extract depends_on edges 3 — find hidden URL edges 4 — write DOT, render SVG 5 — commit or publish in CI
The graph is regenerated on every change, so it cannot go stale.

Expected output

$ ./scripts/compose-graph.sh && grep -c 'hidden' docs/compose-graph.dot
wrote docs/compose-graph.svg
0
$ docker compose up -d --wait checkout && docker compose ps --format '{{.Service}}' | sort | tr '\n' ' '
api cache checkout db pricing

After declaring the missing edges, the graph has no dashed lines, and starting checkout brings up exactly the five services it depends on — not all twenty-three in the file.

That second line is the onboarding answer the original question was looking for, now produced by Compose itself. A new engineer working on checkout can run one command and get a minimal, correct stack, and the rendered SVG in the docs shows why each of those services is there. When combined with Compose profiles, the same graph guides which services belong in each profile.

Prevention

  1. Fail CI on hidden edges. Run the graph script in CI and fail if any dashed edge appears, so a new URL dependency must come with a depends_on entry or an explicit exemption.

  2. Publish the SVG as a CI artifact or commit it under docs/, and link it from the README's onboarding section.

  3. Detect cycles early. docker compose config fails on cycles, but only after someone creates one; the graph makes near-cycles visible in review.

Hand-Drawn Diagram vs Generated Graph Comparison of maintaining an architecture diagram by hand against generating the dependency graph from Compose. Hand-Drawn Diagram vs Generated Graph hand-drawn diagram generated from config stale within weeks rebuilt on every change shows intended design shows actual wiring misses URL coupling flags hidden edges no CI check CI fails on hidden edges
The generated graph is always current and shows the edges people forget to document.

Platform caveats

Graphviz: install with brew install graphviz or apt-get install graphviz, or render inside a throwaway container (docker run --rm -v "$PWD":/w -w /w debian:bookworm-slim sh -c 'apt-get update -qq && apt-get install -y -qq graphviz >/dev/null && dot -Tsvg docs/compose-graph.dot -o docs/compose-graph.svg') to avoid a host dependency.

Windows (native): the script is Bash; run it in WSL2 or Git Bash, or port the jq logic to a small Python script for native use.

Monorepos with include: docker compose config resolves included fragments, so the graph covers the whole combined project automatically.

Rollback

The graph is documentation; remove the script, CI step and rendered files if they are not wanted:

#!/usr/bin/env bash
set -euo pipefail
git rm -q scripts/compose-graph.sh docs/compose-graph.dot docs/compose-graph.svg

Frequently Asked Questions

Why does docker compose up <service> not start everything the service needs?

Compose starts only dependencies declared in depends_on. Services referenced through URLs in environment variables are invisible to it. Declare those dependencies so Compose starts them.

Should every URL dependency become a depends_on entry?

Only those needed at startup or on the first request. Runtime-only callbacks can stay undeclared if the caller retries, which also avoids dependency cycles.

How do I find which services depend on a database?

Reverse the edges in the generated DOT file, or run jq over docker compose config to list services whose depends_on includes it.

Can the graph include services from other repositories?

Only if they are part of the same Compose project, for example through include. For cross-repository systems, combine each repository's graph or use a service catalogue.