Mein Lab ist ein GitOps-Monorepo, aus dem Flux Kubernetes-Cluster reconciled. Aber die Maschinen, von denen aus ich auf dieses Lab schaue — die ThinkPads, der Desktop, das MacBook, ein paar VMs —, haben ihre eigene deklarative Wurzel. Sie heißt fr0st, ist ein flake-parts -Flake, und dieser Beitrag nimmt sie von der Wurzel bis zum Wallpaper auseinander.
Frost auf dem Asphalt, der graue Himmel vor dir
Das Repo ist öffentlich über meinen Radicle -Seed-Node erreichbar:
# klassisch:
git clone https://seed.this-is-fine.io/zVi9VheaDwbEgCUQUQ9sLwpHuaMo.git ~/.config/nix
# oder via Radicle:
rad clone rad:zVi9VheaDwbEgCUQUQ9sLwpHuaMo
Stöbern lässt es sich im Web unter radicle.network/nodes/seed.this-is-fine.io .

Warum überhaupt ein eigenes Flake
Ich hätte eine der vielen fertigen NixOS-Konfigurationen forken können. Habe ich nicht, aus einem Grund: Ich wollte verstehen, wie die Komposition funktioniert, nicht nur dass sie funktioniert. fr0st ist deshalb bewusst klein gehalten („Minimal flake-parts configuration" steht in der Beschreibung) und folgt drei Leitideen:
- Eine Fabrik, viele Maschinen. Ein Host ist ein Datensatz, kein Sonderfall. Sechs Systeme entstehen aus denselben Builder-Funktionen.
- Konvention vor Konfiguration. Module und Profile werden vom Dateisystem entdeckt, nicht in einer zentralen Liste registriert. Eine neue Datei am richtigen Ort ist die Registrierung.
- Ein eigener Options-Namespace. Alles Lab-Eigene lebt unter
fr0st.*— Hardware-Toggles, Apps, Security-Features. Das obere API ist meins, das untere ist NixOS.
Die Wurzel: flake-parts
Die flake.nix ist erfreulich kurz. Sie deklariert Inputs und delegiert die gesamte Output-Logik an ein flake-parts-Modul:
outputs = inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; } ./flake;
Das Verzeichnis ./flake/ ist der eigentliche Einstieg. Sein default.nix legt die unterstützten Systeme fest und importiert die Teilmodule:
{
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ];
imports = [
../lib # die fr0st-eigene lib-Erweiterung
./overlays.nix
./packages.nix
./systems.nix # die Host-Definitionen
./formatter.nix
./dev.nix # die devShells
];
}
Die Inputs sind die übliche Mannschaft eines ernsthaften NixOS-Flakes, jeweils mit follows auf dasselbe nixpkgs, damit der Closure nicht explodiert: home-manager, sops-nix, disko, lanzaboote, nixos-anywhere, deploy-rs, nix-darwin, nixos-hardware, treefmt-nix, nixvim, nix-index-database, nur und firefox-addons. Ein separates nixpkgs-stable (NixOS 25.11) speist nur ein Stable-Overlay für die wenigen Pakete, die ich nicht von unstable ziehen will.
Die Fabrik: lib.system
Das Herzstück ist lib/system.nix. Hier wohnen die vier Builder, die aus einem Host-Datensatz eine fertige Konfiguration machen: mkNixOS, mkDarwin, mkHome und mkInstaller. Ihre Aufgabe ist immer dieselbe — die richtigen Module einsammeln und mit den richtigen specialArgs füttern.
flake/systems.nix ist dann nur noch eine Tabelle plus eine map darüber:
hosts = {
chupacabra = { system = "x86_64-linux"; type = "nixos";
extraModules = [ inputs.nixos-hardware.nixosModules.lenovo-thinkpad-x230 ]; };
squirk = { system = "x86_64-linux"; type = "nixos";
extraModules = [ inputs.nixos-hardware.nixosModules.lenovo-thinkpad-x13-intel ]; };
mean-six = { system = "x86_64-linux"; type = "nixos"; };
cozy-glow = { system = "aarch64-darwin"; type = "darwin"; };
amygdala = { system = "x86_64-linux"; type = "nixos"; };
};
nixosConfigurations = builtins.listToAttrs (map (name: {
inherit name; value = mkNixOS name hosts.${name};
}) nixosHosts) // { inherit installer; };
Ein neuer Host ist ein neuer Tabelleneintrag. Die extraModules sind der Ort, an dem hardware-spezifische
nixos-hardware
-Module andocken — der X230 will andere Quirks als der X13.
Die erweiterte lib
Bevor die Builder Module sammeln, bauen sie eine erweiterte lib. Das ist der Trick, der den ganzen fr0st.*-Komfort erst möglich macht: nixpkgs.lib wird mit einem eigenen Attributset angereichert, das dann in jedem Modul über das lib-Argument verfügbar ist:
mkExtendedLib = nixpkgsLib.extend (_: _super: {
fr0st = fr0stModule // paletteModule; # mkOpt, enabled/disabled, Farbpalette
theme = themeModule;
env = envModule;
gnupg = gnupgModule;
paths = pathsModule;
profile = self.lib.profile;
});
Für Home Manager wird dieselbe lib noch um hm (die Home-Manager-Helfer) erweitert. So kann ein Modul lib.fr0st.enabled, lib.theme.colors oder lib.profile.isNixOS schreiben, ohne irgendwo zu importieren — die Helfer reisen mit der lib mit.
Module finden, statt sie zu listen
Kein zentrales imports = [ ./a.nix ./b.nix … ]. Stattdessen läuft importModulesRecursive über modules/{nixos,home,darwin} und sammelt jedes Verzeichnis ein, das ein default.nix enthält:
importModulesRecursive = dirPath:
let walk = currentPath: let
entries = builtins.readDir currentPath;
directoriesWithDefault = lib.filter (name:
entries.${name} == "directory"
&& builtins.pathExists (currentPath + "/${name}/default.nix")
) (builtins.attrNames entries);
# … plus rekursiver Abstieg in alle Unterverzeichnisse
in directoryImports ++ subDirImports;
in walk dirPath;
Die Modulbäume sind nach Domäne sortiert — apps, hardware, security, services, system, virtualisation, theme, user. Eine neue Funktion heißt: ein neues Verzeichnis mit default.nix anlegen, und es ist Teil jedes Builds. Diese Konvention ist die ganze „Registrierung".

