Keyboard shortcuts

Press ← or β†’ to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

FileContents
topology.d2, topology.svgfleet overview: hosts as tables of units and ports, ingress from Internet, LAN and mesh, flows between hosts
overlay.d2, overlay.svgevery mesh: members, the routes and exit nodes they carry, the control server
networks.d2, networks.svgL3: every network, derived from interface addresses or declared, the hosts on it by interface and address, default gateways
topology-<host>.d2, topology-<host>.svgper host: its units, local flows, and the hosts it talks to
modules-<host>.d2, modules-<host>.svgper host: entry file to the modules it imports, each listing its units
inputs.d2, inputs.svgflake input graph; follows edges dashed
theme.d2classes and palette every .d2 imports
wiki/book.toml, wiki/d2.css, wiki/d2.jsthe book: diagrams fit the page, β€’ opens one to pan and zoom
wiki/src/index.mda stub for your own overview, replaced by indexPage
wiki/src/architecture.mdtopology diagram, networks board, overlay board
wiki/src/hosts.mdper host: location, platform, users, firewall link, default gateway, interfaces, services and their files, topology, module tree
wiki/src/services.mdevery service, the hosts running it, the file defining it
wiki/src/endpoints.mdfqdn, port, scope, host, service; πŸ”’ marks what only the VPN (mesh) reaches
wiki/src/firewall.mdper host: open ports on every interface and on each one, trusted interfaces, findings
wiki/src/inputs.md, inputs-timeline.svgevery input with its rev and lock date, how many days each trails the newest, plus duplicate detection
wiki/src/closures.md, closures.svgopt-in: per-host closure size, largest paths, fleet sharing, stacked bar chart

Next

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

AdapterReadsGives
nginxvhost forceSSL, addSSL, onlySSL, listen, listenAddresses, proxyPass; firewallnames, ports; a literal proxyPass is a connection, any other vhost an expose
caddyvhost hostName, serverAliases, listenAddresses, extraConfig; firewallnames, ports from each site address; a literal reverse_proxy upstream is a connection, any other site an expose
headscaleport, settings.server_url, settings.prefixesport; name and tailnet from a non-loopback server_url, the tailnet named after it
tailscaleextraUpFlags, 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
grafanasettings.server.http_port, settings.server.domainport, name
opensshports, listenAddressesports

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>:

OptionTypeEffect
rolestringnode label, proxy say
kindinfra, appnode style, adapters set infra
scopepublic, mesh, landefault for exposes and connections; unset on an expose, the firewall decides
descriptionlinesServices page section
namesfqdnswhat the unit answers to
portsportswhat 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" ];
OptionTypeEffect
nixdiag.networks.<name>.cidrsCIDRsaddresses inside join this network
nixdiag.networks.<name>.kindpublic, mesh, lancloud style; else from the addresses
nixdiag.networks.<name>.serverhostcontrol 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

toResolves to
internetthat cloud
nashost
nas/grafanaunit on host
grafanaunit, when one host has it
hs.ts.exampleunit with that fqdn in names, expose or connections
http://127.0.0.1:8080unit on the same host with that port
http://tom.ts.example:3000host tom, its unit on 3000
lannetwork with that name, after hosts and units
192.168.1.0/24network holding it, a new one if none
http://192.168.1.20:3000host 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

AdapterRoleMaintainersReads
caddyproxykucendroservices.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
grafanamonitorkucendroservices.grafana.settings.server.domain, services.grafana.settings.server.http_port
headscalemesh-controlkucendroservices.headscale.port, services.headscale.settings.server_url, services.headscale.settings.prefixes.v4, services.headscale.settings.prefixes.v6
nginxproxykucendroservices.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
opensshremote-accesskucendroservices.openssh.listenAddresses, services.openssh.ports
tailscalemesh-nodekucendroservices.tailscale.interfaceName, services.tailscale.extraSetFlags, services.tailscale.extraUpFlags

master

2026-10-132026-10-142026-10-152026-10-162026-10-172026-10-182026-10-192026-10-202026-10-212026-10-222026-10-232026-10-24
vmβœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“
caddyβœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“
grafanaβœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“
headscaleβœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“
nginxβœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“
opensshβœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“
tailscaleβœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“

Last run at nixpkgs 7e53cc6.

nixos-26.05

2026-10-132026-10-142026-10-152026-10-162026-10-172026-10-182026-10-192026-10-202026-10-212026-10-222026-10-232026-10-24
vmβœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“
caddyβœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“
grafanaβœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“
headscaleβœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“
nginxβœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“
opensshβœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“
tailscaleβœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“βœ“

Last run at nixpkgs da6dc0e.

nixos-unstable

2026-10-132026-10-142026-10-152026-10-162026-10-172026-10-182026-10-192026-10-202026-10-212026-10-222026-10-232026-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.

ArgumentDefaultEffect
pkgsrequirednixpkgs instance to build with
flakerequiredflake to document, usually self
title"Infrastructure wiki"book title
hostsalllist of host names to restrict to
indexPageseeded stubyour 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
buildWikitrueset 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
closuresfalseper-host closure sizes: true, or a list of hosts; builds those systems
closuresExclude[ ]hosts to leave out of closures = true
generatedflake.lastModifiedunix time shown under every page title; null hides the line
revisionflake.shortRev, else dirtyShortRevrevision shown with generated

Closure metrics

closures builds every host it measures. Nar sizes exist only for realised paths, so each measured system.build.toplevel becomes a build input. Mostly substituted, but budget for it. NixOS only.

valuemeasured
falsenothing
trueevery 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.

OptionDefaultEffect
serve.enablefalsecreate the vhost
serve.docsrequireddocs derivation, typically lib.mkDocs { … }
serve.virtualHostrequirednginx 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.

FlagEffect
--facts FILEfacts, - reads stdin
--repo DIRthe documented repo, for the module tree and flake.lock
--out DIRoutput directory
--closures FILEthe closures.json that mkDocs { closures = true; } produces
--title "my wiki"book title, used when seeding book.toml
--extra-page Runbooks=./runbooks.mdcopy a hand-written page in and link it, repeatable
--extra-link Termux=termux.mdSUMMARY entry for a page another tool writes, repeatable
--no-svgwrite .d2 only, skip d2
--theme lightlight or dark, default dark; wiki diagrams carry both
--background "#ffffff"diagram canvas fill, default transparent
--color public=#ff5555palette 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

Open the demo wiki

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.