fr0st — mein NixOS-Flake von der Wurzel bis zum Wallpaper

Sechs Maschinen, zwei Betriebssysteme, ein Repository: fr0st ist mein persönliches flake-parts-Flake, das ThinkPads, einen Desktop, ein MacBook und VMs aus derselben Quelle deklariert. Eine ausführliche Tour durch die Architektur — die lib.system-Fabrik, die rekursive Modul-Discovery mit eigenem Options-Namespace, die Profile-Komposition, das Single-User-Identitätsmodell, Secure Boot via Lanzaboote, verschlüsselte Roots via disko, Remote-Installs mit nixos-anywhere, das SOPS-Schlüsselregister, eigene Pakete und Overlays, und am Ende der Desktop, den das alles ergibt.
Table of contents

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 .

Das Ergebnis: ein frisch gebooteter Hyprland-Desktop. Waybar oben, eine Low-Poly-Wallpaper in Orange- und Grautönen.

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:

  1. Eine Fabrik, viele Maschinen. Ein Host ist ein Datensatz, kein Sonderfall. Sechs Systeme entstehen aus denselben Builder-Funktionen.
  2. 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.
  3. 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".

Genau hier wohnt der Charme: ein fr0st-Modul in Arbeit. Links die Option-Definition mit lib.types.lines und description, rechts der Eval-Output — die Komposition wird beim Tippen sichtbar.

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

EbeneProfile (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.

Login-Passwörter liegen bewusst nicht in SOPS. Jeder Host startet mit 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:

SystemPlattformHardwareBootSecure BootEncrypted Root
chupacabrax86_64-linuxThinkPad X230 (Mod)BIOS
squirkx86_64-linuxThinkPad X13 Gen5UEFI
mean-sixx86_64-linuxCustom DesktopUEFI
cozy-glowaarch64-darwinMacBook Air M4UEFI
amygdalax86_64-linuxQEMU VMBIOS
installerx86_64-linuxLive-ISOBOTH

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
  1. cp -r systems/_template/linux systems/<host>default.nix und home.nix anpassen.
  2. Host in flake/systems.nix eintragen.
  3. nix build .#packages.x86_64-linux.installer-iso → Boot-Medium → Zielsystem booten.
  4. nixos-anywhere --flake .#<host> root@<ip>nixos-anywhere partitioniert (via disko), installiert und rebootet die Zielmaschine vollständig fern.
  5. sops-init <host> root@<ip> und sops-init <host> <user>@<ip> — die Schlüssel-Zeremonie (gleich mehr).
  6. Anwenden: nh os switch --ask . auf dem Host, oder deploy .#<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:

PaketZweck
mitm-captureTLS-Traffic mitschneiden/inspizieren
reload-yubikey / gpg-key-cachedYubiKey- und GnuPG-Handgriffe automatisieren
authy-pass-syncAuthy-Tokens in den pass-Store spiegeln
external-ip-addraktuelle externe IP ermitteln
trace-symlink / trace-whichDebug-Helfer für Symlink-Ketten und $PATH
ca-certs / fonts / wallpapersTrust-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.

Alltag auf einer fr0st-Maschine: ein Terminal-Dashboard aus Systemmetriken, Prozessen und Journald-Logs — alles deklarativ installiert, alles in derselben monochromen Palette mit neon-orangen Akzenten.

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.

Und der Kreis schließt sich: qutebrowser auf einer fr0st-Maschine, geöffnet auf genau diesem Blog. Die Maschine, die diesen Text rendert, ist aus dem Flake gebaut, über den er handelt.

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 = enabled zu 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.