DEV Community

Wilson Xu
Wilson Xu

Posted on

Building a Docker Compose Visualizer CLI — See Your Stack at a Glance

Building a Docker Compose Visualizer CLI — See Your Stack at a Glance

Modern applications rarely run as a single container. A typical production stack might involve a web server, an API gateway, a database, a cache layer, a message queue, a worker process, and half a dozen microservices — all wired together in a docker-compose.yml that has grown to hundreds of lines. At that scale, the YAML becomes opaque. Which service depends on which? What ports are exposed? Are there environment variables referencing a .env file that doesn't exist? Did someone change the staging compose file without updating production?

In this tutorial, we'll build compose-viz, a Node.js CLI tool that parses Docker Compose files and gives you instant clarity: ASCII dependency graphs, port/volume/network tables, environment variable audits, drift detection between two compose files, and Mermaid diagram export — all from your terminal.

The Problem With Large Compose Files

Consider a compose file for a typical e-commerce platform:

services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    depends_on:
      - api
      - frontend
    networks:
      - public

  frontend:
    build: ./frontend
    depends_on:
      - api
    environment:
      - API_URL=${API_URL}
    networks:
      - public

  api:
    build: ./api
    depends_on:
      - postgres
      - redis
      - rabbitmq
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=${DATABASE_URL}
      - REDIS_URL=${REDIS_URL}
      - JWT_SECRET=${JWT_SECRET}
    volumes:
      - ./api:/app
    networks:
      - public
      - backend
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  worker:
    build: ./worker
    depends_on:
      - rabbitmq
      - postgres
    environment:
      - DATABASE_URL=${DATABASE_URL}
      - RABBITMQ_URL=${RABBITMQ_URL}
    networks:
      - backend

  postgres:
    image: postgres:16
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
    networks:
      - backend

  redis:
    image: redis:7-alpine
    networks:
      - backend

  rabbitmq:
    image: rabbitmq:3-management
    ports:
      - "15672:15672"
    networks:
      - backend

volumes:
  pgdata:

networks:
  public:
  backend:
Enter fullscreen mode Exit fullscreen mode

That's seven services, two networks, environment variables scattered across four services, and a dependency tree that requires careful reading to reconstruct mentally. Now imagine this at 30 services. You need tooling.

Project Setup

Create the project and install dependencies:

mkdir compose-viz && cd compose-viz
npm init -y
npm install yaml chalk cli-table3 yargs
Enter fullscreen mode Exit fullscreen mode

We use four packages:

  • yaml — a full-spec YAML parser that handles anchors, merge keys, and multi-document streams
  • chalk — terminal coloring
  • cli-table3 — bordered tables in the terminal
  • yargs — argument parsing

Create the entry point:

// bin/compose-viz.js
#!/usr/bin/env node

const yargs = require("yargs");
const { hideBin } = require("yargs/helpers");

yargs(hideBin(process.argv))
  .command("graph [file]", "Show ASCII dependency graph", (yargs) => {
    yargs.positional("file", {
      describe: "Path to docker-compose.yml",
      default: "docker-compose.yml",
    });
  }, cmdGraph)
  .command("table [file]", "Show ports, volumes, networks table", (yargs) => {
    yargs.positional("file", {
      describe: "Path to docker-compose.yml",
      default: "docker-compose.yml",
    });
  }, cmdTable)
  .command("audit [file]", "Audit environment variables", (yargs) => {
    yargs.positional("file", {
      describe: "Path to docker-compose.yml",
      default: "docker-compose.yml",
    });
    yargs.option("env-file", {
      describe: "Path to .env file",
      default: ".env",
    });
  }, cmdAudit)
  .command("diff <file1> <file2>", "Compare two compose files", () => {}, cmdDiff)
  .command("mermaid [file]", "Export Mermaid diagram", (yargs) => {
    yargs.positional("file", {
      describe: "Path to docker-compose.yml",
      default: "docker-compose.yml",
    });
  }, cmdMermaid)
  .command("health [file]", "Show health check status", (yargs) => {
    yargs.positional("file", {
      describe: "Path to docker-compose.yml",
      default: "docker-compose.yml",
    });
  }, cmdHealth)
  .demandCommand(1)
  .help()
  .argv;
