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:
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
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;
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 };
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 };
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]
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 };
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 │
└───────────┴──────────────────────┴──────────────┴────────────────────┴──────────┘
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 };
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 };
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.
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 };
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
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 };
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.
Top comments (0)