Der Options-Namespace: fr0st.*
Damit aus „Module finden" auch „Module konfigurieren" wird, definiert lib/module.nix ein paar winzige, aber allgegenwärtige Helfer:
mkOpt = type: default: description: mkOption { inherit type default description; };
mkBoolOpt = mkOpt types.bool;
enabled = { enable = true; };
disabled = { enable = false; };
enabled/disabled sind der Grund, warum sich eine fr0st-Host-Datei fast wie Prosa liest. So sieht der ThinkPad X13 (squirk) aus:
fr0st = {
profiles.nixos.workstation = enabled;
system.boot.secureBoot = true;
hardware = {
cpu.intel = enabled;
gpu.intel = enabled;
tpm = enabled;
fprintd = disabled;
};
virtualisation.kvm = { enable = true; platform = "intel"; };
security.sops = enabled;
};
Kein einziger NixOS-Low-Level-Schalter ist hier zu sehen. hardware.tpm = enabled expandiert irgendwo in modules/nixos/hardware/ zu den richtigen security.tpm2.*-Optionen — aber das ist die Sorge des Moduls, nicht des Hosts. Das obere API ist absichtlich schmal und sprechend.
Die Komposition: Profile
Zwischen einzelnen Modulen und einem konkreten Host steht eine mittlere Schicht: Profile. Ein Profil bündelt eine sinnvolle Menge an Modulen zu einer Rolle. importProfileModules liest sie schlicht als sortierte .nix-Dateien aus profiles/<env>/:
| Ebene | Profile (Auswahl) |
|---|---|
profiles/nixos/ | common, workstation, server, samba, vm, installer |
profiles/home/ | common, workstation, desktop, dev, kubernetes, secops, business, music, darwin, vm |
profiles/darwin/ | common |
Das workstation-Profil ist das beste Beispiel für Komposition. Es aktiviert sich nur, wenn es wirklich auf NixOS läuft (isNixOS config), und schaltet dann ein ganzes Set:
config = mkIf (cfg.enable && isNixOS config) {
services.displayManager.defaultSession = "hyprland-uwsm";
fr0st = {
profiles.nixos.common = enabled;
hardware = { audio = enabled; bluetooth = enabled; power = enabled;
gpu.opengl = enabled; yubikey = enabled; };
apps = { uwsm = enabled; hyprland = enabled; };
services = { udisks2 = enabled; printing = enabled; };
virtualisation.podman = enabled;
security = {
usbguard = { enable = true; extraRules = mkDefault workstationUsbRules; };
gnupg = { enable = true; enableSSHSupport = true; };
};
};
};
Ein Profil aktiviert also wieder andere fr0st.*-Optionen — Komposition bis nach unten. Derselbe Profilname kann auf NixOS und auf Home Manager unabhängig existieren (Samba nur system-seitig, Hyprland-Monitore nur home-seitig). Bemerkenswert: Das Workstation-Profil bringt
USBGuard
mit einem Basis-Regelsatz mit — angestöpselte USB-Geräte sind per Default blockiert, bis eine Regel (über Hash und Serial gepinnt) sie erlaubt. Auf einem Laptop, der auch mal in fremden Händen liegt, ist das ein billiges Stück Härtung.
Single-User by Design: die Identität
fr0st ist explizit für einen Menschen gebaut. Statt User-Verwaltung quer durch die Hosts zu streuen, lebt die Identität an einem Ort, lib/identity/defaults.nix:
{
name = "ff0x";
uid = 1000;
fullName = "Max Reineke";
email = "[email protected]";
signingKey = "651857F9EA219C8320D52332B6058AC136181295";
initialPassword = "nix";
sshKeys = [ "ssh-ed25519 AAAA…ff0x" /* … weitere Geräte */ ];
extraGroups = [ "wheel" "systemd-journal" "nix" "tss" ];
overrides = {
cozy-glow = { fullName = "Max Buelte"; email = "[email protected]"; };
};
}
Der overrides-Block ist die elegante Ausnahme von der Regel: Mein MacBook (cozy-glow) ist ein Arbeitsgerät, also bekommt es dort einen anderen Namen und eine andere Mail — aber alles andere erbt es vom Default. Die Builder ziehen den Usernamen aus genau dieser Quelle (self.lib.identity.defaults.name), sodass kein Host eine username-Zeile braucht.
initialPassword = "nix" (derselbe Wert wie die Installer-ISO), gesetzt beim ersten Anlegen des Users. Danach passwd — mit users.mutableUsers = true überlebt diese Änderung jeden Rebuild. Ein Passwort-Hash in Git zu pflegen wäre Aufwand ohne Gewinn.Die Hosts: Boot, Secure Boot, verschlüsselte Roots
Sechs Maschinen, drei Boot-Welten — und der Flake abstrahiert sie alle:
| System | Plattform | Hardware | Boot | Secure Boot | Encrypted Root |
|---|---|---|---|---|---|
| chupacabra | x86_64-linux | ThinkPad X230 (Mod) | BIOS | – | ✓ |
| squirk | x86_64-linux | ThinkPad X13 Gen5 | UEFI | ✓ | ✓ |
| mean-six | x86_64-linux | Custom Desktop | UEFI | ✓ | ✓ |
| cozy-glow | aarch64-darwin | MacBook Air M4 | UEFI | – | – |
| amygdala | x86_64-linux | QEMU VM | BIOS | – | – |
| installer | x86_64-linux | Live-ISO | BOTH | – | – |
Secure Boot kommt von
Lanzaboote
: Der lanzaboote.nixosModules.lanzaboote ist in jedem NixOS-Build dabei, scharf geschaltet wird er per fr0st.system.boot.secureBoot = true. Auf squirk und mean-six signiert der Bootloader sich selbst und den Kernel; das TPM des Geräts (hardware.tpm = enabled) hält die Schlüssel. Der modifizierte X230 mit Legacy-BIOS kann das nicht — also lässt man es dort schlicht weg, und derselbe Flake baut trotzdem.
Verschlüsselte Roots deklariere ich mit
disko
. Das Festplatten-Layout — LUKS, Partitionen, Dateisystem — ist eine disks.nix pro Host, die als Modul eingebunden wird. Disko macht aus dieser Deklaration sowohl das initiale Partitionieren als auch die Boot-Zeit-Mounts. Kein manuelles cryptsetup, kein Drift zwischen „wie es eingerichtet wurde" und „wie es deklariert ist".
cozy-glow fällt aus der Reihe: Es ist ein
nix-darwin
-System. Derselbe mkDarwin-Builder, dieselbe erweiterte lib, dieselben Home-Manager-Module — nur dass die System-Schicht macOS statt NixOS ist. Home Manager ist in beiden Welten eingebettet (über nixosModules.home-manager bzw. darwinModules.home-manager), nicht standalone. Das ist eine bewusste Entscheidung mit einer scharfen Kante:
nh home switch ist verboten. Home Manager ist Teil des System-Switches; aktiviert man die standalone homeConfigurations (die es nur zur Evaluation gibt), überspringt man die OS-injizierten Teile — UWSM, die Hyprland-Session-Verdrahtung, die verlinkten App-Pfade — und der Desktop ist kaputt, bis wieder ein nh os switch läuft. Auf NixOS heißt es nh os switch --ask ., auf dem Mac nh darwin switch --ask ..Provisionierung: nixos-anywhere und deploy-rs
Ein neuer Host entsteht nicht durch Klicken in einem Installer. Der Pfad ist:
template ──► systems/<host>/ ──► flake/systems.nix
│ │
▼ ▼
sops-init installer-ISO ──► nixos-anywhere
cp -r systems/_template/linux systems/<host>—default.nixundhome.nixanpassen.- Host in
flake/systems.nixeintragen. nix build .#packages.x86_64-linux.installer-iso→ Boot-Medium → Zielsystem booten.nixos-anywhere --flake .#<host> root@<ip>— nixos-anywhere partitioniert (via disko), installiert und rebootet die Zielmaschine vollständig fern.sops-init <host> root@<ip>undsops-init <host> <user>@<ip>— die Schlüssel-Zeremonie (gleich mehr).- Anwenden:
nh os switch --ask .auf dem Host, oderdeploy .#<host>vom Admin-Rechner.
deploy-rs
liefert den letzten Pfad: ein Top-Level-deploy-Output plus deployChecks, mit denen ich von meiner Workstation aus jeden Host remote rebuilden kann. Nix warnt zwar, dass deploy nicht im Flake-Schema steht — geschenkt, deploy-rs braucht es genau dort.
Secrets: das SOPS-Schlüsselregister
Secrets laufen über
sops-nix
, und das Interessante ist die Provisionierung der Schlüssel. Das Skript sops-init (in flake/scripts/) macht zwei Dinge in einem Lauf — Host-Schlüssel und User-Schlüssel:
sops-init <host> root@<ip> # erzeugt den Host-AGE-Key, patcht default.nix (sops = enabled)
sops-init <host> <user>@<ip> # erzeugt den User-AGE-Key, patcht home.nix
Die AGE-Keys werden aus den SSH-Host- bzw. User-Keys abgeleitet (ssh-to-age). Die .sops.yaml führt ein Register (keys.users, keys.hosts, keys.gpg), aus dem die creation_rules bei jedem sops-init-Lauf regeneriert werden — ein begleitendes Python-Skript sops_yaml.py hält das konsistent. Wichtig und teuer gelernt: Age und PGP sitzen in einem key_groups-Eintrag — also ein logisches ODER, nicht Shamir-Secret-Sharing. Landen sie versehentlich in zwei key_groups, entsteht ein shamir_threshold: 2, und SOPS quittiert nach einem schlechten updatekeys mit MAC mismatch.
Pro Feature gibt es eine useSops-Option (für Tailscale, PAM/U2F, Radicle …), die per Default auf fr0st.security.sops.enable zeigt. So ist „dieser Host nutzt SOPS" eine Entscheidung, die sich nach unten durch alle Features vererbt — und pro Feature überschreibbar bleibt.
Pakete und Overlays
Die Overlays werden — natürlich — wieder vom Dateisystem entdeckt: overlays/ wird nach Verzeichnissen mit default.nix durchsucht. Aktuell sind das ein stable-packages-Overlay (zieht einzelne Pakete aus NixOS 25.11) und ein waybar-Overlay. Dazu kommen nur und das hauseigene Paket-Overlay.
Die eigenen Pakete unter packages/ sind eine kleine Werkzeugkiste, die viel über den Alltag verrät:
| Paket | Zweck |
|---|---|
mitm-capture | TLS-Traffic mitschneiden/inspizieren |
reload-yubikey / gpg-key-cached | YubiKey- und GnuPG-Handgriffe automatisieren |
authy-pass-sync | Authy-Tokens in den pass-Store spiegeln |
external-ip-addr | aktuelle externe IP ermitteln |
trace-symlink / trace-which | Debug-Helfer für Symlink-Ketten und $PATH |
ca-certs / fonts / wallpapers | Trust-Anchor, Schriften, die Low-Poly-Hintergründe |
get-super-villain | … ein kleines Vergnügen am Rande |
flake/packages.nix exponiert dieses Set lazy als pkgs.fr0st und hängt auf x86_64-linux noch das installer-iso an.
Die devShells: Tooling, das mitkommt
flake/dev.nix definiert zwei Shells mit unterschiedlichem Gewicht. Die direnv-Shell ist absichtlich leicht — sie lädt automatisch beim Betreten des Verzeichnisses und tauscht die Shell nicht:
devShells.direnv = pkgs.mkShell {
packages = with pkgs; [ bash deadnix nh statix ];
shellHook = ''${envExports system}\n source ${direnvHook}'';
};
Die default-Shell ist das volle Programm: age deploy-rs git gnupg home-manager jq nh nixos-anywhere pre-commit python3 sops ssh-to-age statix treefmt. Ein kleines dev-Skript startet sie, ohne die eigene $SHELL-Konfiguration wegzuwerfen. Und nach direnv allow zeigt ein Befehl namens ? einen Spickzettel:
Verify
------
Run all checks: nix flake check
Verify build for <host>: nix build .#nixosConfigurations.<host>.config.system.build.toplevel
Eval
----
Option on host: nix eval --json .#nixosConfigurations.<host>.config.<path>
Sops (OS): nix eval --json .#nixosConfigurations.<host>.config.fr0st.security.sops.enable
Genau diese nix eval-Pfade machen den fr0st.*-Namespace introspektierbar: Ich kann jede Option jedes Hosts von der Kommandozeile abfragen, ohne zu booten.