Enter fullscreen mode Exit fullscreen mode

Parsing the Compose File

First, we need a robust parser that loads the YAML and normalizes the structure. Docker Compose has evolved through multiple specification versions, so we handle both the legacy version + services format and the modern Compose Specification format where services sits at the root.

// lib/parser.js
const fs = require("fs");
const YAML = require("yaml");

function parseComposeFile(filePath) {
  if (!fs.existsSync(filePath)) {
    console.error(`File not found: ${filePath}`);
    process.exit(1);
  }

  const raw = fs.readFileSync(filePath, "utf8");
  const doc = YAML.parse(raw);

  if (!doc || !doc.services) {
    console.error("Invalid compose file: no services defined");
    process.exit(1);
  }

  return doc;
}

function getServiceNames(doc) {
  return Object.keys(doc.services);
}

function getDependencies(doc) {
  const deps = {};
  for (const [name, config] of Object.entries(doc.services)) {
    deps[name] = [];
    if (config.depends_on) {
      if (Array.isArray(config.depends_on)) {
        deps[name] = [...config.depends_on];
      } else if (typeof config.depends_on === "object") {
        // Long-form: depends_on: { db: { condition: service_healthy } }
        deps[name] = Object.keys(config.depends_on);
      }
    }
    if (config.links) {
      for (const link of config.links) {
        const target = link.split(":")[0];
        if (!deps[name].includes(target)) {
          deps[name].push(target);
        }
      }
    }
  }
  return deps;
}

module.exports = { parseComposeFile, getServiceNames, getDependencies };
Enter fullscreen mode Exit fullscreen mode

The getDependencies function handles three dependency declaration styles: the short array form (depends_on: [db, redis]), the long-form object with conditions (depends_on: { db: { condition: service_healthy } }), and the legacy links directive.

Visualizing Dependencies as an ASCII Graph

This is the centerpiece. We perform a topological sort of the dependency graph and render it as an indented ASCII tree. Services with no dependencies sit at the root level, and dependent services are indented beneath them.

// lib/graph.js
const chalk = require("chalk");
const { parseComposeFile, getServiceNames, getDependencies } = require("./parser");

function buildLayers(deps) {
  const allServices = Object.keys(deps);
  const visited = new Set();
  const layers = [];

  // Find root nodes (no dependencies)
  let currentLayer = allServices.filter((s) => deps[s].length === 0);

  while (currentLayer.length > 0) {
    layers.push(currentLayer);
    currentLayer.forEach((s) => visited.add(s));

    const nextLayer = allServices.filter(
      (s) => !visited.has(s) && deps[s].every((d) => visited.has(d))
    );
    currentLayer = nextLayer;
  }

  // Catch any remaining (circular deps)
  const remaining = allServices.filter((s) => !visited.has(s));
  if (remaining.length > 0) {
    layers.push(remaining);
  }

  return layers;
}

function renderGraph(filePath) {
  const doc = parseComposeFile(filePath);
  const deps = getDependencies(doc);
  const layers = buildLayers(deps);
  const services = doc.services;

  console.log(chalk.bold.cyan("\n  Docker Compose Dependency Graph"));
  console.log(chalk.dim(`  Source: ${filePath}\n`));

  // Render layer by layer
  layers.forEach((layer, layerIndex) => {
    const indent = "  ".repeat(layerIndex);
    const connector = layerIndex === 0 ? "" : "  ";

    layer.forEach((serviceName) => {
      const config = services[serviceName];
      const icon = config.build ? "🔨" : "📦";
      const ports = config.ports
        ? chalk.yellow(` [${config.ports.join(", ")}]`)
        : "";
      const healthcheck = config.healthcheck
        ? chalk.green("")
        : "";

      if (layerIndex > 0) {
        // Draw connection lines
        const parents = deps[serviceName];
        parents.forEach((parent) => {
          console.log(
            chalk.dim(`${indent}${connector}└── depends on: ${parent}`)
          );
        });
      }

      console.log(
        `${indent}${connector}${chalk.bold.white(serviceName)}${ports}${healthcheck}`
      );
    });

    if (layerIndex < layers.length - 1) {
      console.log(chalk.dim(`${"  ".repeat(layerIndex + 1)}│`));
    }
  });

  console.log();
}

