Multi-host Nix flake for NixOS, nix-darwin, Home Manager, and WSL-style container hosts.
flake/*: flake outputs and wiring.hosts/*: host entrypoints plus local identity, storage, and networking.modules/profiles/*andmodules/services/*: canonical reusable host and service logic.modules/user/*,modules/roles/*, andmodules/shell/*: user and role commitments.pkgs/*: package definitions.docs/*: operator and architecture references.
- Prerequisites: NixOS/Darwin system with flakes enabled
- Clone repo:
git clone <repo-url> && cd nixconfig - List tasks:
task --list-allto see all available tasks - Build a host:
task infra:plan:host:frieren - Deploy:
task infra:deploy:host:frieren(plan + apply + validate)
Module chains compose as follows:
globalModulesNixos → global → nixos → home-manager-shared → role:heim
globalModulesImpermanence → globalModulesNixos → +impermanence module
globalModulesContainers → global → noDE (lean home-manager, no dms/niri)
globalModulesMacos → global → macos (nix-darwin, no dms/niri)
base-node.nix (profile) provides the common NixOS baseline: kernel packages, SSH, keyd, networkmanager, console, sops, user devji, common dev packages, and boot loader defaults (systemd-boot + EFI). Container and desktop hosts import it via globalModulesContainers or desktop-client.nix.
base-node.nix also imports modules/profiles/firewall-baseline.nix, which enables the firewall and only opens TCP/22 by default. Hosts should add service/interface-specific allowances explicitly.
Prerequisite: Bare-metal bootstrap. If this is a fresh machine (no OS yet), boot a NixOS minimal ISO and run Disko +
nixos-installfirst. See Disko install below. The steps that follow assume NixOS is already booted and the flake is reachable.
hosts/<name>/
├── configuration.nix # Host-specific: services, networking, power, boot
├── hardware.nix # Optional: GPU drivers, hardware acceleration, power tweaks
├── hardware-configuration.nix # Generated by nixos-generate-config (disk layout)
├── <module>.nix # Optional: host-local service modules
Start with a basic configuration.nix that imports the right module chain from flake/module-sets.nix and sets networking.hostName.
Add an entry to flake/host-inventory.nix:
<name> = {
kind = "nixos"; # nixos | darwin | home
system = "x86_64-linux"; # aarch64-linux, aarch64-darwin, etc.
specialArgs = {inherit inputs self;};
modules = globalModulesContainers ++ [../hosts/<name>/configuration.nix];
};Pick the right module chain:
globalModulesNixos— desktop with display manager + niriglobalModulesImpermanence— desktop + impermanence + diskoglobalModulesContainers— headless server (no desktop) — most common for serversglobalModulesMacos— nix-darwin on macOSglobalModulesHome— standalone home-manager only
Extract the host's age public key and add it to .sops.yaml so it can decrypt secrets:
# On the new host, after NixOS is installed:
nix-shell -p ssh-to-age --run "cat /etc/ssh/ssh_host_ed25519_key.pub | ssh-to-age"
# Add the output to .sops.yaml under creation_rules and age keysThen rekey:
task infra:sops:update-keysSSH into the new host and collect:
# NIC name(s)
ip link show
# → note the ethernet interface (e.g. enp1s0, eno1, enp2s0)
# CPU governor support
cpupower frequency-info | grep governor || cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_available_governors
# GPU (if applicable)
lspci | grep -i vga
# Storage layout
lsblkApply these findings in configuration.nix and hardware.nix:
- NIC names in firewall rules and service interface bindings
- GPU drivers for hardware acceleration (QuickSync, NVENC, etc.)
- Power management (thermald, CPU governor, C-states)
- Laptop-specific: lid handling, display off, battery conservation mode
This flake already has nixos-hardware as an input (github:NixOS/nixos-hardware/master). Before writing hardware config from scratch, check whether a profile already exists for your CPU/GPU/laptop:
| Profile path | What it provides | Example import |
|---|---|---|
common/cpu/intel/<arch>/ |
CPU microcode + kernel params | common-cpu-intel-kaby-lake (arch examples: kaby-lake, tiger-lake, alder-lake) |
common/gpu/intel/<arch>/ |
GPU driver packages + i915 params | common-gpu-intel-kaby-lake (same arch values) |
common/pc/laptop/ |
TLP/power-profiles-daemon default | common-pc-laptop |
common/pc/ssd/ |
fstrim service | common-pc-ssd |
lenovo/thinkpad/<model>/ |
ThinkPad-specific quirks | lenovo-thinkpad-t420, lenovo-thinkpad-t440p (already in use) |
lenovo/ideapad/<model>/ |
Ideapad-specific quirks | Check repo listing for your model |
How to discover: Browse available profiles at github.com/NixOS/nixos-hardware/tree/master. Module names use hyphens, not slashes (e.g. common-cpu-intel-kaby-lake).
Real example: Host aristotle is a ThinkPad T440 (Haswell i5). There's no dedicated t440 profile, but the similar T440p profile exists at lenovo/thinkpad/t440p/ and provides thinkpad_acpi fan control + Optimus power saving via bbswitch. You'd review the profile, decide which settings apply, and either import it or cherry-pick the relevant kernel params into your local config.
How to use: Add the module to your host's modules list in flake/host-inventory.nix:
modules = globalModulesContainers ++ [
inputs.nixos-hardware.nixosModules.common-cpu-intel-kaby-lake
../hosts/<name>/configuration.nix
];The profile's settings will merge with your local config — you only need to add override lines for anything you want to change.
Pitfall: nixos-hardware profiles are maintained by the community and may set kernel params or packages you don't need. Review what a profile does before importing it (the files are small — read the
default.nixon GitHub). Not all profiles are actively maintained for every kernel version.
nix eval .#nixosConfigurations.<name>.config.networking.hostName # → "<name>"
task infra:plan:host:<name> # Dry-run build
task infra:apply:host:<name> # First deploy (may take a while)ssh <name> # CA-signed cert should work (inherited from base-node.nix)
# Verify key services are running
sudo systemctl status sshd
# Check hardware-specific settings took effect
# These are model-specific examples — adjust for your hardware:
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor # Should be: powersave
cat /sys/bus/platform/drivers/ideapad_acpi/*/conservation_mode # Should be: 1 (Ideapad only)Add the host to the CI build matrix so GitHub Actions pre-builds its closure and pushes to cache:
task lifecycle:ci:enable-host:<name>
# Then commit and push the CI workflow changeThis avoids hours of compilation on the target machine. Only a small closure (like the main server) is enabled by default — new hosts are commented out until you're ready to CI-build them.
Only
frieren(the NAS) is actively built in CI by default. Most hosts are commented out in themachine:list to save CI minutes.
To check what's currently enabled:
task lifecycle:ci:listWhen retiring a host (e.g. thinsandy → frieren), the goal is to leave zero references to the old host so the flake evaluates cleanly without it.
Cast a wide net first to catch every reference to the old host across all file types:
grep -rn "<old-name>" . --include='*.nix' --include='*.md' --include='*.yml' --include='*.yaml'Then drill into nix import paths specifically to find dependency links:
grep -rn "hosts/<old-name>/" flake/ hosts/ --include='*.nix'Key places to inspect:
- CI workflows (
.github/workflows/*.yml) - Deploy docs (
.agents/deploy/hosts/<old-name>.md— delete if exists) - Agent skills (
.agents/skills/*/SKILL.md) - Fleet pattern docs (
docs/) AGENTS.md,README.md,HANDOFF.md(if a migration handoff exists)
If other hosts borrow modules from the old host (e.g. dns.nix, media-stack.nix), copy them to a home that matches their current owner:
cp hosts/<old-name>/<module>.nix hosts/<new-name>/
# Also copy any local imports the module referencesUpdate configuration.nix on borrowing hosts to point to the new location:
# Before:
../../hosts/<old-name>/<module>.nix
# After:
./<module>.nix # if moved to the same host dirCopied modules often contain hardware assumptions from the old host:
| What to check | Why |
|---|---|
NIC names (eno1, enp2s0, etc.) |
Old host's ethernet interface name hardcoded in firewall rules or service binds |
| CPU-specific kernel params | e.g. intel_idle.max_cstate — safe to keep but verify |
| GPU driver packages | Different GPUs need different hardware.graphics.extraPackages |
| Storage paths | Bind mounts, data dirs, file system UUIDs |
Delete the old host's entry from flake/host-inventory.nix. Keep the list alphabetically sorted.
rm -rf hosts/<old-name>| Artifact | Action |
|---|---|
.agents/deploy/hosts/<old-name>.md |
Delete — deploy notes for a host that no longer exists |
.agents/skills/*/SKILL.md |
Remove any <old-name> from task tables (smoke checks, deploy aliases, etc.) |
docs/*fleet-pattern.md (or similar) |
Remove from Current Hosts lists and validation examples |
docs/codex-handoff.md |
Remove or update if the old host was mentioned |
.github/workflows/nixcachix.yml |
Disable via task lifecycle:ci:disable-host:<old-name> (comments it out) — or remove manually |
AGENTS.md |
Update CI pipeline section, module chain tables if stale |
HANDOFF.md (if it exists for this migration) |
Mark migration complete, update module paths |
README.md |
Update Quick Start examples, hosts table, and NAS client references |
.sops.yaml |
Remove old host's age key entry (not strictly necessary — extra keys don't break anything — but keeps the file clean) |
nix eval .#nixosConfigurations.<new-name>.config.networking.hostName # → "<new-name>"
# Also verify the old host name no longer exists in nixosConfigurations:
nix eval '.#nixosConfigurations' --apply 'x: builtins.attrNames x' | grep <old-name>
# should return nothing if decommissioned- Create
hosts/<name>/configuration.nix(andhardware-configuration.nix) - Register in
flake/host-inventory.nixwith correct module chain - Add age key to
.sops.yamland rekey secrets - SSH in, run
ip link showto discover NIC name - Add NIC-aware firewall rules and service interface bindings
- Add hardware-specific config: GPU drivers, power management, kernel params
- Check nixos-hardware for pre-built profiles (CPU, GPU, laptop series)
-
nix eval→ host name resolves - First deploy via
task infra:apply:host:<name> - Validate: SSH, governor, services
- Enable in CI:
task lifecycle:ci:enable-host:<name> - Check what's enabled:
task lifecycle:ci:list
- Audit:
task lifecycle:decommission:audit:<old-name> - Disable in CI:
task lifecycle:ci:disable-host:<old-name> - Remove deploy doc:
task lifecycle:decommission:clean:<old-name>(CI + deploy doc) - Migrate borrowed modules to new owner, update import paths
- Fix hardware-specific traps in copied modules (NIC names, GPU, etc.)
- Remove old host entry from
flake/host-inventory.nix -
rm -rf hosts/<old-name> - Clean up: deploy docs, agent skills, fleet docs, CI, AGENTS.md, README.md
-
nix evalon remaining hosts still passes
# Boot the NixOS minimal ISO, fetch your flake, and partition:
sudo nix run github:nix-community/disko -- --mode disko --flake /path/to/flake#hostname
# Then install:
sudo nixos-install --flake /path/to/flake#hostname
# Reboot into the freshly partitioned system.Disko handles partitioning, formatting, and mounting — no manual fdisk/mkfs needed. Device paths are parameterised per-host (e.g. disko.nix {device = "/dev/sda";}).
| Host | ollama | Role |
|---|---|---|
| frieren | no | ⭐ NAS server (Samba, NFS, Jellyfin, Paperless, DNS) |
| mtfuji | yes | Reference AI host (ollama; agent-era modules removed 2026-09) |
| kellerbench | no | Gaming backup rig |
| scratch | no | Lightweight niri desktop (eisen-style, tmpfs/zram IO diet) |
| stark | no | Dell 3477 AIO Steam Big Picture desktop (MX110 Optimus, legacy_580 offload) |
| fern | no | HP 15 laptop niri desktop (Ryzen 3 3250U, no Steam, autologin) |
| deckstation | no | Steam/gamescope kiosk |
deckstation is a pure Steam install — no desktop environment, just greetd + tuigreet dropping into gamescope-session (Steam Big Picture). Uses globalModulesContainers so no dms/niri leaks in. Runs Sunshine GameStream/Moonlight host so any LAN client (phone, laptop, TV box) can launch the big screen remotely. Closure is minimalistic: ROCm/OpenCL compute packages are dropped from the AMD profile since Steam + gamescope only need Mesa + amdgpu.
Per-host quirks and exceptions: .agents/deploy/hosts/*.md
nixpkgs and all inputs are pinned via flake.lock. Update intentionally with lockfile bumps (for example nix flake update or targeted input updates), then review and commit flake.lock with the corresponding config changes. Note that a root nix flake update only advances the root inputs — branch-following transitive pins (e.g. impermanence/nixpkgs, dms/dank-qml-common) can stay stale for months. Run task dev:flake:update-transitive (or check first with task checks:flake:transitive) to catch those.
nixosConfigurationsdarwinConfigurationshomeConfigurationspackageschecksdevShells
Canonically assembled from flake/outputs.nix and lib/mk-nixos-host.nix.
Secrets use sops-nix. Two separate encrypted files:
| File | Decryptors | Contents |
|---|---|---|
modules/secrets.yaml |
All hosts (via their ssh_host_ed25519_key age keys in .sops.yaml) |
App secrets, hashedPassword, API keys |
modules/ssh-ca-key.yaml |
Workstations only (*devji, *sopsposeidon) |
SSH User CA private key |
Instead of managing per-host authorized_keys, servers trust a single CA public key embedded in modules/ssh-ca.nix. Workstations hold the CA private key (decrypted by sops-nix) and sign short-lived certificates.
As a workstation user, daily flow:
# Sign a 1-week cert for yourself
rotate-ssh-cert
# That's it. All hosts that trust the CA accept this cert automatically.
ssh frieren # or any other host that trusts the CAThe rotate-ssh-cert script is available on any host with ssh.ca.enableClient = true. The cert is cached in ~/.ssh/id_ed25519-cert.pub and used automatically via CertificateFile in the SSH config.
Follow the full Host Lifecycle → Commission guide above. The key SSH CA specifics:
ssh.ca.enable = trueis inherited frombase-node.nix— the CA is trusted automatically on every new host- No
authorized_keysmanagement needed — just sign a cert on your workstation (rotate-ssh-cert) and SSH in - The host's age key (from
ssh_host_ed25519_key) must be in.sops.yamlforhashedPassworddecryption — see Add SOPS key in the commissioning guide
A workstation is any machine you SSH from and sign certificates on. This includes a fresh laptop or a new home-manager-only machine.
-
Build the flake on the new machine
-
Add the machine's age public key as a recipient in
modules/ssh-ca-key.yaml:sops --rotate --add-age <NEW_AGE_KEY> modules/ssh-ca-key.yaml
-
Commit and push the rekeyed file
-
Rebuild on the new workstation — sops-nix places
~/.ssh/user_ca_key -
Run
rotate-ssh-certto sign your first certificate
The first SSH connection to a bare-metal machine (before NixOS is installed) is outside the CA model — you still use a USB installer, rescue ISO, or temporary password. The CA eliminates ongoing management once the host is in the fleet.
TOTP tokens are managed via cloak — a CLI OTP authenticator — with the accounts file encrypted in SOPS.
Viewing a TOTP:
task dev:cloak:view # pick from a list (gum filter)
task dev:cloak:view:github # or directly by nameEditing TOTP accounts:
task dev:cloak:edit # opens the full SOPS file, navigate to the `cloak` keyImporting from Bitwarden:
Export from Bitwarden (Settings → Export → Unencrypted JSON .json), then:
task dev:cloak:sync-bw -- ./bitwarden_export.jsonThis prints the TOML to pipe into sops modules/secrets.yaml --extract '["cloak"]'. The scripts/bw-to-cloak.sh converter is also usable standalone:
./scripts/bw-to-cloak.sh bitwarden_export.json > ~/.cloak/accountstotp-cli was replaced by cloak — it covers the same use case with simpler TOML storage.
Once all hosts have been rebuilt with ssh.ca.enable = true, the authorized-keys.nix / authorized-keys.json gist fetch in base-node.nix is dead weight — it falls back silently and can be removed at your leisure.
- The NAS client recovery profile now lives in
modules/profiles/nas-client.nix, which automounts/Volumes/datafrom the NAS host (frieren, previouslythinsandy) for non-NAS hosts so the compatibility path stays available without relying onhosts/common/localmounts.nix. - The agent-era tooling (nullclaw, zeroclaw, hermes, xs, pancakes-harness, qwen-code and their modules/scripts/docs) was removed in 2026-09; the git history retains it if anything is ever needed again.
- The experimental devcontainer configuration was reverted; there is no current repo-provided devcontainer image, so use the Taskfiles, flake outputs, and hosted workflows directly.
Last updated: 2026-09-29
2026-09-29 — stark + fern hosts added; dank-greeter migration; transitive-input sweep. Two new hosts joined the fleet: stark (Dell Inspiron 24 3477 AIO, i5-7200U + MX110 Pascal dGPU on legacy_580 with PRIME render offload, eisen-style Steam Big Picture autologin, dual-disk disko with a 1 TB HDD Steam library) and fern (HP 15 laptop, Ryzen 3 3250U Vega 3, scratch-style niri desktop autologin, no Steam, single-disk disko). The greeter moved upstream from the dms flake to its own dank-greeter repo: all desktop hosts now use inputs.dank-greeter.nixosModules.default → programs.dms-greeter and dms is unpinned again. globalModulesHome gained nixpkgs.config.allowUnfree so standalone HM hosts (alarm/kali) eval like NixOS hosts do. A new task (checks:flake:transitive / dev:flake:update-transitive, backed by scripts/task/flake-transitive-sweep.sh) sweeps flake.lock transitive pins against upstream heads — a root nix flake update leaves those stale. authorized-keys.nix now appends two repo-side keys (bitlockerpremium Windows box + a Bitwarden key) to the fetched gist list.
2026-09-17 — scratch: Steam Remote Play kiosk converted to an eisen-style niri desktop. The Fujitsu ESPRIMO D556 now runs the globalModulesNixos chain (niri + DankMaterialShell greeter, autoLogin, role:heim userland) via base-desktop-environment.nix; Steam, the cage kiosk wrapper and the greetd autologin are gone, while the tmpfs/zram IO diet (zram 100%, journald volatile, ~/.cache on tmpfs, fstrim, gentle writeback sysctls) is kept verbatim for the shaky f2fs SSD. Legacy BIOS boot with GRUB on /dev/sda is unchanged. See .agents/deploy/hosts/scratch.md.
2026-09-17 — verntil/orb-cassini host files removed; thinsandy references purged. Orphaned hosts/verntil.nix and hosts/orb-cassini/ (never registered in the host inventory) were deleted, and remaining live references to the long-decommissioned thinsandy were replaced with frieren (the current NAS): deploy/logs menus, nas-client skip-list, heim's anki sync URL, nixoshmsymlinks skip-list, pi-hole DNS comments, the stale checks:nullclaw:smoke:thinsandy task, and the thinsandy age keys in .sops.yaml. Historical migration notes (HANDOFF.md, AUDIT.md) intentionally keep their thinsandy mentions.
2026-08-03 — Poseidon microVM disabled; frieren daily self-upgrade; scratch real disk UUIDs + GRUB; GC consolidation. The testvm microVM on poseidon no longer runs — the microvm imports and the microvm.vms block in hosts/poseidon/configuration.nix are commented out for easy re-enable (microbr bridge/NAT profile included). frieren (the NAS) now self-upgrades every morning at 04:00 via the canonical system.autoUpgrade module (no hand-rolled timer): it stages nixos-rebuild boot from github:shaoyanji/nixconfig#frieren first, then reboots into the new generation only if kernel/initrd/kernel-modules changed (allowReboot, reached only after a successful boot), otherwise applies a live switch. Persistent timer catches up if the NAS was off. scratch got its real disk UUIDs from the original machine gist (f2fs root + ext4 /boot) and now boots with GRUB (legacy BIOS) instead of systemd-boot; its redundant 14-day GC was dropped in favour of the global 10-day GC. See .agents/deploy/hosts/frieren.md and .agents/deploy/hosts/scratch.md.
2026-09-17 — Agent-era tooling removed. All agent-era modules (ai-host, hermes-defaults, ollama-cloud-defaults, nullclaw/zeroclaw + deployment wrappers, pancakes-harness, hermes-ai-mounts, ai-services-*), packages (nullclaw, pancakes-harness, qwen-code, xs, xs-helper, xs-materializer), their scripts/taskfiles/smoke-checks, and the fleet-pattern docs were deleted. hosts/mtfuji/ai.nix now only mounts the ollama data subvols and enables ollama; poseidon/garnixMachine/kellerbench agent blocks are gone; the nullclawFleetContract check and the xs-helper app output were dropped. The tailscale DNS target moved from the decommissioned thinsandy pi-hole IP to frieren's 100.97.61.65.
2026-04-30 — Task system consolidation. Deprecated legacy task aliases and menus, directing users to new infra: and dev: prefixed tasks. Simplified checks:nullclaw:smoke tasks and enhanced dev:git tasks with AI commit integration.
2026-04-23 — Manifest and dead-code cleanup. The AI-host manifest system (taskfiles/ai-host-manifest.json, scripts/task/ai-host-*.sh, and taskfiles/services-ai-hosts.yml) was removed. Host menus and validation tasks now use static host lists and direct smoke checks. The modules/user/ai/ directory and modules/goodies.nix were also removed; nothing in active host configs imported them. See AUDIT.md for the full decision log.
AGENTS.md- Agent routing helpers and task namespace summarydocs/task-control-plane.md- Task namespace policy and workflow examplesdocs/frieren-access.md- frieren service-access runbook (LAN/tailnet matrix, DNS, direct ports)taskfiles/README.md- Taskfile ownership map and shard reference
.agents/deploy/README.md- Deploy routing and guardrails.agents/deploy/hosts/*.md- Per-host deployment exceptionsUSB.md- Sledgehammer live USB creation guide
docs/codex-handoff.md- Codex session orientationNIX-REFERENCE.md- Nix patterns and gotchas used in this repodocs/userland-module-map.md- Userland module structure and ownershipdocs/userland-package-ownership.md- Package ownership and role wiring
AUDIT.md- AI module cleanup audit (April 2026)HANDOFF-REFACTOR.md- Refactoring progress (April 2026)TODO.md- Current work tracking and completed tasks
Manage site targets with task dev:site:list; build/preview/deploy the default target with task dev:site:build, task dev:site:preview, and task dev:site:deploy.