|
|
||
|---|---|---|
| hosts/zvm | ||
| modules | ||
| .gitignore | ||
| CLAUDE.md | ||
| flake.lock | ||
| flake.nix | ||
| justfile | ||
| README.md | ||
zvm — Immutable, Ephemeral NixOS VM
A locked-down NixOS VM for QEMU/KVM where:
- The user has no sudo and is not in
wheel. - The root filesystem is a tmpfs, wiped on every boot.
- Only an allowlist persists across reboots:
/etc/ssh/(host keys)/etc/machine-id/var/lib/nixos(uid/gid stability)/var/lib/netbirdand~/.config/netbird/(Netbird VPN identity)/var/log(journald history)/persist/passwords/zvm(user password hash — must be created on first boot)
- The VM auto-updates from a public GitHub flake hourly and re-applies the latest flake on every boot.
- Updates that need a kernel/initrd switch are staged but do not auto-reboot — the user reboots manually.
Layout
flake.nix
hosts/zvm/default.nix # composes everything
modules/
ephemeral.nix # tmpfs / + impermanence
user.nix # single non-wheel user
hardening.nix # no sudo, locked nix, ssh keys-only
updates.nix # autoUpgrade + boot-time rebuild
netbird.nix # netbird daemon
Build
just build # builds main.raw (sparse, ~2 GiB on disk, 20 GiB virtual)
just start # boots main.raw under QEMU+OVMF
just run # build then start
just verify-reproducible # builds twice, sha256s, cmps
Distribution artifact
just build produces main.raw — fast, but raw is fragile across transports (tar, scp, S3 often lose sparseness and inflate it to 20 GiB). For publishing, convert to compressed qcow2:
just package # main.raw → zvm.qcow2 (compressed, typically ~1 GiB)
zvm.qcow2 is bootable directly by any libvirt/QEMU host. Convert further for VMware/VirtualBox if needed (qemu-img convert -O vmdk ..., qemu-img convert -O vdi ...).
First-boot preparation
The qcow2 expects two labeled partitions in addition to the EFI boot:
nix— holds the Nix store and generationspersist— holds the allowlist
Before first boot, mount persist and seed:
mkdir -p /persist/passwords /persist/etc/ssh /persist/var/lib/netbird /persist/home/zvm/.config/netbird
mkpasswd -m sha-512 > /persist/passwords/zvm
chmod 600 /persist/passwords/zvm
Add the user's SSH public key to /persist/home/zvm/.ssh/authorized_keys (also declare it via users.users.zvm.openssh.authorizedKeys.keys in the flake if you'd rather bake it in).
Run
qemu-system-x86_64 \
-enable-kvm -m 4096 -smp 2 \
-drive file=result/nixos.qcow2,if=virtio \
-nic user,model=virtio-net-pci \
-bios /run/libvirt/nix/store/.../OVMF.fd
Updating the fleet
- Push a change to
git+https://forgejo.codingcoffee.me/codingcoffee/zvm. - Within an hour every running VM picks it up via
system.autoUpgrade. - Every reboot re-applies the latest flake regardless of the timer.
The flake URL is hard-coded in modules/updates.nix — change it there.
Reproducibility
The Nix closure of the system is bit-for-bit reproducible: same flake.lock → same system.build.toplevel store path on any host.
nix path-info .#nixosConfigurations.zvm.config.system.build.toplevel
The main.raw image is not byte-identical across builds, even though it's semantically identical. We pin everything we can — GPT disk GUID, partition GUIDs, filesystem UUIDs, FAT volume ID, ext4 hash seeds, FAT directory mtimes, the random-seed file — but ext4 inode timestamps (crtime/atime/mtime/ctime on every file in the Nix store and /persist) are written by the kernel and don't honor SOURCE_DATE_EPOCH. Normalising them would require a debugfs walk over every inode after install; not worth the complexity.
For audit ("which software is in this VM?"), verify the closure path, not the image hash.