module.exports = { renderGraph, buildLayers };
Enter fullscreen mode Exit fullscreen mode

Running compose-viz graph on our example produces:

  Docker Compose Dependency Graph
  Source: docker-compose.yml

  postgres
  redis
  rabbitmq
    │
    └── depends on: postgres
    └── depends on: redis
    └── depends on: rabbitmq
    api [3000:3000] ♥
    └── depends on: rabbitmq
    └── depends on: postgres
    worker
      │
      └── depends on: api
      frontend
      └── depends on: api
      └── depends on: frontend
      nginx [80:80, 443:443]
Enter fullscreen mode Exit fullscreen mode

The layered rendering makes the architecture immediately clear: infrastructure services (postgres, redis, rabbitmq) form the foundation, the API and worker sit in the middle, and the presentation layer (frontend, nginx) sits on top.

Port, Volume, and Network Table

A bird's-eye table view is invaluable for ops work — quickly checking which ports are exposed, what volumes are mounted, and which networks each service joins.

// lib/table.js
const Table = require("cli-table3");
const chalk = require("chalk");
const { parseComposeFile } = require("./parser");

function renderTable(filePath) {
  const doc = parseComposeFile(filePath);

  const table = new Table({
    head: [
      chalk.cyan("Service"),
      chalk.cyan("Image / Build"),
      chalk.cyan("Ports"),
      chalk.cyan("Volumes"),
      chalk.cyan("Networks"),
    ],
    colWidths: [15, 25, 20, 25, 15],
    wordWrap: true,
  });

  for (const [name, config] of Object.entries(doc.services)) {
    const image = config.image || `build: ${config.build?.context || config.build || "."}`;
    const ports = (config.ports || []).join("\n") || chalk.dim("none");
    const volumes = (config.volumes || [])
      .map((v) => (typeof v === "string" ? v : `${v.source}:${v.target}`))
      .join("\n") || chalk.dim("none");
    const networks = config.networks
      ? (Array.isArray(config.networks)
          ? config.networks
          : Object.keys(config.networks)
        ).join("\n")
      : chalk.dim("default");

    table.push([name, image, ports, volumes, networks]);
  }

  console.log(chalk.bold.cyan("\n  Service Overview\n"));
  console.log(table.toString());
  console.log();
}

module.exports = { renderTable };
Enter fullscreen mode Exit fullscreen mode

The output is a clean bordered table:

  Service Overview

┌───────────┬──────────────────────┬──────────────┬────────────────────┬──────────┐
│ Service   │ Image / Build        │ Ports        │ Volumes            │ Networks │
├───────────┼──────────────────────┼──────────────┼────────────────────┼──────────┤
│ nginx     │ nginx:alpine         │ 80:80        │ none               │ public   │
│           │                      │ 443:443      │                    │          │
├───────────┼──────────────────────┼──────────────┼────────────────────┼──────────┤
│ api       │ build: ./api         │ 3000:3000    │ ./api:/app         │ public   │
│           │                      │              │                    │ backend  │
├───────────┼──────────────────────┼──────────────┼────────────────────┼──────────┤
│ postgres  │ postgres:16          │ none         │ pgdata:/var/lib/…  │ backend  │
└───────────┴──────────────────────┴──────────────┴────────────────────┴──────────┘
Enter fullscreen mode Exit fullscreen mode

Health Check Status Integration

