- Nix 82.5%
- Shell 13.1%
- JavaScript 4.2%
- Dockerfile 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| assets | ||
| docker | ||
| docs | ||
| fonts | ||
| hosts | ||
| lib | ||
| modules | ||
| pkgs | ||
| templates | ||
| tests | ||
| users/tobi | ||
| .gitignore | ||
| compose.yaml | ||
| flake.lock | ||
| flake.nix | ||
| nixdev | ||
| README.md | ||
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 getslib/hardware-stub.nix, an invented disk/bootloader that only needs to be internally consistent, never boot real silicon. (It also forcessystemd-bootoff, 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>VmModulesdirectly, withouthardware-stub.nix-- becausepkgs.testers.runNixOSTestsupplies 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.nixormodules/gpu-intel.nix; the VM getsmodules/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 vmstill passes the host's render node into the container on a best-effort basis, but nothing currently reads it without hand-rolledQEMU_OPTS. - Credentials: the real host gets
hosts/<host>/credentials.nixpluslib/require-real-password.nix, the assertion that refuses to let it evaluate without a real one; the VM getslib/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.nixholds 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.nixordesktop-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.nixis the desktop-agnostic core (dotfiles, projects, direnv, rclone, Proton Mail Bridge, GTK/cursor theme, terminals, mpv), andflake.nixadds exactly one session profile --hyprland-session.nix(Hyprland, waybar, rofi, dunst, and the three dotfiles repos that only mean anything there) orgnome-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 byhosts/laptop/default.nix, andmodules/gaming.nixbydesktopalone. 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 tinynixpkgs#hellobuild/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 checkon top of that same store --checkadditionally builds the Tier Aboot-unitsVM test (its own kernel/initrd/QEMU closure, confirmed to add ~1.2 GB on its own) and the two hermetic checks,password-guardanddesktop-eval. Both are negligible: building them added no measurable size at all (confirmed at the byte level, not just by rounding) --desktop-evalforces full evaluation ofmodules/gpu-amd.nixandhosts/desktop/hardware.nix(the real host's own, otherwise-untested modules) without building anything they reference; see the comment onmkHostEvalinflake.nixfor 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
laptophost is not in any figure above: every measurement here predates it, and none has been re-run. Expect./nixdev checkto cost meaningfully more than the ~9.0 GB above now, in two parts. The cheap part islaptop-eval, which by construction adds nothing measurable -- it is the samemkHostEvalmechanism. The real part is GNOME:laptop-vm's toplevel and thelaptop-boot-unitsVM test pull in gnome-shell, gnome-session, GDM, the GNOME portal, gnome-control-center and the core-app set that survivesenvironment.gnome.excludePackages-- a large closure that shares little with Hyprland's beyond Mesa and GTK../nixdev buildand./nixdev vmare unaffected unless you pass--host laptop, since both default todesktop;checkcovers 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
herdron 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.nixputs it inenvironment.systemPackages, so./nixdev buildand./nixdev checkboth 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 fromcache.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 forherdr'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'shyprland-smokeVM test (deliberately kept runnable -- see Known limitations), and the dev shell's tooling (nixpkgs-fmt,nil), none of which a first./nixdev buildpulls 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 updatethat removed theglazeoverlay (see history inflake.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 bundledglazeuntil upstream fixed it, seeflake.nix). Post-update, the build log showshyprland-0.56.1arriving viacopying 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-nixstorealready 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.tomlgainstheme,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.jsonis the plugin registry, rewritten by everyherdr plugincommand. The script callsherdr plugin linkagainst 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:2so it tracks Composer 2 releases without a Composer 3 turning up mid-install. This used to belaravelsail/php<XX>-composer, with<XX>read out of the project'scomposer.json, and those images are abandoned:php82/php83-composerwere last rebuilt in March 2024,php84-composerin November 2024, andphp85-composerwas never published at all -- so the PHP 8.5 project here could not runcomposerwithout 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 ownsail-<version>/appimage, which does carry them, andsail composer installinside it is what validates them.--userns=keep-id, for rootless podman only. Rootless podman maps the invoking user to container root, so--user 1000:1000inside the container is a subuid, and everything Composer writes intovendor/comes back owned by 100999-ish. Nothing errors -- you simply cannot edit your own files afterwards.keep-idmaps 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 atfonts/, because there is no upstream to pin.DroidSansM Nerd FontandFont Awesome 6 Free-- named by waybar'sstyle.css, in that order; Hyprland-only. Notefont-awesome_6, notfont-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.
-
On the target machine, generate the real hardware config and replace the placeholder:
sudo nixos-generate-config --show-hardware-config > hosts/<host>/hardware.nix -
Generate a real credential and set it. A real
nixosConfigurationsentry deliberately refuses to evaluate itssystem.build.toplevelwithout one (seelib/require-real-password.nix;checks.password-guardverifies the guard mechanism itself, hermetically, andchecks.<host>-evalfully type-checks that host's GPU and hardware modules with a synthetic credential, both on every./nixdev check):mkpasswd -m sha-512The target host in this project has no Nix and thus no
mkpasswdonPATHby default. On Arch/CachyOS it ships in thewhoispackage (sudo pacman -S whois); without installing anything, either./nixdev shell(drops intonix develop, which hasmkpasswd) or plainopenssl passwd -6(SHA-512 crypt, same formatmkpasswd -m sha-512produces, ships with OpenSSL which is already on most systems) work too.Add the result to
hosts/<host>/credentials.nix-- nothosts/<host>/hardware.nix. That file is deliberately its own, separate module (imported only by that host's ownnixosConfigurationsentry, alongsidehardware.nixandlib/require-real-password.nix), becausehardware.nixexists specifically to be wholesale-replaced by step 1'snixos-generate-configcommand 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.nixdisables sshd entirely, so the hash is usable only at this machine's own keyboard. If you ever re-enableservices.openssh, revisit that decision, because the NixOS default isPasswordAuthentication yesand 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, usehashedPasswordFilepointing at a path outside it (seehosts/<host>/credentials.nix's own header) instead ofhashedPassword. 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. -
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, orssh, all of which showSEAT=-inloginctl 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 theDRM_PRIME_CAP_IMPORT unsupportederror they produced is not evidence that QEMU cannot host Hyprland.What is established: both display paths render a real SDDM greeter, and
./nixdev vm --glgives the guest genuine hardware rendering on the host GPU (in-guestglxinforeportsvirgl (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 realseat0session, 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:
- 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.serviceactive,/dev/dri/card0andrenderD128present, 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. - 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 exposeDRM_PRIME_CAP_IMPORTto 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.