No description
  • Nix 82.5%
  • Shell 13.1%
  • JavaScript 4.2%
  • Dockerfile 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-04 10:33:53 +02:00
assets Initialize 2026-08-11 20:13:05 +02:00
docker Initialize 2026-08-11 20:13:05 +02:00
docs Add systemd-boot configurationLimit & automatic garbage collection 2026-08-13 11:07:34 +02:00
fonts Initialize 2026-08-11 20:13:05 +02:00
hosts Add systemd-boot configurationLimit & automatic garbage collection 2026-08-13 11:07:34 +02:00
lib Add laptop host with GNOME, restructure modules for multi-host support 2026-08-11 20:32:57 +02:00
modules Add tethering packages 2026-09-04 10:33:53 +02:00
pkgs Add the herdr topic-indicator plugin 2026-08-28 20:47:02 +02:00
templates Initialize 2026-08-11 20:13:05 +02:00
tests Add gitea-cli module & update dosh package 2026-08-26 21:53:53 +02:00
users/tobi Add git core.editor configuration for neovim 2026-08-29 01:49:52 +02:00
.gitignore Initialize 2026-08-11 20:13:05 +02:00
compose.yaml Add laptop host with GNOME, restructure modules for multi-host support 2026-08-11 20:32:57 +02:00
flake.lock Add tethering packages 2026-09-04 10:33:53 +02:00
flake.nix fix: restore hermes session indexing broken by an upstream packaging gap 2026-09-02 12:07:35 +02:00
nixdev Add top module & fix nixdev TTY allocation 2026-08-12 01:05:35 +02:00
README.md Add the herdr topic-indicator plugin 2026-08-28 20:47:02 +02:00

nixos

A NixOS configuration for two machines -- a Hyprland desktop and a GNOME laptop -- built and tested from a container on a non-NixOS host (no Nix installed) via ./nixdev.

Host Desktop Greeter GPU Also gets
desktop Hyprland (waybar, rofi, dunst) SDDM AMD (gpu-amd.nix) modules/gaming.nix
laptop GNOME (GNOME Shell is bar, launcher and notifier) GDM Intel (gpu-intel.nix) modules/laptop.nix

Everything else -- the user, the shell, the dotfiles, the container stack, the coding agents, the audio and keyring stack, the firewall posture -- is one shared set of modules. See Two desktops, one set of modules below for exactly where the two diverge and why.

Quickstart

./nixdev doctor              # verify the harness -- do this first
./nixdev build               # build the desktop-vm system closure
./nixdev build --host laptop # ...or the laptop's
./nixdev check               # every checks.* attribute, plus config-evaluation checks (needs KVM)
./nixdev vm                  # boot it interactively, VNC on localhost:5900
./nixdev vm --host laptop    # ...the GNOME one instead

Commands that act on one machine take --host desktop|laptop and default to desktop. check covers both hosts on every run and takes no --host.

doctor builds the container image on first run, then self-tests it (store persistence, KVM, binary cache reachability, ...). build/check download from cache.nixos.org and build whatever isn't cached into the nixos-nixstore named volume, which persists across runs -- only the first invocation after a store reset pays the full download/build cost. See Costs below for real, measured figures instead of a guess.

Layout

