Skip to content

A few lines of KDL. Readable, locked Nix.

knixl generates maintainable, human-readable NixOS modules from small amounts of opinionated KDL. The KDL is the source of truth, the generated Nix is a committed build artefact, and an upgrade can never change your output without telling you first.

You write

hosts/web.kdl
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/hosts/web.nix
# 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.

generate, check, and the reference for web-service
knixl generate && knixl check && knixl doc web-service

Stale 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.

a KDL edit makes a file stale; a hand-edit makes it drifted
knixl plan && knixl generate

Checked 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.

a misspelt KDL child refused before any Nix is written (exit 5)
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.

a version-pinned package landing in the lock and the generated Nix (the build test is skipped here to keep the recording short)
knixl install curl@8.4.0 --host web --yes --no-abi-check

Modules 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.

stdlib/openssh/knixl-module.kdl
// 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.

the TUI: home, browsing modules, and a module's reference
knixl tui

Generate 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.

flake inputs pinned from the lock, then generated and checked
knixl upgrade --yes && knixl generate && knixl check

How 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.

  1. 1ParseKDL with source spans, so every error points at your file
  2. 2Validateeach node against its module schema; unknown nodes refuse
  3. 3Lowermodules turn nodes into typed Nix assignments
  4. 4Checkevery option path against the real NixOS option set
  5. 5Emitreadable Nix source, imports wired, repeats hoisted
  6. 6Formatwith the pinned nixfmt, so output is byte-stable
  7. 7Lockhash, reconcile, and only then write generated/

Install v1.5.2

Terminal window
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/1stvamp/knixl/releases/download/v1.5.2/knixl-installer.sh | sh

Prebuilt 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.

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.