Diagrams as code
Make diagrams from the files they describe (Terraform, OpenAPI, Docker Compose, Kubernetes, Helm, Kustomize, the AWS CDK, Backstage catalogs, Structurizr workspaces, AsyncAPI documents, Prisma, Drizzle, SQL, GitHub Actions, GitLab CI/CD, and Azure Pipelines pipelines, CSV and JSON, folder listings, and git logs) and keep them in step: a pull request whose diagrams no longer match fails, and a workflow writes them again. Hand changes live in an overrides file, so they survive every run. It is for anyone who keeps architecture or schema diagrams in a repository. A working repository to copy from is plotwork-official/plotwork-examples (opens in a new tab).
- GitHub Actions
- GitHub
TL;DR
| Question | Answer |
|---|---|
| Where do I say what to draw? | In sources in plotwork.config.json: for each diagram, the files (from), their format, and where it goes (out), a .diagram file or a fence of a Markdown file. |
| What makes the diagrams? | npx @buun_group/plotwork generate on your machine, or the GitHub Action with command: generate. Both run the same importers as the import_source tool, locally. |
| How do I change a generated diagram? | In its overrides file (infra.overrides.json beside infra.diagram): labels, icons, tones, types, groups, hidden boxes, extra edges and lines, header options. Never in the generated file, which the next run writes over. |
| I already edited the file by hand. | Run plotwork generate --capture: it writes your edits into the overrides file, then makes the diagram again. |
| What fails a pull request? | A diagram that differs from what its files make now. The check names what changed: nodes and edges added, removed, and changed. |
| How are the diagrams written back? | The Action in write mode opens or edits one pull request (the default), or commits to the branch with on-drift: commit. |
| Can it publish too? | Yes, with publish: true and an API key in a secret: a stable link for a personal key, or the organisation library for an organisation key. |
| Is there a whole example? | plotwork-examples (opens in a new tab): a small company's Terraform, OpenAPI, Compose, Kubernetes, SQL, workflow, and CSV files, with a source for each, an overrides file, a Markdown fence, and the check, regenerate, and smoke workflows. |
| Will the pictures look right on GitHub in dark mode? | Set "schemes": "auto": each diagram gets one SVG that follows the reader's light or dark setting, so a plain link or <img> shows it right. |
| Does anything leave the machine? | Not by default. Only publishing sends anything, and then only the generated diagram source, never the files it was made from. |
1. Name the sources
Each entry of sources is one diagram. Paths start at the folder that holds the settings file.
{ "$schema": "https://docs.plotwork.dev/schemas/plotwork.config.json", "sources": [ { "name": "infra", "from": "infra/**/*.tf", "format": "terraform", "out": "docs/infra.diagram" }, { "name": "api", "from": "openapi.yaml", "format": "openapi", "view": "map", "out": "docs/api.md#endpoints" }, { "name": "services", "from": [ "compose.yaml", "compose.override.yaml" ], "format": "compose", "out": "docs/architecture.md#services" }, { "name": "schema", "from": "prisma/schema.prisma", "format": "prisma", "out": "docs/schema.diagram", "options": { "tables": [ "User", "Org*" ] } } ]}| Field | Meaning |
|---|---|
from | A file, a folder, or a glob, or a list of them. One that starts with ! leaves out what it matches. .git, node_modules, and .terraform are never read. |
format | sql, prisma, drizzle, openapi, terraform, terraform-plan, bicep, arm, pulumi, cloudformation, cdk, compose, kubernetes, helm, kustomize, backstage, structurizr, asyncapi, github-actions, gitlab-ci, azure-pipelines, csv, json, tree, or git-log, as Importing from code and data describes. |
out | A .diagram file, or a Markdown file and a fence id: docs/architecture.md#services. |
name | A short name for --source and the summary. The out path when left out. |
view, title | The picture of a format drawn more than one way (endpoints, map, sequence for OpenAPI; git, timeline for a git log; context, container, component for a Structurizr workspace), and the title. |
options | The importer's options: tables, paths, tags, operationId, chart, groupBy (owner for a Backstage catalog), varFiles, and root, the root module or CDK stack of several to draw (envs/prod), each a diagram of its own when left out, or the system or container a Structurizr view is about. |
overrides | The overrides file. Left out: infra.diagram has infra.overrides.json, and the fence services of docs/architecture.md has docs/architecture.services.overrides.json. |
pictures | For a .diagram out, also write its light and dark SVG beside it. On when left out. |
schemes | light-and-dark writes infra.svg and infra.dark.svg; auto writes one infra.svg that holds both. defaults.schemes, then light-and-dark, when left out. For a Markdown out it applies to every fence of that file. |
publish | Where the Action publishes it (section 6). |
Two sources that write one file, or a view the format does not draw, are S101 on their line of the settings file. A from that matches no file is S103.
A Markdown out needs a fence that says where the diagram goes. It may be empty; the first run fills it and writes the picture block under it, as sync does:
## Services
```diagram id=services```One picture for both themes
A light SVG opened on GitHub, or put in a page with a plain , shows its white cards in dark mode too. GitHub picks a dark file only from a <picture> element with a prefers-color-scheme: dark source. With schemes set to auto, each diagram gets one SVG that holds both schemes and switches inside the file with the same media query, so the file itself, a link to it, and a plain <img> all show the scheme the reader uses. The two schemes are painted from one layout, so the picture is the same as the two files, and the one file is smaller than the two together.
{ "defaults": { "schemes": "auto" }, "sources": [ { "name": "infra", "from": "infra/**/*.tf", "format": "terraform", "out": "docs/infra.diagram" } ]}Use auto for any diagram people see on GitHub. A Markdown fence's picture block is then one <img> instead of a <picture>. Switching a repository to auto removes each .dark.svg that Plotwork drew there, so no file is left behind; a check before that run reports the removal as drift. A file under that name that Plotwork did not draw is never touched. sync reads the same default for the fences it draws.
A git-log source reads a file. Write it in an earlier step, such as git log --oneline --graph -n 40 > docs/history.txt, or the check fails on every new commit.
2. Generate
npx @buun_group/plotwork generate| Command | What it does |
|---|---|
generate | Makes every diagram and writes the ones that changed: the .diagram file and its SVGs, or the fence and its picture block. |
generate --check | Writes nothing. Exits 1 when a diagram differs from what its files make, and prints what changed. |
generate --source infra | One source only. |
generate --capture | First writes the hand edits of each generated diagram into its overrides file. |
generate --format github | Each problem and each drift as a GitHub annotation. --format json prints the plan, --format sarif a SARIF report for code scanning, and --format junit a JUnit report. |
generate --since origin/main | Only the sources whose files, overrides, or diagram changed since the ref, and every source when the settings file changed. |
A drifted diagram prints its changes by meaning, from the same comparison as compare_diagrams: reordered or reformatted lines are no change, and a renamed id is a rename.
drift: docs/infra.diagram (infra, terraform from infra/**/*.tf) Added 1 node (logs). Removed 1 edge (web -> db). + node logs - edge web -> dbfailed: 4 diagram(s) checked, 1 drifted, 0 error(s)The output is the same for the same files on every machine. A generated file starts with a comment that names its source and its overrides file:
# Generated by plotwork generate from the source infra (terraform: infra/**/*.tf).# The next run writes over this file. Put changes in docs/infra.overrides.json, or run plotwork generate --capture# after an edit to keep it there, then run plotwork generate.3. Overrides
An overrides file is JSON beside the diagram. It is applied after every import, so a change to the Terraform and a change to a label never fight.
{ "$schema": "https://docs.plotwork.dev/schemas/plotwork.overrides.json", "header": { "options": { "direction": "right", "legend": "off" } }, "items": { "aws_s3_bucket.assets": { "label": "Assets bucket", "tone": "orange" }, "aws_instance.web": { "group": "compute", "type": "function" }, "aws_internet_gateway.gw": { "hide": true } }, "groups": { "compute": { "label": "Compute", "type": "app-tier", "parent": "aws_vpc.main" } }, "edges": [ { "from": "aws_instance.web", "to": "aws_lb.web", "label": "health", "arrow": "-->" }, { "from": "aws_db_instance.orders", "to": "aws_db_subnet_group.orders", "hide": true } ], "lines": [ { "statement": "node runbook step \"Runbook\" sub=\"Rotated nightly\"", "into": "compute" } ]}| Section | What it changes |
|---|---|
header | title, and header options such as direction, icons, legend, or desc. null takes an option away. |
items | For each box, group, or table, by name: label, sub, icon, tone, type (a node type, or a group's type), group (move it into a group; null is the top level), other options, and hide. |
groups | Groups to add, by the id they get: label, type, sub, icon, parent. Move items into them with group. |
edges | Edges to add (from, to, label, arrow, options), or with hide, the import's edges from one end to the other to take away. |
lines | Any other line, such as a note box, as it is, optionally into a group. |
How a thing is named
An entry names a thing by the name the file gives it, which does not change when the file grows. The id in the diagram can: two resources named web become web and web_2 in the order they are read.
| Format | Name | Example |
|---|---|---|
terraform, terraform-plan | The resource address, after its module and with its copy's key, the same for a plan as for the files | aws_s3_bucket.logs, module.network.azurerm_subnet.this["app"] |
bicep, arm | The symbolic name, after its module | module.data.vault, vnet/subnets/app, queue['orders'] |
pulumi | The logical name, or the URN when two share one | labels |
cloudformation | The logical id | OrdersTable |
cdk | The construct path, without the Resource that wraps it | OrdersApi/Ordering/Handler, OrdersApi/Ordering |
compose | service:, network:, or volume: and its name | service:api |
kubernetes, helm, kustomize (built) | Namespace, kind, and name; a Helm release: or chart: frame | payments/Deployment/api, release:shop |
kustomize (a folder) | frame:, kustomization:, file:, patch:, or generator: and its path | file:base/deployment.yaml |
backstage | The entity reference | component:default/orders-api |
structurizr | The DSL identifier, its path with hierarchical identifiers, or the path of names; group: and its path | banking.api, group:banking/Payments |
asyncapi | app: and the title, channel: and the address, broker: and the protocol | channel:orders.placed.v1 |
github-actions | job: and the job id; on is the trigger | job:test |
gitlab-ci | job:, stage:, or trigger: and the name | job:unit-tests, stage:deploy |
azure-pipelines | stage: and its name; job: and Stage.Job, or the job alone in a file of only jobs | job:Build.UnitTests |
sql, prisma, drizzle | The table name | users |
| The others | The id in the diagram | orders |
The id in the diagram works for every format too, when it is stable enough for you.
When the importer changes
A new version of the importer can draw the same files differently: a Terraform stack whose local modules are now framed, or whose for_each copies are now drawn one by one. A stack the new rules have nothing to say about (no module, no for_each or count) is drawn exactly as before, byte for byte. When an upgrade does redraw a diagram, generate --check reports it as drift; run plotwork generate once, review the change, and commit it.
When the file changes
An entry whose thing is gone is a warning, S201, on its line of the overrides file: Stale override for aws_s3_bucket.logs: the import no longer draws it. An entry the diagram cannot take, such as an added group whose id is taken, is S202. Neither fails a run; remove or rename the entry. A change that leaves the diagram with an error, such as an unknown node type, is that error, on its line of the generated file.
Keep a hand edit
npx @buun_group/plotwork generate --capture --source infra--capture compares the file you edited with what the import and the current overrides make, writes each difference into the overrides file (labels, options, types, moves, hidden and added boxes, groups, edges, and lines), and then makes the diagram again, so it comes back the same with its comment on top. A source split into several diagrams is captured part by part, each against its own file, into its one overrides file.
4. Fail a pull request on drift
name: diagrams
on: pull_request:
permissions: contents: read
jobs: drift: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: persist-credentials: false - uses: plotwork-official/plotwork-action@dcec4036897b73bd3ea724b3991f4fd0ac880757 # v1.0.0-beta.14 with: command: generateThe samples pin the latest release of the Action by its commit. @v1 becomes available at the first stable release.
Each drifted diagram is an error annotation on its file, and the job summary has a table of every diagram: where it comes from, the nodes and edges added and removed, its status, a link to its file, and the engine's table of what changed. A job summary cannot show a picture from the runner, so the changes are written out; the pull request from write mode, and a published diagram, show the pictures.
5. Write the diagrams back
Open a pull request
On a push to the default branch, or on a schedule, write mode makes the diagrams again and pushes them to one branch with one pull request. Each run replaces that branch with one commit on the latest base and edits the open pull request rather than opening another. Once nothing drifts, the next run closes that pull request with a comment and deletes its branch, when the branch's last commit is the action's own.
name: diagrams-regenerate
on: push: branches: [main] schedule: - cron: '0 4 * * *'
permissions: contents: read
jobs: regenerate: runs-on: ubuntu-latest permissions: contents: write pull-requests: write steps: - uses: actions/checkout@v7 - id: plotwork uses: plotwork-official/plotwork-action@dcec4036897b73bd3ea724b3991f4fd0ac880757 # v1.0.0-beta.14 with: command: generate mode: write pr-branch: plotwork/diagrams pr-title: 'docs: regenerate diagrams from their sources'| Setting | Why |
|---|---|
contents: write, pull-requests: write | To push the branch and open the pull request. |
| Settings, Actions, General, "Allow GitHub Actions to create and approve pull requests" | GitHub refuses a pull request from the workflow's token without it. |
github-token | A pull request opened with the workflow's own token starts no workflow, so its checks do not run. Pass a GitHub App or personal token to have them run. |
on-drift: pr refuses a pull_request run: such a run is on a merge commit, not on a branch.
Commit to the branch
on: pull_request:
permissions: contents: read
jobs: regenerate: runs-on: ubuntu-latest permissions: contents: write steps: - uses: actions/checkout@v7 with: ref: ${{ github.head_ref }} - uses: plotwork-official/plotwork-action@dcec4036897b73bd3ea724b3991f4fd0ac880757 # v1.0.0-beta.14 with: command: generate mode: write on-drift: commit commit-message: 'docs: regenerate diagrams'The commit carries the trailer Plotwork-Generated: true. A run started by that commit does nothing, so a token that does start workflows never loops. A pull request from a fork cannot be pushed to with the workflow's token. on-drift: fail writes the diagrams in the workspace and fails, for a later step of your own.
6. Publish
With publish: true and a key in PLOTWORK_API_KEY, write mode publishes each source that has a publish section.
{ "name": "infra", "from": "infra/**/*.tf", "format": "terraform", "out": "docs/infra.diagram", "publish": { "organization": "acme", "folder": "Platform/Infra" }}publish field | Meaning |
|---|---|
alias | A stable link of a personal key: one address that always shows the latest version. See Hosted diagrams. |
organization | File it in this organisation's library. Left out with no alias, the organisation of brand.organization is used. An organisation key publishes only to its own organisation, and needs neither: the API names the key's organisation and the diagram goes to its library. An organisation's diagrams have no public address, so an alias makes no stable link there, and the run says so. |
folder, name, theme | Where it is filed, its name (the title when left out), and light, dark, or auto. |
embedSource | Write the diagram source into the hosted picture. Left out, it is not. |
required | Fail the run when it cannot be published, such as with no key. Left out, a skipped publish is a notice. |
name: diagrams-publish
on: push: branches: [main]
permissions: contents: read
jobs: publish: runs-on: ubuntu-latest permissions: contents: write pull-requests: write steps: - uses: actions/checkout@v7 - uses: plotwork-official/plotwork-action@dcec4036897b73bd3ea724b3991f4fd0ac880757 # v1.0.0-beta.14 with: command: generate mode: write publish: true env: PLOTWORK_API_KEY: ${{ secrets.PLOTWORK_API_KEY }}The key is masked in the log and never printed. The repository's defaults and brand.organization go with the source, so the hosted picture takes the organisation's brand and icon packs when its plan includes them. The links are in the job summary, with the pictures, and in the published output.
7. Inputs and outputs
| Input | Default | Meaning |
|---|---|---|
command | check | generate for this guide. |
mode | check | check fails on drift and writes nothing; write makes the diagrams again. |
on-drift | pr | In write mode: pr, commit, or fail. |
pr-branch | plotwork/diagrams | The branch pr pushes to. The action owns it. |
pr-title | docs: regenerate diagrams from their sources | The pull request's title. |
commit-message | docs: regenerate diagrams from their sources | The commit message, before the trailer. |
publish | false | Publish the sources that have a publish section, in write mode. |
github-token | ${{ github.token }} | The token that pushes and opens the pull request. |
since | A git ref: only the sources whose files changed since it run. Check out with fetch-depth: 0. | |
sarif-file, junit-file | Files to write a SARIF report (for github/codeql-action/upload-sarif) and a JUnit report to. | |
config, working-directory | As for every command. See Keep diagrams in sync in CI. |
| Output | Meaning |
|---|---|
stale | Diagrams that drifted. |
files | The drifted diagrams in check mode; the files written in write mode. |
changes | A JSON array, one entry per drifted diagram: its name, its file, the number of changes, and the sentence that names them. |
pull-request | The address of the pull request opened or edited, or empty. |
published | A JSON array of each source published: name, kind (personal or organization), url, stableLink, svgUrl (an organisation's picture, served to the key), id, and organization. |
errors, warnings | The problems found. |
8. Limits
| Limit | Value |
|---|---|
| Sources in one settings file | 250, in a file of at most 256 KB; a larger repository gives each team a settings file of its own and runs one job per working-directory |
Patterns in one from | 20 |
| Files one source reads | 2,000, at the local import limits, which only guard memory |
| An overrides file | 128 KB, and 500 entries in each section |
| A source too big for one picture | Written as several .diagram files, infra.diagram, infra.2.diagram and on; a Markdown fence holds one diagram, so such a source needs a .diagram out or fewer things chosen with options |
| Parts a source no longer has | Removed with their pictures, but only a file that carries that source's generated comment |
See also
| Page | Why |
|---|---|
| Importing from code and data | What each format draws, and its options |
| Repository settings | The rest of plotwork.config.json: defaults, rules, and brand |
| Keep diagrams in sync in CI | The other commands of the GitHub Action |
| Hosted diagrams | Links, stable links, and what a plan hosts |
| Organisations | Organisation keys and the shared library |
| Enterprise rollout | A reusable workflow, required checks, code scanning, many teams in one repository, and air-gapped runners |
| plotwork-examples (opens in a new tab) | A working repository with every format, and the smoke test of each release |