Path Purpose
flake.nix / flake.lock Entry point: four nixosConfigurations (two hosts × real/VM), checks, packages, the dev shell
hosts/desktop/default.nix Hardware-agnostic desktop config: modules/workstation.nix + modules/desktop-hyprland.nix, hostname, bootloader
hosts/desktop/hardware.nix Real machine hardware, from nixos-generate-config
hosts/desktop/credentials.nix Real-host-only credential (desktop only) -- see Deploying below
hosts/laptop/default.nix Hardware-agnostic laptop config: modules/workstation.nix + modules/desktop-gnome.nix + modules/laptop.nix
hosts/laptop/hardware.nix Placeholder -- fake device IDs, warns on every evaluation until nixos-generate-config replaces it
hosts/laptop/credentials.nix Real-host-only credential (laptop only); committed empty on purpose -- see Deploying below
modules/common.nix Shared system settings: locale, keyboard layout (console + greeter/XWayland), networking, the tobi user
modules/workstation.nix The module set both hosts get regardless of desktop or hardware -- one list, so a second host cannot drift
modules/desktop-common.nix Desktop-agnostic graphical stack: PipeWire, keyring + PAM, portal frontend, shared fonts and applications
modules/desktop-hyprland.nix Hyprland, SDDM, waybar/rofi, portal backend order (desktop only)
modules/desktop-gnome.nix GNOME, GDM, the layout/extension dconf defaults, and the GNOME defaults this repo declines (laptop only)
modules/laptop.nix Battery, lid, thermals, firmware, zram (laptop only; independent of which desktop runs)
modules/gpu-amd.nix Real host's AMD GPU driver config (desktop only)
modules/gpu-intel.nix Intel iGPU config, incl. VA-API (laptop only) -- an assumption, not a measurement; see its header
modules/gpu-vm.nix VM-appropriate GPU stand-in (desktop-vm, laptop-vm and every test node)
modules/firewall.nix Inbound policy: default-deny, and no sshd -- see Firewall below
modules/neovim.nix neovim plus what the nvim dotfiles reach for at runtime, including nix-ld without which mason's LSP binaries cannot execute
modules/nautilus.nix File manager, plus the gvfs/udisks2 services it is silently broken without
modules/containers.nix Rootless podman, a real docker command, compose, and Docker-Hub-first short image names
modules/laravel.nix + laravel-container.sh composer and sail as containerised commands, no PHP installed
modules/llama.nix + llama.sh llama: a llama.cpp server (ROCm) on 127.0.0.1:8012 for llama.vim
modules/mergiraf.nix + git-solve.sh mergiraf, and git solve across every conflicted file
modules/gitea-cli.nix tea, the Gitea CLI, against the git.all4-one.de instance this repo's inputs come from
modules/audiobook.nix + convert-audiobook.sh convert-audiobook: merge a folder of mp3s into one
modules/ai-agents.nix claude, opencode, codex, hermes, plus herdr -- the two of the five not in nixpkgs, taken from upstream's own flakes
modules/gaming.nix Steam, Proton-GE, gamescope, gamemode (desktop only)
modules/dosh.nix + pkgs/dosh.nix dosh, built from source rather than taken from nixpkgs
modules/knowledge-base.nix + pkgs/knowledge-base.nix kb, built from source likewise; attribute named for the repo because nixpkgs owns kb
users/tobi/herdr.nix + pkgs/herdr-topic-indicator/ A local Herdr plugin: topic labels for agent panes in the sidebar -- see Herdr topic indicator below
modules/oh-my-zsh-fhs.nix oh-my-zsh at the FHS path the zsh dotfiles hardcode
templates/ nix flake init -t scaffolds for new node projects
lib/hardware-stub.nix Invented VM disk/bootloader (incl. overriding the hosts' systemd-boot with grub) so <host>-vm builds in a container
lib/require-real-password.nix Assertion: a real host refuses to evaluate without a real credential; host-agnostic, names whichever host is being built
lib/vm-login.nix VM-only throwaway credential, never shared with a real host
lib/no-leaked-credential.nix Assertion: catches a real credential leaking into the shared VM path (desktopVmModules / laptopVmModules)
users/tobi/ home-manager user config: default.nix is the desktop-agnostic core, plus one session profile per host (hyprland-session.nix / gnome-session.nix)
tests/boot-units.nix Tier A for desktop: hermetic VM test, registered in checks
tests/laptop-boot-units.nix Tier A for laptop: GNOME/GDM, the session split, and the GNOME defaults this repo turns back off
tests/hyprland-smoke.nix Tier B: best-effort VM test, deliberately not in checks (see Known limitations)
nixdev The harness CLI (doctor|build|check|test|vm|shell|update)
compose.yaml Equivalent vm/shell entry points via podman-compose
docker/ The container image everything above builds and runs inside
docs/hardware-install.md First install on bare metal: graphical ISO, then switch to #desktop or #laptop; manual nixos-install in an appendix

Four configurations, two hosts

Real machine What the container builds and boots
Hyprland desktop nixosConfigurations.desktop desktop-vm
GNOME laptop nixosConfigurations.laptop laptop-vm

All four come from one mkModules { host, gpu, session } in flake.nix. A real host and its VM twin differ in exactly three places, all supplied by flake.nix, never inside the shared modules themselves:

  • Hardware: the real host gets its hosts/<host>/hardware.nix; the VM gets lib/hardware-stub.nix, an invented disk/bootloader that only needs to be internally consistent, never boot real silicon. (It also forces systemd-boot off, because both hosts choose it on real UEFI hardware and NixOS permits exactly one bootloader.) VM test nodes (tests/boot-units.nix, tests/laptop-boot-units.nix, tests/hyprland-smoke.nix) get neither -- they import <host>VmModules directly, without hardware-stub.nix -- because pkgs.testers.runNixOSTest supplies its own disk and bootloader for every node it builds; adding a hardware module there would conflict with the test framework's own, not supplement it.
  • GPU: the real host gets modules/gpu-amd.nix or modules/gpu-intel.nix; the VM gets modules/gpu-vm.nix (software-rendering fallback). A GPU-passthrough escalation used to live in that file too; it was removed after confirming it delivered nothing (see Known limitations below) -- ./nixdev vm still passes the host's render node into the container on a best-effort basis, but nothing currently reads it without hand-rolled QEMU_OPTS.
  • Credentials: the real host gets hosts/<host>/credentials.nix plus lib/require-real-password.nix, the assertion that refuses to let it evaluate without a real one; the VM gets lib/vm-login.nix, a throwaway credential that never applies to a real host. This is a real, security-relevant divergence, not a cosmetic one -- the configurations do not share credential behavior, only the modules that implement everything else.

Building and testing <host>-vm in this container therefore exercises the actual code that will run on the real machine, not a parallel toy config.

Two desktops, one set of modules

The two hosts diverge in three places and share everything else.

  • The system-level desktop, split three ways rather than two: modules/desktop-common.nix holds what is identical and harmless under both (PipeWire, gnome-keyring and its PAM line, the portal frontend, the fonts every host's terminal names, the applications). Each host adds one flavour module on top -- desktop-hyprland.nix or desktop-gnome.nix -- and imports only that; the flavour pulls the common half in. Rule for anything new: it belongs in the common file only if it would be identical under both and setting it under both is harmless. A portal backend, a notification daemon or a screenshot tool never qualifies, because the failure mode is not an error -- it is two providers of one D-Bus name, resolved by search order.
  • The home-manager user, the same way: users/tobi/default.nix is the desktop-agnostic core (dotfiles, projects, direnv, rclone, Proton Mail Bridge, GTK/cursor theme, terminals, mpv), and flake.nix adds exactly one session profile -- hyprland-session.nix (Hyprland, waybar, rofi, dunst, and the three dotfiles repos that only mean anything there) or gnome-session.nix (much smaller: GNOME Shell is already the bar, the launcher and the notifier).
  • The hardware: modules/laptop.nix (lid, battery, thermals, firmware, zram) is imported by hosts/laptop/default.nix, and modules/gaming.nix by desktop alone. Neither knows anything about which desktop runs, which is what makes a third combination -- a GNOME desktop, a Hyprland laptop -- a two-line change rather than a fork.

GNOME is the one place where "shared module set" needs a caveat: its NixOS module turns on a great deal by default, so modules/desktop-gnome.nix is as much about declining defaults as enabling GNOME. It refuses gnome-remote-desktop, rygel and gnome-user-share (network listeners, on a machine whose firewall opens nothing), gnome-initial-setup (everything it would ask is already declared here), avahi's openFirewall (which appends 5353/udp straight past modules/firewall.nix's empty port list), and the core apps this repo already answers better (epiphany, gnome-console, gnome-music). Each of those is mkDefault true upstream -- one nixpkgs bump away from coming back -- so tests/laptop-boot-units.nix asserts every one of them stays off.

Commands

Two sets, and it is worth keeping them apart. The first runs on the development host, against this repo, inside the container. The second is what the built system puts on PATH once deployed -- none of it exists until nixos-rebuild switch.

On the deployed system

Commands this configuration writes itself. Everything else it installs (nvim, lazygit, gh, podman, ...) behaves as upstream documents.

Command Purpose
composer … / sail … Composer and Laravel Sail in a container, no PHP installed -- see Laravel below
laravel-container composer|sail … The script both of the above wrap; LARAVEL_CONTAINER_DRY_RUN=1 prints the container command instead of running it
llama pull|start|stop|restart|status|logs Local llama.cpp server (ROCm) on 127.0.0.1:8012 -- see Local LLM below
git solve (or git-solve) Run mergiraf over every conflicted file in the working tree
convert-audiobook <folder> <out.mp3> Merge a folder of mp3s into one, in natural filename order
clone-projects Clone the repos declared in users/tobi/projects.nix -- see Project checkouts below
dosh The dosh TUI, built from source by pkgs/dosh.nix
kb Reader for a project's docs/knowledge-base/ tree, built from source by pkgs/knowledge-base.nix. Run it in a repo; with no such tree it says so and exits. Note nixpkgs has an unrelated package also called kb -- this flake's attribute is knowledge-base, and installing both would collide over the binary
use npmrc <name> Not a binary: a direnv function for per-project registry tokens -- see npm tokens below
claude / opencode / codex / hermes Coding agents. Two are named for their binary rather than their package: claude-code's is claude, hermes-agent's is hermes. hermes-agent is not in nixpkgs either -- built from upstream's flake, in its minimal variant (no bedrock/voice/matrix/messaging integrations; add one with extraDependencyGroups in flake.nix)
herdr Persistent terminal workspace for those agents: panes that survive detaching, ctrl+b q detaches and herdr reattaches. Built from upstream's flake, not nixpkgs
herdr-topic-indicator A local Herdr plugin (pkgs/herdr-topic-indicator, wired up by users/tobi/herdr.nix) that labels each agent pane in the sidebar with what it is working on -- see Herdr topic indicator below

On the development host

Command Purpose
./nixdev doctor Harness self-test (image build, store persistence, KVM, binary cache, ...)
./nixdev build [--host H] Build that host's <H>-vm system closure (out-link result-<H>)
./nixdev check Build every checks.x86_64-linux.* attribute, plus every host's real/VM evaluation checks (needs KVM)
./nixdev check --no-vm-tests Evaluation only, no KVM required -- still covers both hosts
./nixdev test [name] Run one VM test under KVM (default boot-units, the desktop one; laptop-boot-units for GNOME; also resolves names from packages.*, e.g. hyprland-smoke)
./nixdev vm [--host H] Boot that host interactively, VNC on localhost:5900
./nixdev shell Interactive nix develop shell in the container
./nixdev update nix flake update

--host may appear anywhere in the argument list (./nixdev vm --gl --host laptop works), and an unknown name is rejected before anything is built. check deliberately takes no --host: leaving one host unchecked is how a shared module regresses on the machine you were not looking at.

The interactive VM's store persists too. <host>.qcow2 -- one image per host, so a GNOME system never boots onto a disk whose /home was populated by the Hyprland one -- keeps /home across boots, so ~/.cache/nix and each project's .direnv/ outlive a shutdown. Upstream puts the guest's writable store layer on a tmpfs, which means everything those two point at -- every devShell direnv builds -- would not. gpu-vm.nix sets writableStoreUseTmpfs = false so the two agree; without it the next boot hands nix a cached drvPath for a store path that no longer exists, and direnv reports opening file '/nix/store/...-nix-shell.drv': No such file or directory, which reads like a broken flake rather than an erased store. The store overlay now shares the root disk, hence diskSize = 20480 -- but a disk size only applies to a newly created qcow2, so an existing one stays at the old 1 GB until ./nixdev vm --fresh.

Env: NIXDEV_PRIVILEGED=1 adds --privileged if a build needs a real sandbox; NIXDEV_RENDER_NODE overrides the GPU render node path passed into ./nixdev vm (best-effort, never required -- it degrades gracefully without it; run_nix --kvm, used by build/check/test, does not read this at all -- nothing there asks for the device); NIXDEV_IMAGE, NIXDEV_STORE_VOLUME, NIXDEV_HOME_VOLUME override the image tag and volume names.

compose.yaml offers the same vm/shell entry points via podman-compose (1.6.0), for anyone who prefers it -- ./nixdev remains the primary interface and does not require compose. Example: podman-compose run --rm shell nix --version. Two limits, both deliberate: compose's vm service is software-rendered only, with no render-node opt-in (./nixdev vm has one) -- given the GPU path is now known to render worse than software (see Known limitations below), this is currently the better path anyway, not a missing feature -- and it boots host desktop only, since a compose service is static and a per-host choice would mean one duplicated service block per host. Use ./nixdev vm --host laptop for the GNOME one.

compose.yaml's two named volumes are declared external: true (compose requires someone else to have created them; it will not silently make its own). ./nixdev creates nixos-nixstore/nixos-nixhome implicitly on its first podman run, so run any ./nixdev command (./nixdev doctor is the cheapest) at least once before a compose command on a fresh machine, or podman volume create nixos-nixstore && podman volume create nixos-nixhome by hand -- otherwise podman-compose up/run fails outright with "no such volume".

Costs

Measured, not estimated -- from a real clean-store run on this development host (12 cores, /dev/kvm available, podman 6.0.2), re-measured for this fix wave since it added checks.desktop-eval: starting from an empty store, ./nixdev doctor, ./nixdev build, ./nixdev check, in that order, reading du -sh on the store volume after each step.

To reproduce them, get that empty store by destroying and recreating the two named volumes (recipe under Never GC inside the container below). Do not reach for a garbage collection inside the container to do it -- that deletes the harness's own toolchain and breaks it outright, as the verification runs behind these very numbers discovered twice, which is why that section exists at all.

  • ~1.4 GB after ./nixdev doctor (image seed plus doctor's own tiny nixpkgs#hello build/run).
  • ~7.8 GB after ./nixdev build -- desktop-vm's toplevel closure, roughly a 6.4 GB addition over the doctor baseline.
  • ~9.0 GB after ./nixdev check on top of that same store -- check additionally builds the Tier A boot-units VM test (its own kernel/initrd/QEMU closure, confirmed to add ~1.2 GB on its own) and the two hermetic checks, password-guard and desktop-eval. Both are negligible: building them added no measurable size at all (confirmed at the byte level, not just by rounding) -- desktop-eval forces full evaluation of modules/gpu-amd.nix and hosts/desktop/hardware.nix (the real host's own, otherwise-untested modules) without building anything they reference; see the comment on mkHostEval in flake.nix for how, and why an earlier version of that check briefly built the entire real AMD desktop closure by accident (118 GB on a 15 GB store) before this was caught and fixed.
  • The laptop host is not in any figure above: every measurement here predates it, and none has been re-run. Expect ./nixdev check to cost meaningfully more than the ~9.0 GB above now, in two parts. The cheap part is laptop-eval, which by construction adds nothing measurable -- it is the same mkHostEval mechanism. The real part is GNOME: laptop-vm's toplevel and the laptop-boot-units VM test pull in gnome-shell, gnome-session, GDM, the GNOME portal, gnome-control-center and the core-app set that survives environment.gnome.excludePackages -- a large closure that shares little with Hyprland's beyond Mesa and GTK. ./nixdev build and ./nixdev vm are unaffected unless you pass --host laptop, since both default to desktop; check covers both hosts by design and pays for both. If you want the numbers, measure them the way the ones above were measured (destroy and recreate the volumes first) rather than trusting an estimate here.
  • Add herdr on top of every figure above: those runs predate it, and it is the one package here with a genuinely non-trivial build cost rather than a download. modules/ai-agents.nix puts it in environment.systemPackages, so ./nixdev build and ./nixdev check both compile it -- and since it is not in nixpkgs there is nothing to substitute: a rust 1.96.1 toolchain (built by rust-overlay, not fetched from cache.nixos.org) and the zig dependency cache for its vendored libghostty-vt land in the store first, then the crate itself. Measured on this host: single-digit minutes wall-clock from a store that already had nixpkgs, and 67.3 MiB for herdr's own runtime closure (nix path-info -Sh) -- the runtime addition is small; the build-time toolchain paths are what cost. Only the first build pays it.
  • ~15 GB -- what the volume had actually grown to across nine tasks of earlier development (measured before an earlier clean-store run, predating checks.desktop-eval). The gap over the ~9.0 GB figure above is retained history, not a bigger first-time cost: multiple kept generations, packages.vm (the interactive VM image), Tier B's hyprland-smoke VM test (deliberately kept runnable -- see Known limitations), and the dev shell's tooling (nixpkgs-fmt, nil), none of which a first ./nixdev build pulls in. Don't extrapolate any of these figures to a smaller or single-config repo -- this one deliberately keeps two system variants, a VM test framework, and QEMU/KVM around at once.
  • The nix flake update that removed the glaze overlay (see history in flake.nix) likely lowered the first-build cost, not raised it: overriding a package's dependency changes its derivation hash, so the overlaid Hyprland could never hit the binary cache and had to compile from source in-container instead (this is precisely why the overlay existed -- nixpkgs' own Hyprland wouldn't build at all against its bundled glaze until upstream fixed it, see flake.nix). Post-update, the build log shows hyprland-0.56.1 arriving via copying path ... from 'https://cache.nixos.org' -- a substitution, not a local C++ compile. No exact before/after minutes are claimed here: the log evidence for which mechanism ran is solid, a stopwatch comparison across two different nixpkgs revisions and machine states would not be.
  • Warm-store rebuilds (the common case once nixos-nixstore already exists) are dominated by whatever actually changed; an unrelated one-line edit rebuilds in well under a minute.

Never GC inside the container

Do not run nix-collect-garbage or nix store gc inside the container. It does not just free space, it destroys the harness: the container's own nix, bash and coreutils live in the same shared /nix named volume as everything else, and nothing in that volume registers them as a GC root, so a collection deletes the exact toolchain the container needs to start. Every later command then fails with env: 'bash': No such file or directory, or with a bare podman OCI exec error -- execve() fails before docker/entrypoint.sh can run, so its own empty-store guard never gets to print anything. This happened twice during this project's development, and both times it looked like a broken image rather than a broken volume.

Routine ./nixdev usage never triggers this: doctor, build, check, test, vm, shell and update never collect garbage. It only happens if someone runs a GC deliberately -- typically to reclaim disk space.

To reclaim space, delete the volume and rebuild instead of GCing inside it. That is also the recovery procedure if a GC has already broken things:

podman volume rm -f nixos-nixstore nixos-nixhome
podman volume prune -f
rm -rf ~/.local/share/containers/storage/volumes/nixos-nixstore \
       ~/.local/share/containers/storage/volumes/nixos-nixhome
podman volume create nixos-nixstore
podman volume create nixos-nixhome
./nixdev doctor

All of those steps matter. podman volume rm alone was not sufficient: it left the volume metadata inconsistent -- the volume directory present but its _data subdirectory missing -- which made the image's auto-copy seeding of /nix silently do nothing, so every ./nixdev doctor check then failed with the same confusing "no such file or directory" errors as the original breakage. The prune and the explicit rm -rf of the two volume directories are what actually cleared that state; podman volume create afterwards produces a volume the image reseeds correctly on the next podman run.

One caution on the second line: podman volume prune -f is host-wide -- it removes every unused podman volume on the machine, not only this project's two. It skips volumes still referenced by a container (including stopped ones), but check podman volume ls first if other projects keep data in dangling volumes; the rm -rf and podman volume create steps are the ones doing the real work here.

The cost of recovery is a full rebuild from the binary cache, which is exactly what the Costs figures above measure: ~1.4 GB after ./nixdev doctor, ~7.8 GB after ./nixdev build, ~9.0 GB after ./nixdev check.

Dotfiles from separate repositories

users/tobi/dotfiles.nix loads config directories from external dotfiles repos, so Hyprland's settings can live in their own repository and be reused across machines. They are pinned flake inputs, not a pull-on-boot:

# flake.nix
inputs.dotfiles-hypr = { url = "github:you/hypr-dotfiles"; flake = false; };

# users/tobi/dotfiles.nix (or anywhere in the home-manager config)
my.dotfiles.sources.hypr = { src = inputs.dotfiles-hypr; };

Seven repos are wired up this way: hypr, waybar, nvim, ghostty, lazygit (all into ~/.config/<name>), plus zsh and tmux, which use target to land at ~/.zshrc and ~/.tmux.conf because convention puts them in $HOME rather than under $XDG_CONFIG_HOME.

Only nvim is recursive = true, and the distinction matters: lazy.nvim writes lazy-lock.json back into ~/.config/nvim, so a whole-directory symlink there would be read-only and break plugin syncing. lazygit looks like the same case but is not -- it keeps its mutable state in ~/.local/state/lazygit, so a read-only directory is fine.

Update with nix flake update dotfiles-hypr, then ./nixdev check before deploying. Pinning is what keeps nixos-rebuild switch reproducible, keeps a rollback restoring the desktop as well as the system, and keeps tests/boot-units.nix able to assert on the content that will actually land.

A git pull into ~/.config on boot is not an option regardless: home-manager owns those paths and they are read-only symlinks into the store.

Taking hypr from a repo means wayland.windowManager.hyprland.settings must be emptied (keep enable = true) -- an assertion in dotfiles.nix says so explicitly rather than letting it surface as a bare option conflict.

One thing to do by hand, in the hyprland-dotfiles repo. It still starts the old notification daemon: exec-once = mako in hyprland.conf, and hl.exec_cmd("mako") in hyprland.lua -- the latter being the live one, since Hyprland prefers the Lua config when both exist. Since mako is no longer installed, both are now no-ops that fail silently, and notifications work anyway because dunst is D-Bus activated rather than exec'd (see users/tobi/notifications.nix). Nothing is broken by leaving them, but they are misleading, and deleting both lines is the fix -- do not replace them with dunst, which would start a second copy that immediately exits without the bus name.

Project checkouts: clone-projects

users/tobi/projects.nix declares which repositories a fresh machine should end up with, cloned into ~/projects. Deliberately a one-time clone, not ongoing management: it never pulls, resets, or touches a dirty tree. Once a repo is on disk it is yours.

systemctl --user start clone-projects   # or just: clone-projects

It is not started at login, on purpose. The keys are expected to be passphrase-protected and nothing can prompt for one from a systemd user unit, so an autostart would fail on every login and train you to ignore a red unit. It is idempotent, so re-running after adding a repo clones only the new one.

Two SSH keys, placed by hand. ~/.ssh/keys/ is created empty at mode 700 by an activation step; this repo is not a secret store. Drop all4one (Gitea, which listens on port 2222, not 22) and github there, then ssh-add them before the run. identitiesOnly is set for both -- on GitHub especially, without it ssh offers every key in the agent and you authenticate as whoever the first accepted key belongs to, so pushes can silently land under the wrong account.

The toolchain lives here, not in the project. A repo with a devShell set gets a generated one-line .envrc pointing back at a devShell in this flake, so switching a project from pnpm to yarn is an edit in this repo and the project's own tree stays clean. .envrc and .direnv/ are covered by a global gitignore, so they can never be staged into a project by accident.

That .envrc names this flake by path -- my.projects.flake, by default ~/projects/nixos -- and direnv resolves that path at cd time, on the machine you are sitting at. So the config has to be checked out there, which is why nixos is one of the declared repos: the flake that built the system is no help, since a store path is immutable and, in the VM, not even known to the guest's Nix database. Point my.projects.flake elsewhere if the checkout lives somewhere else. When it is missing, clone-projects says so up front -- otherwise the only symptom is direnv reporting getting status of "~/projects/nixos": No such file or directory on every cd into a project, which reads like a broken project rather than a checkout that was never made.

devShell Node
node-pnpm 22
node-yarn 20
node24-pnpm 24

The version is only in the newest name because it was the second pnpm shell, added when projects declaring engines.node: 24 needed something node-pnpm's 22 did not satisfy. These shells pin the node version only -- the package manager comes from corepack, whose shims read each project's packageManager field, which is why three projects on pnpm 9, 10 and 11 share one shell. pkgs.pnpm/pkgs.yarn are deliberately absent: they would shadow the shims and silently supply a different version than the project declares.

A project that carries its own mise.toml (granti does) gets no devShell, because it already declares its node version. Both at once works but makes "where did this version come from" harder to answer.

Herdr topic indicator

herdr's sidebar lists every agent pane, but by default a row says only which agent is in it -- four claude entries look identical no matter what the four are doing. pkgs/herdr-topic-indicator is a local Herdr plugin that adds a third line naming the work:

monday_discord_bridge    claude    Field block hash fix
projects                 claude    Pushing Gitea dependency fixes
~                        hermes    Coolify MCP integration verification

It reads a pane's recent output, asks Claude Haiku for a 2-5 word label, and reports it to Herdr as pane metadata; users/tobi/herdr.nix adds the [ui.sidebar.agents] rows that render it through the $topic token. Event hooks on pane.agent_status_changed keep it near-live, and a background watcher (45s) catches work that changes while the status stays working.

Every refresh is gated on a hash of the pane's recent output, so an unchanged pane costs nothing -- an idle session makes no LLM calls at all.

Options, all under my.herdr.topicIndicator (see users/tobi/herdr.nix):

Option Default Purpose
enable true Whole feature on/off
color "#89b4fa" Topic row colour. Must be #RGB/#RRGGBB -- Herdr rejects named colours for inline sidebar token styles
settings { } Written to the plugin's config.env; keys are listed in the package's config.env.example (model, interval, max_chars, agents, watch, ...)
my.herdr.topicIndicator.settings = {
  interval = 90;     # slower sweeps
  agents = "claude"; # label only claude panes
};

Why an activation script rather than a config file

Two of the three files involved are ones Herdr itself writes, so neither can be a read-only store symlink:

  • ~/.config/herdr/config.toml gains theme, onboarding, status_indicators, sound, toast and agent-panel keys whenever those are changed in Herdr's in-app Settings UI. The activation script therefore edits it in place, rewriting only the region between its own # >>> managed by herdr.nix ... >>> markers and leaving everything else -- including whatever Settings has since written -- untouched.
  • ~/.config/herdr/plugins.json is the plugin registry, rewritten by every herdr plugin command. The script calls herdr plugin link against the package's store path; re-linking the same plugin id replaces its entry rather than duplicating it, so this is safe on every activation, which it must be: the store path changes with every edit to the plugin.

The plugin's own config.env is a store symlink (xdg.configFile), because the plugin only ever reads it.

If an [ui.sidebar.agents] section already exists in config.toml without those markers -- a hand-written one -- activation leaves it alone and prints a warning rather than appending a second one, which would be invalid TOML and make Herdr fall back to defaults for the whole section.

Checking on it

herdr-topic-indicator status              # watcher state and current topics
herdr plugin log list --plugin tobi.topic-indicator
herdr api snapshot | grep -o '"topic":"[^"]*"'

The upstream working tree is ~/projects/herdr-topic-indicator; develop there with herdr plugin link, then copy into pkgs/herdr-topic-indicator/plugin/. The dispatcher ships with an empty nix_path_prefix="" so that checkout keeps working from PATH, and the build substitutes an absolute store PATH into the packaged copy -- Herdr's server is long-lived and survives nixos-rebuild switch, so its inherited PATH can name store paths that have since been garbage collected.

Per-project npm registry tokens

For a private registry whose token cannot be committed. users/tobi/npmrc.nix adds a direnv function; the token lives outside every checkout:

# ~/.config/npmrc.d/acme   -- chmod 600; the directory is created 700
//npm.pkg.github.com/:_authToken=ghp_xxx
@acme:registry=https://npm.pkg.github.com/

Declare which projects use it in users/tobi/projects.nix, next to the clone URL, and the line lands in the generated .envrc:

granti = {
  url = "git@github.com:veganhacktivists/granti.git";
  npmrc = "veganhacktivists";
};
# the resulting .envrc -- one line, nothing secret
use npmrc acme

npmrc is independent of devShell: a project that pins node in its own mise.toml and therefore has no devShell (granti) still gets an .envrc for this alone. That was not always true -- the generator keyed the whole file off devShell, so those projects silently got no .envrc, direnv never ran in them and the token never applied.

It exports NPM_CONFIG_USERCONFIG for that directory only, so one machine can hold different tokens for the same registry across projects. It refuses to load a file that is not mode 600 -- npm neither checks nor complains, so a world-readable token would otherwise go unnoticed -- and watch_files it, so rotating the token does not need a manual direnv reload.

npm and pnpm honour NPM_CONFIG_USERCONFIG; Yarn Berry does not. Berry reads .yarnrc.yml and wants npmAuthToken, which takes a ${ENV_VAR} reference, so a Berry project needs the token exported as a variable instead.

Laravel: composer and sail without PHP installed

modules/laravel.nix puts two commands on PATH:

composer install     # Composer, in a container, no PHP on the host
sail up -d           # Sail -- installs vendor/ first if it is missing

No PHP is installed anywhere in this configuration, and none needs to be. Sail is itself a Composer dependency, so vendor/bin/sail does not exist on a fresh checkout and cannot bootstrap itself; the documented answer is to run Composer in a container once. Both commands do that, and sail then hands over to the project's real vendor/bin/sail, which is what talks to docker compose (podman plus podman-compose here -- see modules/containers.nix).

The container flags are the reason this is a script and not an alias, and modules/laravel-container.sh's header explains each one. The two that matter:

  • The image is library/composer, pinned to :2 so it tracks Composer 2 releases without a Composer 3 turning up mid-install. This used to be laravelsail/php<XX>-composer, with <XX> read out of the project's composer.json, and those images are abandoned: php82/php83-composer were last rebuilt in March 2024, php84-composer in November 2024, and php85-composer was never published at all -- so the PHP 8.5 project here could not run composer without a registry error that said nothing about PHP.
  • COMPOSER_IGNORE_PLATFORM_REQ=ext-*,lib-*, the consequence of that image: it is Composer plus a bare PHP, with no imagick, intl or redis, all of which these projects require. Those requirements are real, but this is not where they get checked -- the application runs in its own sail-<version>/app image, which does carry them, and sail composer install inside it is what validates them.
  • --userns=keep-id, for rootless podman only. Rootless podman maps the invoking user to container root, so --user 1000:1000 inside the container is a subuid, and everything Composer writes into vendor/ comes back owned by 100999-ish. Nothing errors -- you simply cannot edit your own files afterwards. keep-id maps your uid to itself.

Note what is not ignored: the php requirement itself. The image tracks current stable PHP -- 8.5 at the time of writing, which is what the project here pins -- so a project on an older series fails with a message naming both versions rather than quietly resolving its dependencies against the wrong PHP. The answer in that case is sail composer install, which runs against the version that project actually targets.

sail's bootstrap install goes further and passes --ignore-platform-reqs, which the plain composer command does not. That install only has to get laravel/sail onto disk, so a PHP-series mismatch should not block it: sail composer install against the right PHP is exactly the command it unlocks.

LARAVEL_CONTAINER_DRY_RUN=1 prints the container command instead of running it, which is how tests/boot-units.nix asserts the image and the keep-id flag without a network or a pull.

One thing to do by hand, in the other repo. The zsh dotfiles carry alias sail='[ -f sail ] && sh sail || sh vendor/bin/sail', and an alias beats a binary on PATH in an interactive shell -- so sail keeps meaning the alias and the bootstrap never runs. ~/.zshrc comes from that repo and home-manager cannot edit it (see the Dotfiles section above), and zsh sources no system-wide file after ~/.zshrc to unalias from. Dropping that one line from the dotfiles repo is the fix; the binary is a superset of it.

Port 80 and rootless containers

Sail's default APP_PORT is 80, and a rootless container cannot bind below net.ipv4.ip_unprivileged_port_start (1024 by default), so sail up on a stock .env fails at bind time. modules/laravel.nix sets that sysctl to 80, matching the host. The tradeoff is real and worth stating: any unprivileged process can then bind 80-1023.

The same setting on the CachyOS host lives in /etc/sysctl.conf, where it does not survive a reboot -- systemd-sysctl reads /etc/sysctl.d/*.conf and /usr/lib/sysctl.d/*.conf, not that file. sudo sysctl -p applies it for the current boot and then it silently reverts to 1024. Moving it to /etc/sysctl.d/99-unprivileged-ports.conf fixes it there. NixOS has no such trap: boot.kernel.sysctl writes /etc/sysctl.d/60-nixos.conf, which is one of the directories that actually gets read.

tests/boot-units.nix asserts the effective value in /proc, not the file -- a written file nothing applied is precisely the failure mode above, and a file-content assertion would have passed on the host too.

Local LLM for llama.vim: llama

The nvim config's llama.vim block points at http://127.0.0.1:8012. modules/llama.nix puts a llama command on PATH that serves exactly that, running llama.cpp's ROCm image under podman:

llama start      # downloads the model first if it is missing
llama status
llama logs -f
llama stop

The model is Qwen2.5-Coder-14B (Q5_K_M, ~9.8 GiB), fetched on first start into ~/llm_models (LLAMA_MODEL_DIR overrides). It is verified by exact byte count after download and resumable, so a truncated fetch fails loudly instead of handing llama.cpp a partial gguf. Nothing about it lives in the nix store -- a 9.8 GiB blob does not belong in a system closure.

Two details worth knowing. The port is published on 127.0.0.1:8012, not 0.0.0.0:8012, so the model server is not reachable from the LAN. And HSA_OVERRIDE_GFX_VERSION=11.0.0 is deliberate: the host GPU is Navi 32 (gfx1101) and ROCm ships gfx1100 kernels, which is the standard override for that card.

Firewall

modules/firewall.nix. NixOS enables a default-deny inbound firewall by default, so the explicit enable = true there changes nothing on its own -- it is written down because tests/boot-units.nix asserts the property rather than inheriting it quietly, and a stray enable = false anywhere in the module set would otherwise be invisible until something scanned the host.

No inbound SSH. services.openssh.enable = false lives in that module rather than next to programs.ssh.startAgent, because it is a decision about network exposure: sshd's openFirewall defaults to true, so enabling the daemon anywhere also opens 22 on every interface. The client side is untouched -- git clone over SSH and the agent still work.

Nothing is opened in that module on purpose. The only open ports come from the module that needs them: Steam's own remotePlay.openFirewall and localNetworkGameTransfers.openFirewall in modules/gaming.nix (TCP 27036/27037/27040, UDP 27031-27035, 27036, 10400, 10401). A port listed in two places is a port nobody can close with confidence.

Before publishing a container port: rootless podman proxies published ports through an ordinary userspace listener, so these rules apply to it. Rootful containers DNAT in PREROUTING and traverse FORWARD rather than INPUT, which bypasses the firewall entirely -- sail up as root would be reachable from the LAN even though nothing here opens its port.

Fonts

The desktop modules install the families the dotfiles actually name, which is not the same set as "some nerd fonts". They are split by who names them: modules/desktop-common.nix carries the ones a shared config asks for, and modules/desktop-hyprland.nix the ones only waybar and rofi do (so the GNOME host does not carry Font Awesome for a bar it never runs).

  • UbuntuMonoBront Nerd Font -- named by the ghostty config, so it lives in the common half. Self-built from a TTF vendored at fonts/, because there is no upstream to pin.
  • DroidSansM Nerd Font and Font Awesome 6 Free -- named by waybar's style.css, in that order; Hyprland-only. Note font-awesome_6, not font-awesome: the latter is 7.x and provides "Font Awesome 7 Free", which does not match the stylesheet.
  • Hack -- named by the rofi config; Hyprland-only for the same reason.

fonts.fontconfig.defaultFonts pins the generic families to Noto, matching what the CachyOS host resolves them to. Without it NixOS defaults to DejaVu, and anything asking for a generic family -- kitty and alacritty name no font at all -- renders differently on the two machines. tests/boot-units.nix asserts both the family names and the fc-match results.

A font a config names but nothing installs does not error. The config loads fine and silently falls back, which reads exactly like the config not being applied at all -- which is why these are asserted rather than assumed.

File manager

modules/nautilus.nix. Nautilus is a GTK4/libadwaita app, which matches the xdg-desktop-portal-gtk this configuration already runs; the KDE and Cinnamon alternatives cost 5-6x the closure for a similar or older-looking result.

Installing the package alone is the trap. services.gvfs.enable and services.udisks2.enable are both off by default, and without them nautilus launches and browses perfectly while delete-to-trash silently does nothing and removable media never appears in the sidebar. ffmpegthumbnailer covers video thumbnails; adwaita-icon-theme keeps the UI from rendering missing-image glyphs.

"Open in Terminal" comes from nautilus-open-any-terminal, pointed at ghostty through dconf -- its own default targets gnome-terminal, which does not exist here, so the menu entry would be dead.

xdg.portal.config.common.default = [ "hyprland" "gtk" ] is set here too. Order matters: hyprland first so screencast and screenshot keep working, gtk behind it to pick up FileChooser, which the hyprland portal does not implement at all. Without any config, xdg-desktop-portal 1.18+ has no declared preference and the resolution is not pinned.

Proton Mail Bridge: one manual login per machine

users/tobi/protonmail.nix runs bridge as a systemd user service, and the service is --noninteractive -- it never prompts, so it cannot do the initial Proton login itself. Do that once per machine, with the unit stopped so the two do not both try to bind 1143/1025:

systemctl --user stop protonmail-bridge
protonmail-bridge --cli        # then: login
systemctl --user start protonmail-bridge

That run creates ~/.config/protonmail/bridge-v3/vault.enc. The credentials to give the mail client are bridge's own per-account ones, shown from that same CLI (help lists its commands) -- the password is generated by bridge, not the Proton account password. Until the vault exists the unit's ConditionPathExists makes it skip on every login rather than fail, so a never-logged-in machine has no red unit to learn to ignore; the flip side is that a skipped unit is quiet, so check systemctl --user status protonmail-bridge first if mail stops working.

The vault key itself lives in the session keyring, which is why modules/desktop-common.nix enables services.gnome.gnome-keyring -- that one option also sets security.pam.services.login.enableGnomeKeyring, and the password typed at the greeter unlocks the keyring through PAM, so bridge finds its key with nothing prompting. It is in the common module because bridge runs on both hosts and its need for a keyring has nothing to do with which desktop is up.

login is the right service for both greeters, though for a reason that is easy to get wrong in each direction. Adding security.pam.services.sddm.enableGnomeKeyring does nothing at all: SDDM's PAM stack is a pure delegation to login (auth substack login, session include login), so it has no rules of its own for the module to join. GDM looks different in the module source -- gdm.nix defines explicit pam_gnome_keyring rules -- but on this configuration those land in gdm-autologin and gdm-fingerprint, while the rendered /etc/pam.d/gdm-password is also just includes and substacks of login. Checked rather than inferred, by filtering config.security.pam.services for a rendered pam_gnome_keyring: exactly login and gdm-autologin. tests/laptop-boot-units.nix asserts both the delegation and that gdm-autologin rule, the latter as a canary for the login.enableGnomeKeyring coupling being renamed away.

Two more consequences worth knowing: gcr's SSH agent is explicitly declined in that module (it defaults to on with gnome-keyring -- and GNOME asks for it a second time -- and refuses to coexist with the programs.ssh.startAgent that modules/common.nix wants, so that one false is what keeps the GNOME host evaluating at all), and KWallet is not an alternative -- the 3.25.0 bridge binary has no kwallet backend in it at all, only SecretService.

Not verified in the harness. Everything above about keyring unlocking is reasoned from the generated PAM stack and nixpkgs' modules, not observed: proving it end to end needs a real graphical login, which this container cannot do (see Known limitations). tests/boot-units.nix asserts what is checkable without one -- the unit exists, carries the vault condition, is wanted by default.target and not by home-manager's own graphical-session.target (nothing here ever reaches that target), that pam_gnome_keyring sits in /etc/pam.d/login with auto_start on its session line and that /etc/pam.d/sddm still delegates there, and that the keyring daemon and its cap_ipc_lock wrapper are in the closure.

Deploying to real hardware

These three steps assume a machine that already runs NixOS. For one that does not, see docs/hardware-install.md: it installs with the graphical ISO first and switches to this flake afterwards (which leaves a known-good graphical generation in the boot menu to fall back on, since neither desktop here is tested on real silicon), and wraps these three steps as its 3-5. It also documents one trap in step 1 below: the boot.loader options are set in hosts/<host>/default.nix, not in hardware.nix, precisely so that replacing that file wholesale cannot drop them.

Substitute the host you are deploying throughout: desktop or laptop. On the laptop, step 1 is not optional in the way it is on an already-installed desktop -- hosts/laptop/hardware.nix is a placeholder with fake device identifiers, and it prints an evaluation warning on every nix command until it is replaced. A generation built from it would not find its root filesystem.

  1. On the target machine, generate the real hardware config and replace the placeholder:

    sudo nixos-generate-config --show-hardware-config > hosts/<host>/hardware.nix
    
  2. Generate a real credential and set it. A real nixosConfigurations entry deliberately refuses to evaluate its system.build.toplevel without one (see lib/require-real-password.nix; checks.password-guard verifies the guard mechanism itself, hermetically, and checks.<host>-eval fully type-checks that host's GPU and hardware modules with a synthetic credential, both on every ./nixdev check):

    mkpasswd -m sha-512
    

    The target host in this project has no Nix and thus no mkpasswd on PATH by default. On Arch/CachyOS it ships in the whois package (sudo pacman -S whois); without installing anything, either ./nixdev shell (drops into nix develop, which has mkpasswd) or plain openssl passwd -6 (SHA-512 crypt, same format mkpasswd -m sha-512 produces, ships with OpenSSL which is already on most systems) work too.

    Add the result to hosts/<host>/credentials.nix -- not hosts/<host>/hardware.nix. That file is deliberately its own, separate module (imported only by that host's own nixosConfigurations entry, alongside hardware.nix and lib/require-real-password.nix), because hardware.nix exists specifically to be wholesale-replaced by step 1's nixos-generate-config command the next time the disk or kernel modules change -- a credential kept there would be silently dropped by that regeneration:

    users.users.tobi.hashedPassword = "<the mkpasswd output>";
    

    On committing that hash: nothing can log in over the network with it -- modules/firewall.nix disables sshd entirely, so the hash is usable only at this machine's own keyboard. If you ever re-enable services.openssh, revisit that decision, because the NixOS default is PasswordAuthentication yes and the pair would then be an SSH login prompt plus a password hash committed to git. To keep the hash out of the repo altogether, use hashedPasswordFile pointing at a path outside it (see hosts/<host>/credentials.nix's own header) instead of hashedPassword. On a laptop -- the machine most likely to be lost or stolen -- that is worth the extra step, and full-disk encryption (which this configuration does not set up; the graphical installer does) matters more than either.

  3. Deploy:

    sudo nixos-rebuild switch --flake .#<host>
    

Bare nix flake check fails by design until step 2 is done: it walks every nixosConfigurations entry including the real hosts, whose assertion is supposed to fail with no credential set. Use ./nixdev check instead -- it builds every checks.* attribute explicitly and checks each real host's assertions list rather than forcing its toplevel, so it correctly reports "only the expected guard is failing" (or, once step 2 is done for that host, nothing) and does not need or want that assertion to pass first. As shipped, desktop has a credential set and laptop does not, so a clean check currently means exactly one guard failing, on laptop.

Known limitations

No session-level test for GNOME either. tests/laptop-boot-units.nix is a unit-and-closure test like tests/boot-units.nix: it asserts GDM is up, the GNOME portal and extension are where the session will look for them, and every default this repo declines stays declined -- but nothing logs in and no gnome-shell is driven. Doing that needs autologin plus a Shell running --unsafe-mode so the test can call org.gnome.Shell.Eval over D-Bus (upstream's nixos/tests/gnome.nix is the model, and it is not much code). It was left out rather than half-done: that is the same Tier B slot hyprland-smoke occupies for the other host, and the rendering caveats below apply to it too. Worth knowing before trusting a green check to mean "the GNOME desktop comes up" -- what it means is "everything the desktop needs is present and correctly wired".

Correction (supersedes the two subsections below). Both subsections were written on the strength of Hyprland launches that ran in sessions with no seat -- via systemd-run, machinectl shell, or ssh, all of which show SEAT=- in loginctl list-sessions. Aquamarine's DRM backend cannot acquire DRM master without a seat, so those runs could not have succeeded whatever the renderer was, and the DRM_PRIME_CAP_IMPORT unsupported error they produced is not evidence that QEMU cannot host Hyprland.

What is established: both display paths render a real SDDM greeter, and ./nixdev vm --gl gives the guest genuine hardware rendering on the host GPU (in-guest glxinfo reports virgl (AMD Radeon RX 7800 XT (radeonsi, navi32, ACO))). Hyprland still fails after logging in at that greeter, on both paths. Why is unknown. The test that would settle it -- log in at the greeter, which is a real seat0 session, then read /run/user/1000/hypr/*/hyprland.log -- has not been run. Read the subsections below as a record of what was tried, not as a ceiling.

Tier B (hyprland-smoke): Hyprland does not come up in this container

tests/hyprland-smoke.nix tries to boot a NixOS VM and launch Hyprland itself inside it (as opposed to Tier A's boot-units, which only checks that the binaries and services required for a desktop session are present and wired up correctly). It is not registered in checks -- it fails deterministically on every approach tried, so it lives at packages.x86_64-linux.hyprland-smoke instead, runnable on demand with ./nixdev test hyprland-smoke.

Two approaches were tried; both fail, for different, well-understood reasons that are independent of this test's own correctness:

Plain software rendering (-device virtio-gpu-pci, no host GPU). Hyprland's compositor backend (aquamarine) hard-requires DRM_CAP_PRIME/DRM_PRIME_CAP_IMPORT on the GPU device it binds to. Neither QEMU's default bochs-drm VGA nor virtio-gpu-pci without virgl expose that capability, so the DRM backend never comes up:

ERR from aquamarine ]: drm: DRM_PRIME_CAP_IMPORT unsupported
ERR from aquamarine ]: drm: Failed checking features
ERR from aquamarine ]: DRM Backend failed
ERR from aquamarine ]: Wayland backend cannot start: wl_display_connect failed (is a wayland compositor running?)
CRIT from aquamarine ]: Cannot open backend: no allocator available
terminate called after throwing an instance of 'std::runtime_error'
  what():  CBackend::create() failed!