Docker Compose supports health checks that determine when a service is actually ready — not just running. Our CLI inspects these definitions and also queries the Docker engine for live status when containers are running.

// lib/health.js
const { execSync } = require("child_process");
const chalk = require("chalk");
const Table = require("cli-table3");
const { parseComposeFile } = require("./parser");

function renderHealth(filePath) {
  const doc = parseComposeFile(filePath);

  const table = new Table({
    head: [
      chalk.cyan("Service"),
      chalk.cyan("Healthcheck Defined"),
      chalk.cyan("Test Command"),
      chalk.cyan("Interval"),
      chalk.cyan("Live Status"),
    ],
    wordWrap: true,
  });

  // Try to get live container health
  let liveHealth = {};
  try {
    const output = execSync(
      'docker ps --format "{{.Names}}\\t{{.Status}}"',
      { encoding: "utf8", timeout: 5000 }
    );
    for (const line of output.trim().split("\n")) {
      if (!line) continue;
      const [name, status] = line.split("\t");
      liveHealth[name] = status;
    }
  } catch {
    // Docker not available or not running
  }

  for (const [name, config] of Object.entries(doc.services)) {
    const hc = config.healthcheck;
    const defined = hc ? chalk.green("Yes") : chalk.dim("No");

    let testCmd = chalk.dim("");
    let interval = chalk.dim("");

    if (hc) {
      if (hc.test) {
        testCmd = Array.isArray(hc.test)
          ? hc.test.filter((t) => t !== "CMD" && t !== "CMD-SHELL").join(" ")
          : hc.test;
      }
      interval = hc.interval || "30s";
    }

    // Find matching live container
    const matchingContainer = Object.keys(liveHealth).find(
      (c) => c.includes(name)
    );
    let liveStatus = chalk.dim("not running");
    if (matchingContainer) {
      const status = liveHealth[matchingContainer];
      if (status.includes("healthy")) {
        liveStatus = chalk.green("healthy");
      } else if (status.includes("unhealthy")) {
        liveStatus = chalk.red("unhealthy");
      } else if (status.includes("starting")) {
        liveStatus = chalk.yellow("starting");
      } else {
        liveStatus = chalk.blue(status);
      }
    }

    table.push([name, defined, testCmd, interval, liveStatus]);
  }

  console.log(chalk.bold.cyan("\n  Health Check Status\n"));
  console.log(table.toString());
  console.log();
}

module.exports = { renderHealth };
Enter fullscreen mode Exit fullscreen mode

The health command cross-references compose definitions with live Docker state, so you can immediately see which services lack health checks (a common reliability gap) and which ones are currently failing.

Environment Variable Audit

Missing environment variables are one of the most common reasons a compose stack fails to start. The audit command extracts every ${VAR} reference from the compose file, checks whether it's defined in the .env file, and flags gaps.

// lib/audit.js
const fs = require("fs");
const chalk = require("chalk");
const Table = require("cli-table3");
const { parseComposeFile } = require("./parser");

function parseEnvFile(envPath) {
  const vars = {};
  if (!fs.existsSync(envPath)) return vars;

  const lines = fs.readFileSync(envPath, "utf8").split("\n");
  for (const line of lines) {
    const trimmed = line.trim();
    if (!trimmed || trimmed.startsWith("#")) continue;
    const eqIndex = trimmed.indexOf("=");
    if (eqIndex > 0) {
      const key = trimmed.substring(0, eqIndex).trim();
      const value = trimmed.substring(eqIndex + 1).trim();
      vars[key] = value;
    }
  }
  return vars;
}

