A few lines of KDL. Readable, locked Nix.
You write
host "web" { system "x86_64-linux"
web-service "example.com" { upstream "http://127.0.0.1:3000" acme email="ops@example.com" hardened #true }
raw-nix { #""" systemd.services.nginx.serviceConfig.MemoryMax = "512M"; """# }}knixl generates
# Generated by knixl 1.5.2 from hosts/web.kdl# Do NOT edit. Regenerate from the KDL source.# Overrides: add a sibling module and use lib.mkForce / lib.mkAfter.{ config, lib, pkgs, ...}:{ nixpkgs.hostPlatform = "x86_64-linux"; networking.hostName = "web"; services.nginx.enable = true; services.nginx.recommendedTlsSettings = true; services.nginx.recommendedProxySettings = true; services.nginx.recommendedOptimisation = true; services.nginx.virtualHosts."example.com".forceSSL = true; services.nginx.virtualHosts."example.com".enableACME = true; services.nginx.virtualHosts."example.com".locations."/".proxyPass = "http://127.0.0.1:3000"; services.nginx.virtualHosts."example.com".serverAliases = [ ]; security.acme.acceptTerms = true; security.acme.certs."example.com".email = "ops@example.com"; services.nginx.virtualHosts."example.com".locations."/".extraConfig = '' add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; ''; # raw-nix passthrough systemd.services.nginx.serviceConfig.MemoryMax = "512M";}Compile, check, and read back
knixl generate expands each host into an idiomatic NixOS module, formats it with the pinned nixfmt, and records every output’s hash in knixl.lock.kdl. Run it twice and you get the same bytes.
knixl check is the CI gate: it recomputes everything and exits non-zero if anything is out of date, drifted or invalid. knixl doc <node> prints the typed reference for any module, straight from its schema.
knixl generate && knixl check && knixl doc web-serviceStale or drifted, knixl can tell
Change the KDL and the generated file is stale: generate rewrites it. Edit a generated file by hand and it is drifted: knixl tells the two apart with a third hash, and refuses to overwrite a human’s edit (exit 3) unless you say --accept-drift.
Nothing is silently lost, and CI can branch on the exit code.
knixl plan && knixl generateChecked against real NixOS options
Every option path knixl emits is checked against the nixosOptionsDoc option set for your pinned nixpkgs, plus the out-of-tree modules you declare (disko, sops-nix, home-manager). A misspelt option or a string where an integer belongs refuses to generate, before nixos-rebuild ever sees it.
KDL that no module understands refuses too (exit 5), so a typo never quietly drops out of your config.
knixl check; echo $?Pin a package by version
knixl install curl@8.4.0 finds the nixpkgs commit that shipped that version, records it per host in the lock, and emits the package from that commit. knixl build-tests how to pin it: overriding the current package’s source when that builds, and falling back to the whole package from the old commit when it doesn’t.
Each host can also pin its own baseline nixpkgs release, or an exact commit.
knixl install curl@8.4.0 --host web --yes --no-abi-checkModules are KDL too
A module is a schema plus an emit template. The stdlib ships inside the binary (openssh, tailscale, zfs, users, home-manager, web services and more), and your own modules in modules/ sit beside it, written the same way. Fetched modules are pinned in the lock like everything else.
Where a layer shadows another, knixl says so rather than picking silently.
// A declarative knixl module: hardened OpenSSH (password auth off, key auth on)// with optional listen ports and login knobs. All stock NixOS options.
module name="openssh" version="1.0.0" { summary "Hardened OpenSSH (password auth off) with port and login knobs." claims-node "openssh"
schema { child "port" type="int" repeated=#true \ doc="Listen port(s) (services.openssh.ports). NixOS default [ 22 ] if omitted." child "permit-root" repeated=#true \ doc="PermitRootLogin value, e.g. \"no\" or \"prohibit-password\". At most one." { arg "value" type="string" required=#true } child "x11-forwarding" type="bool" \ doc="Enable X11 forwarding (off by default)." }
emit { set "services.openssh.enable" #true set "services.openssh.settings.PasswordAuthentication" #false set "services.openssh.settings.KbdInteractiveAuthentication" #false set "services.openssh.ports" (collect-opt)"port" when-flag "x11-forwarding" { set "services.openssh.settings.X11Forwarding" #true } for-each "r" in "permit-root" { set "services.openssh.settings.PermitRootLogin" "{r.value}" } }}A TUI when you want one
knixl tui installs packages with a live preview of the generated Nix, browses the module library and its schemas, and drafts new modules with the schema validated as you type.
Everything it does is also a plain command, so scripts and CI never need it.
knixl tuiGenerate the whole system flake
Add a system {} block and knixl generates flake.nix as well: every host as a nixosConfiguration, installer ISOs and lxc guest images as packages. Declare flake inputs and their modules, and knixl pins each input’s commit in its own lock, so the flake.lock nix writes is fixed by it.
Migrated systems come out byte-identical: a real host moved onto a generated flake evaluates to the same store path it ran before.
knixl upgrade --yes && knixl generate && knixl checkHow it works
One pure function from your KDL, the tool and module versions, the formatter and the oracle’s nixpkgs to output bytes. plan computes the whole thing without writing a byte; every other command is a policy over that plan.
- 1ParseKDL with source spans, so every error points at your file
- 2Validateeach node against its module schema; unknown nodes refuse
- 3Lowermodules turn nodes into typed Nix assignments
- 4Checkevery option path against the real NixOS option set
- 5Emitreadable Nix source, imports wired, repeats hoisted
- 6Formatwith the pinned nixfmt, so output is byte-stable
- 7Lockhash, reconcile, and only then write generated/
Install v1.5.2
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/1stvamp/knixl/releases/download/v1.5.2/knixl-installer.sh | shPrebuilt binaries for Linux (gnu and musl) and macOS, on x86_64 and aarch64. Each archive carries GitHub build provenance, so you can check it with gh attestation verify <archive> -R 1stvamp/knixl.
cargo install knixl --version 1.5.2Builds from crates.io. Needs Rust 1.87 or newer.
mise use github:1stvamp/knixl@1.5.2Installs the release binary and verifies its attestation.
knixl runs nixfmt on its output, so have it on your PATH (or point KNIXL_FORMATTER at it). nix itself is only needed for the option oracle and pin verification.