AQ_NO_KMS_REQUIREMENT=1 relaxes the display requirement, not this capability check, so it cannot paper over it.

GPU passthrough via virgl (human-approved escalation: pass the host's real /dev/dri/renderD128 into the container -- at the time, nixdev's run_nix --kvm did this automatically when the device existed and was writable, falling back gracefully otherwise; it no longer does, since the finding below made that passthrough pointless for every current consumer -- see the "Net effect" paragraph at the end of this section). This was wired all the way through and independently confirmed working -- but see the "confirmed working" caveat below: what was actually confirmed here is that QEMU itself starts cleanly and reaches a login prompt, not that Hyprland runs under it; the ./nixdev vm section further down found Hyprland still crashes identically under this exact recipe. With -device virtio-vga-gl + -display egl-headless,gl=on,rendernode=/dev/dri/renderD128, a qemu package override (the test framework defaults every node to qemu_test, a build with no GL/virgl display backends at all -- confirmed via -display help; the general-purpose qemu_kvm has both), and a wrapper supplying Mesa's GBM/EGL loader with the paths it would otherwise only find via a real NixOS host's /run/opengl-driver (absent in this bare container), the exact generated QEMU invocation boots cleanly by hand, outside the test framework.

It is still unreachable through pkgs.testers.runNixOSTest: nixpkgs' nixos-test-driver unconditionally appends -nographic (equivalent to -display none) to the QEMU invocation whenever DISPLAY/WAYLAND_DISPLAY are absent from the test driver process's own environment, and a Nix builder's environment is always stripped to a minimal, clean set regardless of what the invoking shell or container has -- confirmed by setting both variables on the container and rebuilding, which reproduced the identical failure:

