Paste a docker-compose.yml and see it as an interactive service graph: services, dependencies,
networks, volumes and ports. Everything runs in your browser; nothing is uploaded.
Try it at stackgraph.tobias-klanert.de. The About page explains how it is built and why.
StackGraph is a portfolio project, built to show clean Angular architecture, strict TypeScript and accessible data visualization.
- An editor with line numbers and caret position. The graph follows as you type, once you pause.
- Errors say what is wrong and where, with a hint on how to fix it and a "Go to line" button. Meanwhile the graph keeps showing the last valid version, and says so.
- One click loads an example stack.
Ctrl+Enter(⌘+Enteron a Mac) opens the graph.
- Services as nodes,
depends_onas arrows, networks and volumes as nodes of their own, ports as chips on each service. The layout runs top to bottom, so dependencies read in one direction. - Node types differ by shape and line style, not by colour alone.
- Select a service to see its image, ports (host → container), networks, volumes and bind mounts, what it depends on and what requires it. Related services are one click away.
- Without a selection, the side panel lists the whole stack.
- Zoom and pan with mouse, touch or the zoom bar, which shows the real zoom level and fits the graph or resets it to 100 %.
- Export the graph as SVG, styled as on screen. Fonts are not embedded yet: on a machine without Inter and JetBrains Mono the file falls back to system fonts.
- Light and dark theme, following the system or chosen by hand.
- On phones and small tablets the editor and the graph get a tab each, and the details sit in a sheet under the graph, or beside it in landscape.
The compose text runs through a pipeline in which every step only knows the one before it:
- Parse:
js-yamlreads the text, and the result is checked and turned into a typed model of services, networks and volumes. Problems come back as readable errors with a position, not as exceptions. - Layout: the model becomes an ELK graph. Node sizes are computed from their content first, then ELK's layered algorithm places the nodes and routes the edges at right angles.
- Render: Angular draws the positioned graph as plain SVG.
d3-zoomonly supplies the zoom and pan transform. Because the SVG is the app's own DOM, export means serialising it. - State: signals hold the state. RxJS only handles the timing: it waits for typing to pause and drops results of outdated input.
Parser, mapping and node geometry are plain functions, tested without Angular or a DOM. The About page covers constraints, decisions and trade-offs in detail.
- There is no backend: the compose file never leaves the browser.
- No cookies, no analytics. The fonts are self-hosted, so the page makes no third-party requests.
- The only stored item is one
localStorageentry, and only if you choose a theme by hand. - The container's nginx keeps no access log.
- Every service node is a toggle button: Tab reaches it, Enter or Space selects it, Escape clears the selection. A skip link leads past the editor to the graph.
- The stack overview lists every service, network and volume as text, as an alternative to the graph. Choosing a service there works like selecting it in the graph.
- The parse result is announced through a polite live region. The error details are there to read and to jump to, not read out on every keystroke.
- Focus moves on purpose: to the details when a service is chosen from the panel, to the error when the graph cannot open on a touch screen, into the field when it cannot open with a mouse.
- Colour is never the only cue, the focus ring is always visible, and reduced motion is respected.
- On touch screens, the app's controls are at least 40 px, and the chips that select a service 44 px.
| Area | Choice |
|---|---|
| Framework | Angular 21 + TypeScript (strict) |
| YAML parsing | js-yaml |
| Graph layout | elkjs (ELK layered algorithm) |
| Rendering | Custom SVG via Angular templates |
| Pan / zoom | d3-zoom (transform only) |
| State | Signals as the source of truth; RxJS for the debounced parse → layout pipeline |
| Styling | SCSS with CSS custom properties as design tokens; no UI library |
| Testing | Vitest (jsdom) |
| Linting / formatting | ESLint (angular-eslint flat config) + Prettier |
| Container | Multi-stage Docker build (node:24.22.2-alpine → nginx-unprivileged:1.30-alpine): non-root, read-only file system, no capabilities, health check |
Why ELK and custom SVG instead of a graph library? Keeping layout (computing positions) apart from rendering and interaction (drawing SVG, zoom, selection) keeps each layer simple, makes the SVG export trivial, and gives layered layouts that suit dependency graphs.
- Node.js 24 LTS (
^24.22.1). The version is pinned in.nvmrcand enforced throughengineswithengine-strict, sonpm installstops with a clear error on any other version. With a version manager such as fnm or nvm,fnm useornvm usepicks it up automatically. CI and the Docker build use the same version. - npm 11+, Git, and Docker for the container workflow.
The Angular CLI runs through the npm scripts, so a global @angular/cli is optional.
git clone https://github.com/TobiasKlanert/stackgraph.git
cd stackgraph
npm install
npm startOpen http://localhost:4200/. The app reloads when a source file changes.
npm run buildThe output in dist/stackgraph/browser/ is plain static files for any web server.
The Dockerfile builds the app with the Node version from .nvmrc and serves it with the
unprivileged nginx image: nginx runs as a non-root user on port 8080, with the SPA fallback,
long-term caching for hashed assets, revalidation for the icons and a health check.
docker build -t stackgraph .
docker run --rm -p 8080:8080 --read-only --tmpfs /tmp --cap-drop ALL stackgraphOpen http://localhost:8080/ and stop the container with Ctrl+C. The flags match the
production hardening, so a local run shows whether the image still works under it.
The
docker-compose.ymlin this repository is the production deployment: it runs behind an existing Caddy reverse proxy on an external Docker network namedweb, publishes no ports, and runs the container with a read-only file system, no capabilities andno-new-privileges. For a local run, use the commands above.
npm test # unit tests with Vitest (jsdom, no browser needed)
npm run lint # ESLint
npm run format # Prettier: write changes
npm run format:check # Prettier: check onlyCI runs lint, format check, tests and the production build for every pull request to main and
every push to it.
Dependabot proposes dependency updates weekly. Angular packages are grouped into one pull request, and major updates of Angular and its coupled tooling are left to ng update, which runs the migrations.
The README images are rendered from a production build with
scripts/readme-screenshots.mjs; the file explains how to run it.
A push to main runs the same workflow with two more jobs. image builds the Docker image and
pushes it to the GitHub Container Registry (ghcr.io/tobiasklanert/stackgraph, tagged
sha-<commit> and latest). deploy then connects to the server over SSH, verifies the server's
host key and sends nothing but the tag to roll out. Only image may push packages, and only
deploy sees the SSH key.
On the server, the deploy key is bound to a forced command in authorized_keys: whatever the
client asks for, sshd runs only ops/stackgraph-deploy.sh, and the
key allows no shell, tunnels or forwarding. The script accepts nothing but sha- followed by a
full commit hash, pulls that image and starts it. If the container does not come up, it switches
back to the previous image and the job fails. The pipeline cannot change the script; it is
installed on the server by hand. There the app runs behind Caddy, which handles TLS.
The actions in the workflow are pinned to commit hashes. Dependabot keeps them up to date and waits seven days after a release before proposing it.
src/
├─ app/
│ ├─ core/ # parser and model, ELK mapping and node geometry, state, theme, export
│ ├─ features/
│ │ └─ graph/ # the tool: home view, editor, graph, details, the compact layout's parts
│ ├─ shared/ # header, footer, theme toggle, error box, zoom directive
│ ├─ pages/ # About, Impressum, Datenschutz: lazy-loaded with a shared document layout
│ └─ app.routes.ts
├─ styles/ # design tokens, themes, base styles, shared controls
└─ testing/ # helpers for the unit tests
scripts/ # tooling outside the app, e.g. the README images
docs/images/ # images used in this README
Dependencies point one way: features use shared/ and core/, and core/ uses neither.
Version 1.0 covers everything above. The v1.1 milestone collects what comes next, among other things: the ELK layout in a Web Worker so large stacks stay responsive, filter toggles in the legend, opening a compose file from disk, PNG export, and shareable links.
This project is licensed under the MIT License © 2026 Tobias Klanert.