nixdiag
Static infrastructure docs from any Nix flake: topology, module tree, flake
inputs and an mdBook wiki from nixosConfigurations and
darwinConfigurations.
nix run github:kucendro/nixdiag
Topology comes from the NixOS module system. Adapters read the options you
already set, nixdiag.* options override them.
Both are the two-host fixture, the live demo.
What you get
| File | Contents |
|---|---|
topology.d2, topology.svg | fleet overview: hosts as tables of units and ports, ingress from Internet, LAN and mesh, flows between hosts |
overlay.d2, overlay.svg | every mesh: members, the routes and exit nodes they carry, the control server |
networks.d2, networks.svg | L3: every network, derived from interface addresses or declared, the hosts on it by interface and address, default gateways |
topology-<host>.d2, topology-<host>.svg | per host: its units, local flows, and the hosts it talks to |
modules-<host>.d2, modules-<host>.svg | per host: entry file to the modules it imports, each listing its units |
inputs.d2, inputs.svg | flake input graph; follows edges dashed |
theme.d2 | classes and palette every .d2 imports |
wiki/book.toml, wiki/d2.css, wiki/d2.js | the book: diagrams fit the page, β€’ opens one to pan and zoom |
wiki/src/index.md | a stub for your own overview, replaced by indexPage |
wiki/src/architecture.md | topology diagram, networks board, overlay board |
wiki/src/hosts.md | per host: location, platform, users, firewall link, default gateway, interfaces, services and their files, topology, module tree |
wiki/src/services.md | every service, the hosts running it, the file defining it |
wiki/src/endpoints.md | fqdn, port, scope, host, service; π marks what only the VPN (mesh) reaches |
wiki/src/firewall.md | per host: open ports on every interface and on each one, trusted interfaces, findings |
wiki/src/inputs.md, inputs-timeline.svg | every input with its rev and lock date, how many days each trails the newest, plus duplicate detection |
wiki/src/closures.md, closures.svg | opt-in: per-host closure size, largest paths, fleet sharing, stacked bar chart |
Next
- Quickstart
- Topology: adapters,
nixdiag.*options, overrides - Build and serve:
mkDocs, the nginx module - CLI: flags
Quickstart
Render an existing flake
cd ~/my-nix-config
nix run github:kucendro/nixdiag
Prints the store path of the docs. Open wiki/book/index.html in it.
Start a new flake
nix flake init -t github:kucendro/nixdiag
nix build .#docs
First override
{
nixdiag.units.nginx.role = "gateway";
nixdiag.units.exporter.connections = [ { to = "grafana"; label = "metrics"; } ];
}
More in Topology.
Keep it honest in CI
checks.x86_64-linux.docs = self.packages.x86_64-linux.docs;
An unresolved connection fails nix flake check.
Topology
{
services.headscale = {
enable = true;
port = 8080;
settings.server_url = "https://hs.ts.example";
};
services.nginx.virtualHosts."hs.ts.example" = {
forceSSL = true;
locations."/".proxyPass = "http://127.0.0.1:8080";
};
}
Adapters turn that into headscale on 8080, a proxy exposing hs.ts.example
on 443 to the internet, and the connection between them. Override with
nixdiag.* options in plain Nix.
Adapters
| Adapter | Reads | Gives |
|---|---|---|
nginx | vhost forceSSL, addSSL, onlySSL, listen, listenAddresses, proxyPass; firewall | names, ports; a literal proxyPass is a connection, any other vhost an expose |
caddy | vhost hostName, serverAliases, listenAddresses, extraConfig; firewall | names, ports from each site address; a literal reverse_proxy upstream is a connection, any other site an expose |
headscale | port, settings.server_url, settings.prefixes | port; name and tailnet from a non-loopback server_url, the tailnet named after it |
tailscale | extraUpFlags, extraSetFlags, interfaceName | --login-server is a control connection to it, else internet; the interface on that serverβs tailnet, routing --advertise-routes and, with --advertise-exit-node, the internet |
grafana | settings.server.http_port, settings.server.domain | port, name |
openssh | ports, listenAddresses | ports |
Scope from listen addresses: 100.64.0.0/10 is mesh, RFC 1918 is lan,
anything else public when the firewall opens the port.
Options
nixdiag.units.<unit>:
| Option | Type | Effect |
|---|---|---|
role | string | node label, proxy say |
kind | infra, app | node style, adapters set infra |
scope | public, mesh, lan | default for exposes and connections; unset on an expose, the firewall decides |
description | lines | Services page section |
names | fqdns | what the unit answers to |
ports | ports | what it listens on |
expose | { port, udp, scope, name } | Endpoints row; an edge from the internet, or from the hostβs lan or mesh networks |
connections | { to, label, name, port, scope, plane } | outbound edges; name adds an Endpoints row |
nixdiag.description, scope, names, expose: the same for the host.
plane is data (default), control or mgmt: control is infrastructure
coordinating itself, a mesh node talking to its server; mgmt is you operating
it, scrapes, backups, deploys. The overview draws only data; host boards draw
every plane, control and mgmt thin and dashed.
Networks
Every static interface address puts its host on the subnet it belongs to. Name a subnet, or join more under one name, on any host:
nixdiag.networks.lan.cidrs = [ "192.168.1.0/24" "fd00:1::/64" ];
| Option | Type | Effect |
|---|---|---|
nixdiag.networks.<name>.cidrs | CIDRs | addresses inside join this network |
nixdiag.networks.<name>.kind | public, mesh, lan | cloud style; else from the addresses |
nixdiag.networks.<name>.server | host | control server; a mesh interface with it joins |
nixdiag.interfaces.<name> | { kind, addresses, routes, server } | an interface NixOS does not configure; routes are prefixes it carries, 0.0.0.0/0 makes an exit node |
A network belongs to the nixdiag.location of the hosts on it, so the same
range at two locations is two networks, 192.168.1.0/24 @ home and
192.168.1.0/24 @ lab. A declared name splits the same way into lan @ home
and lan @ lab; a mesh network never splits. A host without a location
joins the only copy of its range, and fails the build when there are several.
The same name declared differently at one location, or two declared networks that overlap without sitting at two different locations, fails the build.
The overlay board draws every mesh network: its members by interface, the
routes they carry, and the control server their control connections reach.
Any mesh shows up once an adapter or your config declares the network, the
member interfaces and the control connection; nothing on the board is
specific to tailscale.
Locations
nixdiag.location = "home";
Hosts sharing a lan or public network share a location: name it on one
host and the rest follow. A hostβs own nixdiag.location wins; two names in
one group leave its unnamed hosts out. The overview and the networks board
draw each location as a box around its hosts and the networks inside it;
mesh networks and the internet stay outside.
Firewall
The firewall page lists, per host, what networking.firewall opens on every
interface, on each one, and trusts, then what does not add up:
- a port open that no unit, port or expose uses
- an expose closed on every interface its scope reaches
- a trusted interface other than
lo: every port on it is open - a connection from another host to a port closed on every interface they share
A port counts as used only when nixdiag knows it: a service with no adapter
and no nixdiag.units entry shows as unused. Raw nftables or iptables rules
are not read.
An expose with no scope is reached from the networks behind every interface
the firewall opens its port on, and takes the widest of their kinds.
Overriding
{ lib, ... }:
{
nixdiag.units.nginx.role = "gateway";
nixdiag.units.grafana = {
scope = "mesh";
expose = [ { port = 3000; name = "grafana.ts.example"; } ];
connections = [ { to = "exporter"; label = "scrapes"; } ];
};
nixdiag.units.tailscale.connections = lib.mkForce [ ];
nixdiag.units.exporter = { };
}
Assignment wins over an adapter, lists append, mkForce replaces. An empty
attrset declares a unit no adapter knows.
Targets
to | Resolves to |
|---|---|
internet | that cloud |
nas | host |
nas/grafana | unit on host |
grafana | unit, when one host has it |
hs.ts.example | unit with that fqdn in names, expose or connections |
http://127.0.0.1:8080 | unit on the same host with that port |
http://tom.ts.example:3000 | host tom, its unit on 3000 |
lan | network with that name, after hosts and units |
192.168.1.0/24 | network holding it, a new one if none |
http://192.168.1.20:3000 | host with that address, its unit on 3000; else its network |
A network, CIDR or address found at several locations resolves at the source hostβs location; from a host with no location it fails the build.
Unresolved fails the build. lan is no longer built in: declare
nixdiag.networks.lan.cidrs and to = "lan" keeps working.
Adding an adapter
Copy nix/adapters/example.nix to nix/adapters/foo.nix. Every field is
explained there. Besides topology, an adapter may define networks and
interfaces: reads in, nixdiag.networks and nixdiag.interfaces out. tests is required: nix flake check boots the VMs it names
and compares the adapterβs claims to what runs.
Audit
nix run github:kucendro/nixdiag#audit -- nixos-unstable github:NixOS/nixpkgs/master
Checks every read against options.json, downloaded for a channel or built
for any nixpkgs ref: ok, renamed, unaudited, broken; --json for
rows. Every day at 20:00 Prague time the monitor workflow runs it against
master, nixos-unstable and nixos-26.05, boots the adapter VMs on both
channels, evaluates them on master, and records both on the data branch.
An adapter that is not ok gets an
issue mentioning its maintainers, which are GitHub handles; it closes once
every head is ok again.
Adapters
| Adapter | Role | Maintainers | Reads |
|---|---|---|---|
caddy | proxy | kucendro | services.caddy.virtualHosts.<name>.serverAliases, services.caddy.virtualHosts.<name>.extraConfig, networking.firewall.enable, services.caddy.virtualHosts.<name>.hostName, services.caddy.virtualHosts.<name>.listenAddresses, networking.firewall.allowedTCPPorts |
grafana | monitor | kucendro | services.grafana.settings.server.domain, services.grafana.settings.server.http_port |
headscale | mesh-control | kucendro | services.headscale.port, services.headscale.settings.server_url, services.headscale.settings.prefixes.v4, services.headscale.settings.prefixes.v6 |
nginx | proxy | kucendro | services.nginx.virtualHosts.<name>.addSSL, services.nginx.defaultListenAddresses, networking.firewall.enable, services.nginx.virtualHosts.<name>.forceSSL, services.nginx.virtualHosts.<name>.listen, services.nginx.virtualHosts.<name>.listenAddresses, services.nginx.virtualHosts.<name>.onlySSL, services.nginx.virtualHosts.<name>.locations.<name>.proxyPass, networking.firewall.allowedTCPPorts |
openssh | remote-access | kucendro | services.openssh.listenAddresses, services.openssh.ports |
tailscale | mesh-node | kucendro | services.tailscale.interfaceName, services.tailscale.extraSetFlags, services.tailscale.extraUpFlags |
master
| 2026-10-13 | 2026-10-14 | 2026-10-15 | 2026-10-16 | 2026-10-17 | 2026-10-18 | 2026-10-19 | 2026-10-20 | 2026-10-21 | 2026-10-22 | 2026-10-23 | 2026-10-24 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| vm | β | β | β | β | β | β | β | β | β | β | β | β |
caddy | β | β | β | β | β | β | β | β | β | β | β | β |
grafana | β | β | β | β | β | β | β | β | β | β | β | β |
headscale | β | β | β | β | β | β | β | β | β | β | β | β |
nginx | β | β | β | β | β | β | β | β | β | β | β | β |
openssh | β | β | β | β | β | β | β | β | β | β | β | β |
tailscale | β | β | β | β | β | β | β | β | β | β | β | β |
Last run at nixpkgs 7e53cc6.
nixos-26.05
| 2026-10-13 | 2026-10-14 | 2026-10-15 | 2026-10-16 | 2026-10-17 | 2026-10-18 | 2026-10-19 | 2026-10-20 | 2026-10-21 | 2026-10-22 | 2026-10-23 | 2026-10-24 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| vm | β | β | β | β | β | β | β | β | β | β | β | β |
caddy | β | β | β | β | β | β | β | β | β | β | β | β |
grafana | β | β | β | β | β | β | β | β | β | β | β | β |
headscale | β | β | β | β | β | β | β | β | β | β | β | β |
nginx | β | β | β | β | β | β | β | β | β | β | β | β |
openssh | β | β | β | β | β | β | β | β | β | β | β | β |
tailscale | β | β | β | β | β | β | β | β | β | β | β | β |
Last run at nixpkgs da6dc0e.
nixos-unstable
| 2026-10-13 | 2026-10-14 | 2026-10-15 | 2026-10-16 | 2026-10-17 | 2026-10-18 | 2026-10-19 | 2026-10-20 | 2026-10-21 | 2026-10-22 | 2026-10-23 | 2026-10-24 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| vm | β | β | β | β | β | β | β | β | β | β | β | β |
caddy | β | β | β | β | β | β | β | β | β | β | β | β |
grafana | β | β | β | β | β | β | β | β | β | β | β | β |
headscale | β | β | β | β | β | β | β | β | β | β | β | β |
nginx | β | β | β | β | β | β | β | β | β | β | β | β |
openssh | β | β | β | β | β | β | β | β | β | β | β | β |
tailscale | β | β | β | β | β | β | β | β | β | β | β | β |
Last run at nixpkgs 3ec6363.
Build and serve
inputs.nixdiag.url = "github:kucendro/nixdiag";
packages.x86_64-linux.docs = nixdiag.lib.mkDocs {
pkgs = nixpkgs.legacyPackages.x86_64-linux;
flake = self;
title = "my infrastructure wiki";
};
nix build .#docs: your flake evaluates its configurations, the sandbox
renders, nothing is committed back. nix run github:kucendro/nixdiag does
the same for the current directory without touching the flake.
| Argument | Default | Effect |
|---|---|---|
pkgs | required | nixpkgs instance to build with |
flake | required | flake to document, usually self |
title | "Infrastructure wiki" | book title |
hosts | all | list of host names to restrict to |
indexPage | seeded stub | your own wiki/src/index.md |
extraPages | { } | { Runbooks = ./runbooks.md; }, copied in and linked |
extraLinks | { } | { Termux = "termux.md"; }, SUMMARY entry for a page another tool writes |
extraAssets | { } | { "img/rack.png" = ./rack.png; }, copied into wiki/src before mdbook |
buildWiki | true | set false for diagrams and markdown only, no mdbook |
theme | "dark" | "light" or "dark"; wiki diagrams carry both |
background | "transparent" | diagram canvas fill, any color d2 reads |
colors | { } | palette overrides, see below |
closures | false | per-host closure sizes: true, or a list of hosts; builds those systems |
closuresExclude | [ ] | hosts to leave out of closures = true |
generated | flake.lastModified | unix time shown under every page title; null hides the line |
revision | flake.shortRev, else dirtyShortRev | revision shown with generated |
Closure metrics
closuresbuilds every host it measures. Nar sizes exist only for realised paths, so each measuredsystem.build.toplevelbecomes a build input. Mostly substituted, but budget for it. NixOS only.
| value | measured |
|---|---|
false | nothing |
true | every NixOS host, minus closuresExclude |
[ "nas" "tom" ] | exactly those |
A host that serves the docs is measured with an empty directory in the docsβ place, so a build may document the host that serves it. The Closures page says so under that host.
Each bar is one hostβs closure split into what every host carries, what some
carry, and what this host alone costs; each measured host also gets a treemap
of its packages. An unmeasured host keeps its row with β. Packages are
named without their store path, so the docs never retain the closures they
describe.
Chart colors: chartShared, chartPartial, chartUnique, chartMark,
chartInk, chartMuted, chartTrack, chartTileInk.
Build and root the systems in your own pipeline first, on the machine that
runs the docs build; the measurement then finds everything realised and costs
seconds of jq:
- run: |
for host in nas edge nixbook; do
nix build ".#nixosConfigurations.$host.config.system.build.toplevel" \
--out-link "/var/lib/ci/gcroots/$host"
done
- run: nix build .#docs
The docs hold no references to the systems, so anything unrooted goes at the
next nix-collect-garbage. The measurement is preferLocalBuild, so
invoking it elsewhere copies every closure to the invoking machine.
Diagram styling
theme = "light";
background = "#ffffff";
colors = { public = "#ff5555"; mesh = "#7fa7e8"; };
Names: appFill, appStroke, infraFill, infraStroke, baseFill,
baseStroke, hostFill, hostStroke, progFill, hostCloud, public,
lan, mesh, ink for text, line for plain edges. An unknown name is an
error listing the palette.
theme also picks the mdBook theme the seeded book.toml starts with:
navy for dark, light with coal for dark-preferring viewers otherwise.
Serving it
{
imports = [ inputs.nixdiag.nixosModules.default ];
services.nixdiag.serve = {
enable = true;
docs = inputs.self.packages.x86_64-linux.docs;
virtualHost = "wiki.example.com";
virtualHostExtra = {
listenAddresses = [ "100.64.0.1" ];
useACMEHost = "example.com";
forceSSL = true;
};
};
}
The vhost points straight at the docs derivation, so the wiki ships with every deploy. No daemon, no timer, no checkout.
| Option | Default | Effect |
|---|---|---|
serve.enable | false | create the vhost |
serve.docs | required | docs derivation, typically lib.mkDocs { β¦ } |
serve.virtualHost | required | nginx vhost name |
serve.subpath | "wiki/book" | path inside the derivation used as web root |
serve.virtualHostExtra | { } | merged into the vhost: TLS, listen addresses |
The module also declares the nixdiag.* options; mkFacts adds it itself
when a host does not import it. Keep one nixdiag input for both, a module
from another revision fails with a schema mismatch.
Printing
mdBookβs print icon lays the book out on one page, and the browserβs βSave as PDFβ turns that into a vector PDF, diagrams included. Choose landscape or a larger paper size where a dense diagram has to stay legible.
CLI
nixdiag --facts facts.json --repo . --out docs
Renders from a facts.json that lib.mkFacts produced and never calls
nix. This is what mkDocs runs in the sandbox; nix run github:kucendro/nixdiag builds it for the flake in the current directory.
| Flag | Effect |
|---|---|
--facts FILE | facts, - reads stdin |
--repo DIR | the documented repo, for the module tree and flake.lock |
--out DIR | output directory |
--closures FILE | the closures.json that mkDocs { closures = true; } produces |
--title "my wiki" | book title, used when seeding book.toml |
--extra-page Runbooks=./runbooks.md | copy a hand-written page in and link it, repeatable |
--extra-link Termux=termux.md | SUMMARY entry for a page another tool writes, repeatable |
--no-svg | write .d2 only, skip d2 |
--theme light | light or dark, default dark; wiki diagrams carry both |
--background "#ffffff" | diagram canvas fill, default transparent |
--color public=#ff5555 | palette override, repeatable, see styling |
SVG rendering needs d2 on PATH; the packaged binary wraps it in.
nix run github:kucendro/nixdiag#audit
Checks the adapters against nixpkgs, see Topology.
Live demo
The two-host fixture in tests/fixture/, rendered by nix build .#demo-docs:
around sixty lines of NixOS config and one nixdiag.units block produce
every page there.
The same render feeds checks.reference and supplies the diagrams on this
site, so fixture, tests, images and demo cannot drift apart.