No description
Find a file
2026-06-02 11:35:45 +05:30
hosts/zvm feat: disable copy 2026-05-26 15:42:28 +05:30
modules Revert "chore: tweak space" 2026-06-02 11:35:45 +05:30
.gitignore chore: add ovmf_vars.fd 2026-05-25 16:08:05 +05:30
CLAUDE.md docs: claude 2026-06-01 22:47:13 +05:30
flake.lock fix: impermanence follow home-manager 2026-05-25 16:12:03 +05:30
flake.nix fix: impermanence follow home-manager 2026-05-25 16:12:03 +05:30
justfile chore: inc mem for ff 2026-05-25 16:45:39 +05:30
README.md chore: change remote url 2026-05-23 12:29:23 +05:30

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/netbird and ~/.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 generations
  • persist — 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

  1. Push a change to git+https://forgejo.codingcoffee.me/codingcoffee/zvm.
  2. Within an hour every running VM picks it up via system.autoUpgrade.
  3. 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.