Skip to content

vm-boot: does this config still boot on a machine that is not this one?

nix build .#vm-boot. A NixOS test that boots THIS host inside QEMU and asserts that the config was APPLIED, not merely that it evaluated. hosts/nixos-kingston/vm-boot.nix is the variant it boots.

Rule 8 requires nixos-rebuild build before the switch, which proves the tree EVALUATES and BUILDS. Nothing proved it BOOTS anywhere other than on this machine, and that is the question the whole repo exists to answer: if the hardware dies tomorrow, does the SSOT still produce a running system? Building is not booting, the same distinction flake.md records between evaluating and building.

Why it is a package and NOT a check

Anything in checks enters nix flake check, so the CI would build a whole desktop closure and run QEMU on a free runner with ~20 GB free. That is the same reason system.build.toplevel stays out of the gate, and it is why vm-boot is also removed from checks.packages, next to curseforge, each with its own reason.

So this one runs HERE, on the machine that has the store warm, and it needs a weekly trigger: a check nobody remembers to run is a check that does not exist, which is the argument the CI's own header makes.

The weekly drill

home/services/vm-boot-drill.nix, a systemd --user timer on Sundays at 11:00, Persistent so a machine that was off runs it on the next boot. It is SILENT on success (the journal has the line) and pushes one ntfy notification when it fails.

User level and not system level, for two reasons that both point the same way: the flake has a PRIVATE input over git+ssh, and the key belongs to the user (the same reason rule 13 keeps update as the user), and the checkout it builds is the user's. programs.nh.flake is read through osConfig, so the repo path keeps one owner (rule 11).

A cache HIT is the normal case, and that is the point. The test is one derivation over the config, so with nothing changed since the last run nix build returns from the store in seconds and no VM boots. The VM only really boots again when a rebuild changed the closure, which is exactly "re-verify what is new, and pay nothing for what was already verified". A nix-collect-garbage that sweeps the result also forces an honest re-run, which is fine.

TWO HONEST FALSE ALARMS to expect. The drill builds the WORKING TREE, so a broken edit sitting uncommitted on a Sunday fires it, and that is arguably useful. And a failure to fetch the private input (no network, a rotated key) fires it too, which is not a config regression: the journal says which one it was.

The variant is not a second config

commonModules was hoisted out of mkHost for this: the test builds the SAME module list the host builds, plus hosts/nixos-kingston/vm-boot.nix. One definition, two consumers, instead of a copy that drifts (rule 14). What the variant turns off is only what needs the real hardware, a secret or another machine:

Override Why
disko.devices = mkForce { } disko generates the Kingston's fileSystems and the test VM brings its own root; keeping both waits forever for a device that does not exist
/mnt/seagate-old as tmpfs it is a second physical disk, and the mount point still has to exist
every my.services off they need secrets, /srv, a GPU or the private input. Read from the OPTION SET (options.my.services), never from a copy of the list, so a toggle added tomorrow is off here by construction (rule 11)
lightdm and autoLogin off no GPU and no monitor. A session that cannot start would drown the failed-unit list this test exists to read

node.pkgsReadOnly = false is needed because runNixOSTest pins the node's pkgs and makes the nixpkgs.* options read-only, while this config sets both nixpkgs.overlays (in flake.nix) and nixpkgs.config.allowUnfree (in system/core/core.nix). Without it the eval dies on "nixpkgs.config is set to read-only".

What it found on the first run, before it ever booted

my.services.jellyfin = false did not evaluate. system/services/jellyfin.nix declared users.users.jellyfin.extraGroups OUTSIDE the toggle, so with the service off the user was half-declared and the assertion fired: "Exactly one of isSystemUser and isNormalUser must be set". The panel in hosts/<host>/services.nix was offering a switch that did not work, and nothing could have caught it while this machine kept the service ON. The systemd.services.jellyfin UMask override had the same shape and would have declared a unit with no ExecStart. Both moved behind the toggle.

That is the class of bug this test exists for: a declaration that only works because another module happens to complete it.

The first version of the test LIED, and this is the important part

It asserted only on systemctl list-units --state=failed and passed green. The full log said otherwise:

sops-install-secrets: cannot read keyfile '/var/lib/sops-nix/key.txt'
Activation script snippet 'setupSecretsForUsers' failed (1)
Activation script snippet 'setupSecrets' failed (1)

Activation snippets do not appear in systemctl --failed, because they run inside the activation script and not as units. A boot test that only reads failed units therefore passes while half of the activation failed, which is worse than no test: it is a green light over a broken system. The script now parses the journal for failed snippets and compares them against an ALLOWED of exactly those two, each with its reason.

And the reason is the boundary of this test, not a defect: the age key lives OUTSIDE git by design (rule 12), so a VM that never received it cannot install secrets. Proving that half is the DRILL's job, with the key coming from the vault.

What it proves, and what it does not

PROVES, measured on 23/08/2026: the 115 .nix evaluate together with the panel off; the system reaches multi-user.target in 9.96s (666ms kernel, 3.675s initrd, 5.618s userspace); sshd comes up; the user exists with zsh as their shell; the whole home-manager generation activates with Result=success; and no unit fails.

DOES NOT PROVE: the secrets (no age key), the compositor and the GPU, the optional services (all off), the real disk layout, or anything about hardware. The disk layout is the next stage: nix build .#nixosConfigurations.nixos-kingston.config.system.build.vmWithDisko formats virtual disks with the real disko layout, and nixos-rebuild build-vm is the WRONG tool for it (it hangs waiting for the root partition on a disko layout, disko issue #668).

An oddity seen twice and NOT investigated: nix-daemon segfaults in libnixstore during the VM's boot, with no visible effect on anything the test asserts. Written down so the next reader knows it is known and not new.