function extractEnvRefs(doc) {
  const refs = {}; // { VAR_NAME: [service1, service2] }

  for (const [serviceName, config] of Object.entries(doc.services)) {
    const envList = config.environment || [];
    const entries = Array.isArray(envList) ? envList : Object.entries(envList);

    for (const entry of entries) {
      const str = Array.isArray(entry) ? `${entry[0]}=${entry[1]}` : entry;
      // Match ${VAR} and ${VAR:-default} patterns
      const matches = String(str).matchAll(/\$\{([A-Z_][A-Z0-9_]*)(?::?-[^}]*)?\}/g);
      for (const match of matches) {
        const varName = match[1];
        if (!refs[varName]) refs[varName] = [];
        if (!refs[varName].includes(serviceName)) {
          refs[varName].push(serviceName);
        }
      }
    }
  }

  return refs;
}

function runAudit(filePath, envFilePath) {
  const doc = parseComposeFile(filePath);
  const envVars = parseEnvFile(envFilePath);
  const refs = extractEnvRefs(doc);

  const table = new Table({
    head: [
      chalk.cyan("Variable"),
      chalk.cyan("Used By"),
      chalk.cyan("In .env?"),
      chalk.cyan("Status"),
    ],
    wordWrap: true,
  });

  let missingCount = 0;

  const sortedVars = Object.keys(refs).sort();
  for (const varName of sortedVars) {
    const services = refs[varName];
    const inEnv = varName in envVars;
    const isEmpty = inEnv && !envVars[varName];

    let status;
    if (!inEnv) {
      status = chalk.red("MISSING");
      missingCount++;
    } else if (isEmpty) {
      status = chalk.yellow("EMPTY");
    } else {
      status = chalk.green("OK");
    }

    table.push([
      varName,
      services.join(", "),
      inEnv ? chalk.green("Yes") : chalk.red("No"),
      status,
    ]);
  }

  console.log(chalk.bold.cyan("\n  Environment Variable Audit\n"));
  console.log(table.toString());

  if (missingCount > 0) {
    console.log(
      chalk.red(`\n  ${missingCount} variable(s) missing from ${envFilePath}`)
    );
    console.log(
      chalk.dim("  Your stack will likely fail to start without these.\n")
    );
  } else {
    console.log(chalk.green("\n  All referenced variables are defined.\n"));
  }
}

module.exports = { runAudit, extractEnvRefs, parseEnvFile };
Enter fullscreen mode Exit fullscreen mode

Running compose-viz audit against our example without a .env file would produce:

  Environment Variable Audit

┌───────────────────┬────────────┬─────────┬─────────┐
│ Variable          │ Used By    │ In .env? │ Status  │
├───────────────────┼────────────┼─────────┼─────────┤
│ API_URL           │ frontend   │ No       │ MISSING │
│ DATABASE_URL      │ api, worker│ No       │ MISSING │
│ JWT_SECRET        │ api        │ No       │ MISSING │
│ POSTGRES_PASSWORD │ postgres   │ No       │ MISSING │
│ RABBITMQ_URL      │ worker     │ No       │ MISSING │
│ REDIS_URL         │ api        │ No       │ MISSING │
└───────────────────┴────────────┴─────────┴─────────┘

  6 variable(s) missing from .env
  Your stack will likely fail to start without these.
Enter fullscreen mode Exit fullscreen mode

This is the kind of check that should run in CI before deployment. One missing variable in production can mean hours of debugging.

Drift Detection: Comparing Two Compose Files

Teams often maintain separate compose files for development, staging, and production. Over time, these diverge — a service gets added to dev but never to prod, an environment variable is updated in one but not the other. Drift detection catches this.

// lib/diff.js
const chalk = require("chalk");
const { parseComposeFile, getDependencies } = require("./parser");