qemu-system-x86_64: OpenGL is not supported by display backend 'none'

QEMU applies "last -display wins", and the test driver's append is always last, so no virtualisation.qemu.options value set from inside the test module can survive it. This is a deterministic property of this nixpkgs version's test framework, not host-specific flakiness, and would reproduce identically regardless of which attribute (checks.* or packages.*) the test is registered under -- there is no way to route around it from within a test module.

At the time this did not make the GPU-passthrough plumbing dead work: it stayed in nixdev (run_nix --kvm passed the render node whenever present) because the interactive VM (./nixdev vm) goes through a different, unaffected code path (system.build.vm's plain run-nixos-vm script, confirmed directly to never append -nographic). That path was reconsidered once ./nixdev vm itself hit the identical dead end (see the "Net effect" paragraph below) -- run_nix --kvm no longer passes the render node at all, since no current consumer of it (build/check/test) reads the device; ./nixdev vm still does, on its own.

Verified across three consecutive runs of ./nixdev test hyprland-smoke, each exiting 1 with the identical DRM_PRIME_CAP_IMPORT unsupported / CBackend::create() failed! failure -- not flaky, just not possible here.

./nixdev vm: shows the SDDM greeter, not a logged-in Hyprland session

./nixdev vm boots the config interactively and is software-rendered by default. This was verified, not assumed: a QEMU monitor screendump and an actual VNC client capture (vncdotool) both show a real, fully-rendered SDDM greeter (clock, date, the tobi user, session picker), not a blank/uniform frame. The same fallback was re-tested with NIXDEV_RENDER_NODE pointed at a nonexistent path -- ./nixdev vm logs a clear "continuing with software rendering" line and still renders correctly, confirming the graceful-degradation requirement.

Task 8 also tested the human-approved GPU escalation from Task 7 (-device virtio-vga-gl -display egl-headless,gl=on,rendernode=..., available via QEMU_OPTS but not the default -- see modules/gpu-vm.nix's comment). Two things were confirmed empirically that go beyond what Task 7 established:

  1. QEMU's VNC server in this build cannot display a GL/virgl scanout at all. Both the monitor's screendump (Error: no surface) and an actual VNC capture (a uniform black frame) confirm this, while the guest itself was independently confirmed healthy over SSH in the same run (display-manager.service active, /dev/dri/card0 and renderD128 present, no GBM/EGL/QEMU errors). This matches widely-corroborated upstream QEMU/virt-manager behavior: GL-accelerated virtio-gpu output is documented as a SPICE feature, not something plain VNC can read back.
  2. GPU passthrough does not clear the DRM_PRIME_CAP_IMPORT blocker either. Rebuilt with SDDM disabled (mirroring the Tier B test setup exactly, to rule out DRM-master contention) and launched Hyprland by hand over SSH under the full virgl recipe: aquamarine still hit the identical drm: DRM_PRIME_CAP_IMPORT unsupported -> CBackend::create() failed! crash documented above for plain software rendering. Task 7's own "GPU passthrough is real and functional end-to-end" finding verified only that QEMU itself doesn't crash (no GBM/EGL errors, reaches a login prompt) -- it never actually started Hyprland under virgl, since that was unreachable through the test driver. This is the first direct test of that combination, and it does not clear the bar: this specific virtio-gpu/virgl/kernel/Mesa combination does not expose DRM_PRIME_CAP_IMPORT to the guest regardless of GPU passthrough.

Net effect: for the one thing that reliably renders inside this VM (the SDDM greeter), plain software rendering over VNC is strictly better evidence than the GPU path, which would make it invisible for no compensating benefit. The GBM-wrapped qemu package that made the GPU escalation reachable at all (modules/gpu-vm.nix's qemuWithGbm, supplying Mesa's GBM/EGL loader paths the escalation's -display egl-headless needs) was removed after this finding -- it added real code for a path proven to deliver nothing; the baseline -vnc :0 option that gpu-vm.nix still sets is unrelated and untouched. ./nixdev vm still passes the host's render node into the container whenever present (harmless on its own; run_nix --kvm, used by ./nixdev build/check/test, does not -- nothing there ever read it), and QEMU_OPTS remains available for anyone who wants to re-attempt the GPU display path by hand, though without the removed wrapper it will not get as far as the blank screen documented above. Logging in past the greeter to an actual Hyprland session inside this containerized VM remains blocked by the same root cause as Tier B, now confirmed independent of GPU passthrough too.