Formatierung und Linting laufen über
treefmt-nix
: nixfmt, statix und deadnix fürs Nix, dazu shfmt, ruff, yamlfmt, jsonfmt und prettier. nix fmt . formatiert alles, nix flake check prüft Formatierung und die deploy-rs-Checks in einem Rutsch. Ein .pre-commit-config.yaml verdrahtet dieselben Werkzeuge in den Git-Hook — dieselbe Philosophie wie im Lab-Repo.
Das Theme: monochrom mit Neon-Orange
Der Name ist kein Zufall, und die Palette auch nicht. fr0st ist im Kern monochrom — Schwarz, ein paar Grautöne — mit neon-orangen Highlights als einziger Signalfarbe. Wo eine einzelne Akzentfarbe nicht reicht, fächert sich das in shades-of-orange und shades-of-gray auf, mehr nicht. Genau das sieht man auf jedem Screenshot in diesem Beitrag: graue Flächen, orange Kanten, kein bunter Lärm. Die Palette lebt in einem eigenen lib.theme/lib.fr0st-Modul, die Wallpapers sind ein eigenes Paket, das Waybar-Overlay pinnt eine konkrete Version.
Dass diese Konsistenz bis in die Pixel reicht, hat einen Preis — und hier kommt eine bewusste Entscheidung: Ich nutze kein Stylix . Der naheliegende Weg in NixOS-Land wäre, eine Base16-Palette zentral zu setzen und alle Programme automatisch einfärben zu lassen. Mir haben die Möglichkeiten schlicht nicht gereicht: Stylix trifft den kleinsten gemeinsamen Nenner, aber jedes Programm hat Ecken, die ein generischer Generator nicht erreicht — eine Statusleiste hier, ein Prompt-Segment da, ein Cursor-Highlight, das einen Tick anders sitzen soll.
Also theme ich jedes Programm einzeln und von Hand — deklarativ in seinem eigenen fr0st-Modul, aber mit voller Kontrolle über jede Farbe: rofi , ZSH, FZF, qutebrowser, Hyprland, Hyprlock, Neovim, systemd, yazi , btop, ncmpcpp, WeeChat — und viele andere. Das ist mehr Arbeit als ein zentraler Schalter, aber das Ergebnis ist eine Kohärenz, die sich nicht generieren lässt: Jedes Tool trägt dieselbe Handschrift, weil ich sie ihm einzeln gegeben habe. Für WeeChat habe ich genau diese manuelle Grau/Orange-Palette schon im Detail beschrieben — sie ist exemplarisch für den ganzen Rest.

Was ich daraus gelernt habe
Drei Dinge nehme ich mit, nachdem fr0st jetzt seit einer Weile alle meine Maschinen trägt:
- Discovery schlägt Registry. Module und Profile per Dateisystem zu finden statt sie zu listen, hat genau eine zentrale Liste eliminiert, die ich sonst ständig vergessen hätte zu pflegen. Die Konvention ist die Doku.
- Ein schmaler eigener Namespace ist Gold wert.
fr0st.hardware.tpm = enabledzu schreiben statt drei NixOS-Low-Level-Optionen zu kennen, macht eine Host-Datei lesbar wie eine Spezifikation. Die Übersetzung passiert genau einmal, im Modul. - Eine Fabrik macht den sechsten Host so billig wie den zweiten. Genau dieselbe Lektion wie bei CloudNativePG im Lab: Wenn die Grenzkosten einer weiteren Instanz gegen null gehen, hört man auf, über „lohnt sich das?" nachzudenken, und fängt an, es einfach zu tun.
Das Repo liegt offen — rad:zVi9VheaDwbEgCUQUQ9sLwpHuaMo . Wer mag, klont, liest, kopiert — und bekommt eine Maschine, die genau so bootet, wie sie deklariert ist.