function compareCompose(file1, file2) {
  const doc1 = parseComposeFile(file1);
  const doc2 = parseComposeFile(file2);

  const services1 = new Set(Object.keys(doc1.services));
  const services2 = new Set(Object.keys(doc2.services));

  console.log(chalk.bold.cyan("\n  Compose File Diff\n"));
  console.log(chalk.dim(`  Comparing: ${file1} vs ${file2}\n`));

  // Services only in file1
  const onlyIn1 = [...services1].filter((s) => !services2.has(s));
  const onlyIn2 = [...services2].filter((s) => !services1.has(s));
  const common = [...services1].filter((s) => services2.has(s));

  if (onlyIn1.length > 0) {
    console.log(chalk.red(`  Services only in ${file1}:`));
    onlyIn1.forEach((s) => console.log(chalk.red(`    - ${s}`)));
    console.log();
  }

  if (onlyIn2.length > 0) {
    console.log(chalk.green(`  Services only in ${file2}:`));
    onlyIn2.forEach((s) => console.log(chalk.green(`    + ${s}`)));
    console.log();
  }

  // Compare common services
  for (const name of common) {
    const s1 = doc1.services[name];
    const s2 = doc2.services[name];
    const diffs = [];

    // Image changes
    if (s1.image !== s2.image) {
      diffs.push(`image: ${chalk.red(s1.image || "build")}${chalk.green(s2.image || "build")}`);
    }

    // Port differences
    const ports1 = (s1.ports || []).map(String).sort();
    const ports2 = (s2.ports || []).map(String).sort();
    if (JSON.stringify(ports1) !== JSON.stringify(ports2)) {
      diffs.push(`ports: ${chalk.red(ports1.join(", ") || "none")}${chalk.green(ports2.join(", ") || "none")}`);
    }

    // Environment differences
    const env1 = normalizeEnv(s1.environment);
    const env2 = normalizeEnv(s2.environment);
    const allEnvKeys = new Set([...Object.keys(env1), ...Object.keys(env2)]);

    for (const key of allEnvKeys) {
      if (!(key in env1)) {
        diffs.push(`env +${key}: ${chalk.green(env2[key])}`);
      } else if (!(key in env2)) {
        diffs.push(`env -${key}: ${chalk.red(env1[key])}`);
      } else if (env1[key] !== env2[key]) {
        diffs.push(`env ~${key}: ${chalk.red(env1[key])}${chalk.green(env2[key])}`);
      }
    }

    // Volume differences
    const vols1 = (s1.volumes || []).map(String).sort();
    const vols2 = (s2.volumes || []).map(String).sort();
    if (JSON.stringify(vols1) !== JSON.stringify(vols2)) {
      diffs.push(`volumes changed`);
    }

    // Network differences
    const nets1 = normalizeNetworks(s1.networks);
    const nets2 = normalizeNetworks(s2.networks);
    if (JSON.stringify(nets1) !== JSON.stringify(nets2)) {
      diffs.push(`networks: ${chalk.red(nets1.join(", "))}${chalk.green(nets2.join(", "))}`);
    }

    if (diffs.length > 0) {
      console.log(chalk.yellow(`  ${name}:`));
      diffs.forEach((d) => console.log(`    ${d}`));
      console.log();
    }
  }

  if (onlyIn1.length === 0 && onlyIn2.length === 0) {
    const hasDiffs = common.some((name) => {
      const s1 = doc1.services[name];
      const s2 = doc2.services[name];
      return JSON.stringify(s1) !== JSON.stringify(s2);
    });
    if (!hasDiffs) {
      console.log(chalk.green("  No drift detected. Files are equivalent.\n"));
    }
  }
}

function normalizeEnv(env) {
  if (!env) return {};
  if (Array.isArray(env)) {
    const obj = {};
    for (const entry of env) {
      const eqIdx = String(entry).indexOf("=");
      if (eqIdx > 0) {
        obj[entry.substring(0, eqIdx)] = entry.substring(eqIdx + 1);
      } else {
        obj[entry] = "";
      }
    }
    return obj;
  }
  return { ...env };
}

function normalizeNetworks(networks) {
  if (!networks) return ["default"];
  if (Array.isArray(networks)) return networks.sort();
  return Object.keys(networks).sort();
}

module.exports = { compareCompose };
Enter fullscreen mode Exit fullscreen mode

