NixOS spinner words, host and generation in the status line

Multi-host NixOS configuration using flakes, a single parameterised host template, Home Manager as a flake module, and Stylix-driven theming.
Last verified: 2026-09-01 against nixos-unstable (NixOS 26.11).
| Host | Class | Hardware | Role | Template |
|---|---|---|---|---|
| p620 | workstation | AMD RX 7900 (ROCm) | Primary development, AI workloads | desktop/workstation |
| p510 | workstation | Intel Xeon + RTX 3070 Ti | Media server (Plex), k3s, CI runner, desktop | desktop/workstation |
| razer | laptop | Intel + NVIDIA | Mobile development, Secure Boot | desktop/laptop |
All three run the same Wayland session — see Desktop below.
p510 is the always-on media server: never build or deploy it without asking first. Heavy razer rebuilds should be built on p620 (just deploy-via-p620 razer).
DEX5550 is offline; Samsung and HP are decommissioned. References in older docs are stale.
hosts/templates/desktop.nix (selects between workstation and laptop profiles via the profile argument). Surfaced through lib/hostTypes.nix as hostTypes.workstation / hostTypes.laptop. The previous server/hybrid/base templates were removed; p510 also uses the workstation template, overriding only what genuinely differs.hosts/common/hardware-profiles/ (amd, nvidia, intel-integrated).lib/features.nix).modules/ is imported explicitly by the template; there is no auto-discovery.overlays/: default.nix, custom-packages.nix, cmake-compat.nix, python-compat.nix, upstream-fixes.nix, citrix-workspace.nix.base16 palette), whose source of truth is the active Omarchy theme — see Theming. Dependent surfaces — Plymouth, GRUB, the console, GTK, Qt, GNOME Terminal — derive their colours from config.lib.stylix.colors. The standalone nix-colors input has been removed.flake.nix. Do not run home-manager switch directly; user environments are activated by the system rebuild.One Wayland session on every host: Omarchy on Hyprland, packaged for NixOS as Nixarchy and consumed as a flake input. GNOME stays installed and selectable as a fallback. Everything else — niri, labwc, mango, Noctalia, DankMaterialShell — is gone, including the flake inputs. COSMIC is parked, not removed.
| p620 | razer | p510 | |
|---|---|---|---|
| Sessions | omarchy, gnome | omarchy, gnome | omarchy, gnome |
| Greeter | Omarchy's SDDM | Omarchy's SDDM | Omarchy's SDDM |
| Default | omarchy | omarchy | omarchy |
| Autologin | off | off | on (Sunshine needs a live session) |
| Omarchy preinstalls | on | on | off (~4 GiB of closure) |
Wired per host in hosts/<host>/nixos/nixarchy.nix plus shared fragments under hosts/common/nixos/omarchy-*.nix.
Three things are easy to get wrong:
programs.hyprland is enabled by the Nixarchy module, not by us. It supplies the portal, uwsm and the wayland-session basics, so it cannot be turned off — but it also registers a second, indistinguishable Hyprland login entry. omarchy-sole-hyprland.nix forces the module off and restates what Nixarchy needs. Read that file before touching anything Hyprland-adjacent; the two obvious workarounds both fail.hyprctl dispatch 'hl.dsp.dpms({ action = "enable" })'. The string form ("dpms on") silently does nothing.nixarchy-apps.nix (by nixarchy-apply) and nixarchy-theme.nix (by omarchy theme set). Both leave the tree dirty, and nhs refuses a dirty tree.Full detail: docs/architecture/desktop.md.
git clone https://github.com/olafkfreund/nixos_config.git ~/.config/nixos
cd ~/.config/nixos
just validate # Configuration validation
just test-host p620 # Build a host without switching
just <host> # Build and switch (p620 / p510 / razer)
The recommended flow for the common case (bump lock, commit, push, build, switch) — works for local and remote hosts, refuses to run with a dirty tree, and never orphans a lock:
nhs # current host, nixpkgs scope (zsh shortcut)
nhs razer # remote deploy via SSH (nh --target-host)
nhs p510 all # update all flake inputs, deploy p510
nhs razer home-manager # bump a single input
# Without the alias:
just update-commit-deploy [HOST] [SCOPE]
# Pre-build for a currently offline host (commit + build now, deploy later):
nhsb razer
Full reference: docs/UPDATE-DEPLOY.md.
flake.nix Main flake (inputs, outputs, host wiring)
Justfile Automation recipes (just --list)
lib/ Shared functions (hostTypes, features, secrets)
modules/ Feature modules (explicit imports only)
overlays/ Split nixpkgs overlays
hosts/
templates/desktop.nix Single parameterised template (workstation | laptop)
common/ Shared host fragments + hardware-profiles
p620/ p510/ razer/ Per-host configurations
home/ Home Manager modules
profiles/ Role-based profiles (developer, server-admin, ...)
Users/<user>/<host>_home.nix Per-user, per-host Home Manager entry
secrets/ agenix-encrypted secrets (.age)
checks/ flake checks (nix flake check)
scripts/ Operational scripts
docs/ Documentation
assets/wallpapers/ Centralised wallpapers (consumed by Stylix)
# Validation
just check-syntax # Nix syntax pass
just validate-quick # Fast validation
just validate # Full validation
just test-host <host> # Build a host without switching
# Deployment
just deploy # Local switch
just <host> # Build + switch a specific host
just quick-deploy <host> # Deploy only if the closure changed
just update-commit-deploy <host> [scope] # See docs/UPDATE-DEPLOY.md
nhs [host] [scope] # zsh shortcut for the above
# Maintenance
just cleanup # GC old generations
just update-flake # nix flake update (no commit)
just secrets # Interactive secrets manager
just --list # All recipes
Hosts compose functionality through flags rather than direct service configuration. Example (from hosts/p620/configuration.nix):
features = {
development.enable = true;
desktop.enable = true;
virtualization = {
enable = true;
docker = true;
};
};
Adding a new service: create a module under modules/services/<name>.nix exposing features.<name> options, then import it explicitly from the template or host. Do not place services.* = { ... } blocks directly in hosts/*/configuration.nix.
Encrypted with agenix; decrypted at activation time only.
just secrets # Interactive manager
./scripts/manage-secrets.sh create <name>
./scripts/manage-secrets.sh edit <name>
./scripts/manage-secrets.sh rekey
Always reference secrets by path, never read them at evaluation time:
# Correct — runtime load
services.myapp.passwordFile = config.age.secrets.myapp-password.path;
# Wrong — embeds the secret in /nix/store
services.myapp.password = builtins.readFile "/secrets/password";
Access control lives in secrets.nix (per-host and per-user public keys).
razer boots via lanzaboote (v1.1.0) with systemd-initrd. Because the firmware ships a locked Setup Mode, MOK enrollment is handled by a shim+MOK module (modules/razer/...) that wraps lzbt and points pkiBundle at /var/lib/sbctl (the sbctl 0.18 default).
Kernel is linuxPackages_latest (7.0.1) because 6.18.24 + nvidia-open failed to boot. hosts/razer/nixos/boot.nix documents the fallback: pin 6.18.22 via a separate module if 7.0.1 also fails.
The ESP is 511 MiB against ~149 MiB of NVIDIA-firmware initrd per generation, so it holds very few generations. --clean escalates to a single generation rather than failing its own threshold.
omarchy theme set X retints Omarchy's own 24 files immediately and a theme-set.d hook writes X into nixarchy-theme.nix at the flake root; modules/desktop/stylix-theme.nix maps that theme's colors.toml onto base16 and feeds Stylix. Everything Omarchy cannot reach at runtime follows at the next rebuild, not instantly.inputs.nixarchy, never config.programs.nixarchy.package — nixarchy consumes Stylix, so that closes the module fixpoint and evaluation dies with infinite recursion.config.lib.stylix.colors (base16 scheme) drives Plymouth, GRUB, the console, GTK, Qt, fonts, cursor, icons and GNOME Terminal.assets/wallpapers/ contains all wallpapers; the active wallpaper is selected by the per-host theme module.desktop.cosmic.enable = false everywhere); the writer stays wired.home/profiles/desktop-user/profile.nix and the desktop.gnome.profile module unify the wiring; the desktop.displayManager module unifies display-manager selection.host.class (enum) is used to gate desktop-only Stylix targets so headless hosts don't pull GNOME assets.The following are intentionally absent from the current configuration:
journalctl, systemctl, and per-service logs. Older docs still list grafana-status / prometheus-status helpers; those commands no longer exist.dms-shell.nix, "Niri (DMS)", "Hyprland (DMS)" or ${DESK_SHELL:-noctalia} is describing a tree that no longer exists.p620:5000. modules/nix/nix.nix substitutes from cache.nixos.org and nix-community.cachix.org; flake.nix adds cuda-maintainers and devenv as flake-level extra-substituters. Tailscale is used for remote SSH, not caching.nix-colors input (replaced by Stylix base16).termshark, wireshark, reddix, wasistlos, steampipe modules and the standalone cosmic-applet-package-updater chain.vim from the base user package set (Home Manager vimAlias handles it).cosmic-ext-applet-radio as an upstream input — replaced by a local module workaround for an upstream mkPackageOption/description bug.gh issue develop <n> --checkout # Branch from issue
just test-host <host> # Build the change
just validate # Syntax + checks + flake check
git commit -m "type(scope): summary (#n)"
gh pr create --fill # Open PR
nhs <host> # After merge: lock-aware deploy
Four packages are tracked here rather than taken from nixpkgs, because we follow their upstreams faster than nixpkgs does. Every release used to mean a manual version + hash bump.
.github/workflows/package-autoupdate.yml does it nightly at 03:43 UTC: resolve upstream → rewrite version + hash → build → open PR → merge. The config is updated before you wake up. Deploying stays manual.
| Package | Upstream source | Update script |
|---|---|---|
claude-code | Anthropic GCS latest channel | update-claude-code-native.sh |
claude-desktop | Anthropic signed apt repo | update-claude-desktop.sh |
warp-terminal | app.warp.dev redirect chain | update-warp-terminal.sh |
antigravity | Hy4ri/antigravity-flake | update-antigravity.sh |
The logic lives in scripts/, not in YAML. Each script is idempotent, writes nothing when already current, and has a --check mode. The bump you get at 03:43 is one you can run and debug by hand:
./scripts/update-warp-terminal.sh --check # exit 1 if a bump is available
./scripts/update-antigravity.sh # apply it
The workflow then reduces to: run script → did git diff change anything → build → merge. No update logic is trapped in a YAML run: block where it can only be tested by pushing a commit.
Verification runs inline, then the same job merges. Pull requests opened with GITHUB_TOKEN do not trigger other workflows, so ci.yml never runs on them and gh pr merge --auto would wait forever on checks that never arrive. The job that merges is therefore the job that built it.
max-parallel: 1. Each matrix leg merges to main; running them concurrently just leaves the later ones rebasing against a moved base.
Most of these cost real debugging time. If you are building something similar, they are the reason this page exists.
magic-nix-cache is retired. It fails with Cache service responded with 400 and Our services aren't available right now. Replaced with cachix/cachix-action against our own cache.update-flake.yml had referenced secrets.CACHIX_AUTH_TOKEN since the day it was written, but the secret was never set — so the step was a silent no-op and every CI run rebuilt from source. cachix-action does not fail loudly on an empty token.GitHub Actions is not permitted to create or approve pull requests. It is a repository setting, not a workflow permission: gh api -X PUT repos/OWNER/REPO/actions/permissions/workflow \
-f default_workflow_permissions=write \
-F can_approve_pull_request_reviews=true
pkgs.antigravity-cli, which resolves to a flake input's package — ours is pkgs.customPkgs.antigravity-cli, a different derivation at a different version. The gate passed green without touching a single file the script had rewritten. A build gate pointed at the wrong attribute is worse than no gate, because it reads as proof.Packages file lists every historical release in arbitrary order. Reading it top-down returned a version 11,000 releases behind the one we had pinned — and it would have built fine, silently downgrading the package by more than a year. sort -V is load-bearing.sed will happily swap them, and the mistake only surfaces as a hash mismatch on the architecture you do not build.just check-syntax # Syntax errors
just diff <host> # Pending closure delta
nix flake check --show-trace # Detailed evaluation errors
journalctl -u <service> -f # Follow service logs
sudo nixos-rebuild switch --rollback # Roll back to previous generation
nhs / just update-commit-deploy reference (local + remote).See LICENSE.
hooks/register.ts 42 lines1import type { EngineInterface, Register } from 'claude-code'
2import { formatStatus } from './status'
3
4const WORDS = [
5 'Evaluating thunks', 'Realising derivations', 'Substituting from cache.nixos.org',
6 'Fleeing infinite recursion', 'Bumping flake.lock', 'Hashing the NAR', 'Reaching the fixpoint',
7 'Overriding attrs', 'Fetching tarballs', 'Pinning inputs', 'Consulting the overlays',
8 'Collecting garbage', 'Patching shebangs', 'Wrapping programs', 'Rebasing on nixos-unstable',
9]
10const DONE = ['Realised', 'Evaluated', 'Substituted', 'Built', 'Activated', 'Garbage-collected', 'Flaked']
11const pick = (xs: string[]) => xs[Math.floor(Math.random() * xs.length)]!
12
13async function refresh($: EngineInterface) {
14 const r = await $.process.run([
15 'readlink', '/nix/var/nix/profiles/system', '/run/current-system',
16 '/run/current-system/kernel', '/run/booted-system/kernel',
17 ])
18 const theme = await $.fs.read('/home/olafkfreund/.config/nixos/nixarchy-theme.nix').catch(() => '')
19 $.ui.status(formatStatus(r.stdout, theme))
20}
21
22export const register: Register = on => {
23 let word = pick(WORDS)
24
25 on('session.start', async ($, e, next) => {
26 await refresh($)
27 $.clock.every(60_000, () => refresh($))
28 return next(e)
29 })
30
31 on('prompt.submit', ($, e, next) => {
32 word = pick(WORDS)
33 return next(e)
34 })
35
36 on('ui.render', { component: 'Spinner' }, ($, e, next) =>
37 e.props.message === null ? next({ ...e, props: { ...e.props, word } }) : next(e))
38
39 on('ui.render', { component: 'TurnDuration' }, ($, e, next) =>
40 next({ ...e, props: { ...e.props, word: DONE[[...e.requestId].reduce((n, c) => n + c.charCodeAt(0), 0) % DONE.length]! } }))
41}
42hooks/status.ts 9 lines1export function formatStatus(readlinkStdout: string, themeFile: string): string {
2 const [profile = '', current = '', kernel, booted] = readlinkStdout.trim().split('\n')
3 const host = /nixos-system-([^-]+)-/.exec(current)?.[1] ?? '?'
4 const gen = /system-(\d+)-link/.exec(profile)?.[1] ?? '?'
5 const theme = themeFile.match(/^"([^"]+)"/m)?.[1]
6 return [`❄ ${host}`, `gen ${gen}`, kernel !== booted ? '⟳ new kernel, reboot' : '', theme ? `🎨 ${theme}` : '']
7 .filter(Boolean).join(' · ')
8}
9