Running compose-viz diff docker-compose.yml docker-compose.prod.yml might output:

  Compose File Diff

  Comparing: docker-compose.yml vs docker-compose.prod.yml

  Services only in docker-compose.yml:
    - debug-ui

  api:
    image: build → registry.example.com/api:v2.1.0
    ports: 3000:3000 → none
    env +NEW_RELIC_KEY: ${NEW_RELIC_KEY}
    volumes changed
Enter fullscreen mode Exit fullscreen mode

This immediately tells you that someone has a debug UI in development that doesn't exist in production (probably fine), the API uses a pre-built image in prod (correct), the debug port is not exposed in prod (correct), but a New Relic key was added to prod without a corresponding dev entry (potential issue).

Mermaid Diagram Export

Sometimes you need to share the architecture with non-terminal users — in documentation, pull requests, or wiki pages. Mermaid diagrams render natively in GitHub, GitLab, Notion, and many other platforms.

// lib/mermaid.js
const { parseComposeFile, getDependencies } = require("./parser");

function exportMermaid(filePath) {
  const doc = parseComposeFile(filePath);
  const deps = getDependencies(doc);
  const services = doc.services;

  const lines = ["graph TD"];

  // Define nodes with metadata
  for (const [name, config] of Object.entries(services)) {
    const ports = config.ports ? `\\nPorts: ${config.ports.join(", ")}` : "";
    const hasHealth = config.healthcheck ? "" : "";

    if (config.build) {
      // Custom-built services get a different shape
      lines.push(`  ${name}[/"${name}${hasHealth}${ports}"/]`);
    } else {
      // Pre-built images get standard rectangles
      const img = config.image || "";
      lines.push(`  ${name}["${name}${hasHealth}\\n${img}${ports}"]`);
    }
  }

  lines.push("");

  // Define edges from dependencies
  for (const [name, depList] of Object.entries(deps)) {
    for (const dep of depList) {
      lines.push(`  ${dep} --> ${name}`);
    }
  }

  // Add network subgraphs
  if (doc.networks) {
    lines.push("");
    const networkMembers = {};

    for (const [name, config] of Object.entries(services)) {
      const nets = config.networks
        ? Array.isArray(config.networks)
          ? config.networks
          : Object.keys(config.networks)
        : ["default"];

      for (const net of nets) {
        if (!networkMembers[net]) networkMembers[net] = [];
        networkMembers[net].push(name);
      }
    }

    for (const [netName, members] of Object.entries(networkMembers)) {
      lines.push(`  subgraph ${netName}`);
      members.forEach((m) => lines.push(`    ${m}`));
      lines.push("  end");
    }
  }

  const output = lines.join("\n");
  console.log(output);

  return output;
}

module.exports = { exportMermaid };
Enter fullscreen mode Exit fullscreen mode

The output is a valid Mermaid definition that you can paste directly into a GitHub README:

```

mermaid
graph TD
  nginx["nginx\nnginx:alpine\nPorts: 80:80, 443:443"]
  frontend[/"frontend"/]
  api[/"api ♥\nPorts: 3000:3000"/]
  worker[/"worker"/]
  postgres["postgres\npostgres:16"]
  redis["redis\nredis:7-alpine"]
  rabbitmq["rabbitmq\nrabbitmq:3-management\nPorts: 15672:15672"]

  api --> nginx
  frontend --> nginx
  api --> frontend
  postgres --> api
  redis --> api
  rabbitmq --> api
  rabbitmq --> worker
  postgres --> worker

  subgraph public
    nginx
    frontend
    api
  end
  subgraph backend
    api
    worker
    postgres
    redis
    rabbitmq
  end


```
```

`

Custom-built services use a different Mermaid shape (trapezoid vs rectangle), giving visual distinction between your code and third-party images.

## Wiring It All Together

Connect the commands to their implementations:

```js
// bin/compose-viz.js (complete)
const { renderGraph } = require("../lib/graph");
const { renderTable } = require("../lib/table");
const { runAudit } = require("../lib/audit");
const { compareCompose } = require("../lib/diff");
const { exportMermaid } = require("../lib/mermaid");
const { renderHealth } = require("../lib/health");

function cmdGraph(argv) {
  renderGraph(argv.file);
}

function cmdTable(argv) {
  renderTable(argv.file);
}

function cmdAudit(argv) {
  runAudit(argv.file, argv.envFile);
}

function cmdDiff(argv) {
  compareCompose(argv.file1, argv.file2);
}

function cmdMermaid(argv) {
  exportMermaid(argv.file);
}

function cmdHealth(argv) {
  renderHealth(argv.file);
}
```

Add the binary entry to `package.json`:

```json
{
  "name": "compose-viz",
  "version": "1.0.0",
  "bin": {
    "compose-viz": "./bin/compose-viz.js"
  },
  "dependencies": {
    "chalk": "^4.1.2",
    "cli-table3": "^0.6.3",
    "yaml": "^2.3.4",
    "yargs": "^17.7.2"
  }
}
```

Link it globally for local use:

```bash
chmod +x bin/compose-viz.js
npm link
```

Now you can run any command from any directory:

```bash
compose-viz graph
compose-viz table
compose-viz audit --env-file .env.production
compose-viz diff docker-compose.yml docker-compose.prod.yml
compose-viz mermaid > architecture.mmd
compose-viz health
```

## Adding CI Integration

The audit and diff commands are particularly valuable in CI pipelines. Add a `--ci` flag that exits with a non-zero code when problems are detected:

```js
// In audit.js, add to runAudit:
if (argv.ci && missingCount > 0) {
  process.exit(1);
}

// In diff.js, add to compareCompose:
if (argv.ci && hasDrift) {
  process.exit(1);
}
```

Then in your GitHub Actions workflow:

```yaml
- name: Audit compose environment
  run: compose-viz audit --ci --env-file .env.production

- name: Check for compose drift
  run: compose-viz diff docker-compose.yml docker-compose.prod.yml --ci
```

This ensures that PRs which introduce environment variable gaps or compose file drift are caught before merge.

## Performance Considerations

The `yaml` package handles files up to several megabytes efficiently, but if you're working with generated compose files (from Helm or Terraform outputs) that can be enormous, consider streaming the parse:

```js
const { parseDocument } = require("yaml");

// For very large files, parseDocument gives you access
// to the CST without full resolution
const doc = parseDocument(rawYaml);
const services = doc.get("services");
```

For the graph rendering, the layer-based approach runs in O(V + E) time — the same as a standard topological sort — so it handles compose files with hundreds of services without any noticeable delay.

## Where to Go From Here

This CLI covers the essential inspection workflows, but there's room to extend it:

- **Live watch mode**: Use `fs.watch` to re-render the graph whenever the compose file changes.
- **Interactive TUI**: Libraries like `blessed` or `ink` could turn this into a full terminal dashboard where you click on services to see their details.
- **Docker API integration**: Beyond health checks, pull real-time resource usage (CPU, memory) from the Docker socket and display it alongside the service graph.
- **Compose validation**: Check for common misconfigurations like using `latest` tags in production, exposing database ports publicly, or mounting host paths that don't exist.

The complete source code for this project is available as an npm package. Install it with `npm install -g compose-viz` and point it at any compose file to get instant architectural visibility.

## Wrapping Up

Docker Compose files are deceptively simple at small scale and deceptively complex at production scale. The gap between "I can read YAML" and "I understand my entire service topology" is real, and it grows with every service you add. A visualization tool that lives in your terminal — where you already work with Docker — closes that gap without context-switching to a browser or diagramming tool.

The key techniques we used — YAML parsing with the `yaml` package, topological sorting for dependency layers, regex extraction for environment variable auditing, and structured diffing for drift detection — are broadly applicable patterns. The same approach works for Kubernetes manifests, Terraform configurations, or any declarative infrastructure format where understanding relationships between resources matters more than reading individual definitions.

Build the tool, add it to your CI pipeline, and never again wonder which service depends on what.
Enter fullscreen mode Exit fullscreen mode

Top comments (0)