Disclaimer: This is not a community framework or distribution. It’s a private configuration and an ongoing experiment to feel out NixOS. I make no guarantees that it will work out of the box for anyone but myself. It may also change drastically and without warning.
Welcome to my compilation of dotfiles, the secret sauce behind the construction and configuration of my Linux systems. For a deeper dive into NixOS, the innovative Linux distribution I use, and Nix, the powerful package management tool and language that this repository is primarily written in, click Nix.
The configuration follows the dendritic pattern on top of flake-parts: every .nix file under modules/ is automatically discovered and treated as a flake-parts module — there are no hand-written imports = [...] lists. Adding a new program, service, or host is a single touch away.
| Host | Role |
|---|---|
| aanallein | HP server |
| rhuidean | Virtual machine — used for testing |
| tanchico | Gaming PC — 32 GB RAM, Ryzen 7 5800X3D |
| terangreal | Desktop PC — 48 GB RAM, Ryzen 7 5600 |
| tuathaan | HP work laptop |
.
├── flake.nix # ~80 lines: inputs + flake-parts.lib.mkFlake { imports = [ (import-tree ./modules) ]; }
├── flake.lock
├── lib/ # repl helper used by pkgs/repl
├── pkgs/ # custom packages: raiderio-client, repl, warcraftlogs
├── scripts/install.sh # bootstrap installer (uses disko)
├── secrets/ # sops-encrypted secrets
└── modules/ # ★ everything that configures the system lives here
├── options.nix # declares flake.{nixosModules,homeModules} schema
├── lib.nix # exposes flake.lib (nixpkgs.lib // home-manager.lib)
├── home.nix # home-manager base module
│
├── perSystem/ # devShell, formatter, packages, pre-commit checks
├── config/ # custom NixOS options + assertions
│ # (environment.{server,desktop,gaming,develop,theme})
├── hardware/ # boot, plymouth, disko, graphics, locale, network, fonts, …
├── programs/ # one file per program, NixOS or home-manager
├── services/ # one file per service, NixOS or home-manager
├── themes/ # gtk theme + colour palette
├── scripts/ # raw shell-script derivations + HM module exposing them
└── hosts/<name>/ # one directory per host
├── default.nix # builds nixosConfigurations.<name>
├── _hardware-configuration.nix # ignored by import-tree (leading "_")
├── _disks.nix # disko config; used by scripts/install.sh
├── _machine.nix # host NixOS bits (hostname, env toggles)
└── _home.nix # host-specific home-manager bits
Each file under modules/ is a flake-parts module. It declares its class by registering under one (or both) of:
flake.nixosModules.<name>— consumed by NixOSflake.homeModules.<name>— consumed by home-manager
A NixOS-only example:
# modules/services/openssh.nix
_: {
flake.nixosModules.services-openssh = _: {
services.openssh = {
enable = true;
settings = {
PermitRootLogin = "no";
PasswordAuthentication = false;
};
};
};
}A home-manager-only example:
# modules/programs/bat.nix
_: {
flake.homeModules.programs-bat = _: {
programs.bat.enable = true;
};
}A single file can register both sides of the same concern (see modules/programs/fish.nix, modules/programs/hyprland.nix, modules/services/sops.nix, modules/programs/cachix.nix):
# modules/programs/fish.nix
_: {
flake.nixosModules.programs-fish = { pkgs, lib, ... }: {
programs.fish = {
enable = true;
vendor.completions.enable = true;
shellAliases = { ls = "${pkgs.eza}/bin/eza"; /* … */ };
# …
};
};
flake.homeModules.programs-fish = { pkgs, ... }: {
programs.fish.plugins = [ /* … */ ];
home.packages = with pkgs; [ eza fd jump ];
};
}# modules/hosts/aanallein/default.nix
{ config, lib, inputs, ... }:
{
flake.nixosConfigurations.aanallein = inputs.nixpkgs.lib.nixosSystem {
specialArgs = { inherit inputs; self = config.flake; };
modules =
(lib.attrValues config.flake.nixosModules) # every NixOS module
++ [
inputs.disko.nixosModules.disko
inputs.home-manager.nixosModules.home-manager
inputs.impermanence.nixosModules.impermanence
inputs.sops-nix.nixosModules.sops
./_machine.nix # host-local bits
{
home-manager = {
useGlobalPkgs = true;
useUserPackages = true;
extraSpecialArgs = { inherit inputs; self = config.flake; };
backupFileExtension = ".hm-backup";
users.merrinx.imports =
(lib.attrValues config.flake.homeModules) # every HM module
++ [
inputs.nix-colors.homeManagerModules.default
inputs.sops-nix.homeManagerModules.sops
./_home.nix
];
};
}
];
};
}Every host loads every registered module. Host-specific behaviour comes from setting the gating options in _machine.nix (e.g. environment.server.enable = true, environment.desktop.windowManager = "gnome", service.wireguard.enable = false). Modules are no-ops on hosts that don’t enable them.
The _ prefix on per-host data files (_hardware-configuration.nix, _disks.nix, _machine.nix, _home.nix) tells import-tree to skip those paths so they aren’t loaded as flake-parts modules — they’re plain NixOS / home-manager modules referenced by relative path from the host’s default.nix.
# modules/programs/htop.nix
_: {
flake.homeModules.programs-htop = _: {
programs.htop.enable = true;
};
}That’s it. Every host picks it up on the next rebuild.
# modules/programs/myprog.nix
_: {
flake.nixosModules.programs-myprog = _: {
programs.myprog.enable = true;
};
flake.homeModules.programs-myprog = { pkgs, ... }: {
programs.myprog.theme = "dracula";
home.packages = with pkgs; [ myprog-extras ];
};
}Identical pattern, in modules/services/<name>.nix. Pick the right namespace depending on whether it’s a system service or a user service.
mkdir modules/hosts/<name>
# Generate hardware-configuration into modules/hosts/<name>/_hardware-configuration.nix
# Add _disks.nix, _machine.nix, _home.nix
# Copy modules/hosts/aanallein/default.nix and rename "aanallein" → "<name>"Edit modules/hosts/<name>/_machine.nix:
{
service.wireguard.enable = false;
environment.gaming.enable = false;
}Prefix the file (or directory) name with _. import-tree skips any path containing /_. That’s how all the per-host data files and modules/scripts/_*.nix raw derivations stay out of the auto-import.
Day-to-day rebuilds go through nh (configured in modules/programs/nh.nix). It wraps nixos-rebuild, shows an nvd diff before activation, pipes builds through nom, and handles GC.
nh os switch # build + diff + activate (uses configured flake path)
nh os boot # set as next boot only, no activation
nh os test # activate without making it the boot default
nh os switch -H aanallein # explicit host (otherwise hostname is used)
nh clean all # manual GC (a systemd timer also runs this)
nh search firefox # search nixpkgsIf you ever need the unwrapped tool (e.g. recovery, or a host where nh isn’t available yet):
nix-shell
nixos-rebuild switch --flake .#(This assumes your hostname matches one of the configurations in the flake. Otherwise pass the host explicitly: nixos-rebuild switch --flake .#aanallein.)
build-installer (in modules/scripts/, available in the user environment) bakes a host’s entire install onto a single bootable stick: a minimal NixOS live ISO with this flake embedded, plus a SOPSKEY partition carrying the sops secrets needed at install time. A reinstall of an existing host then becomes: boot, run one command, confirm the disk wipe.
A dd-flashed bootable USB does not need to be pre-formatted — the raw image overwrites the whole device (partition table included), so any existing filesystem is wiped anyway. The only things that matter are picking the right device and making sure none of its partitions are mounted.
# 1. Find the device. Run before and after plugging the stick in and compare;
# match on size/model. Use the whole-disk node (/dev/sdX), NOT a partition
# (/dev/sdX1). On NVMe/SD it looks like /dev/nvme0n1 or /dev/mmcblk0.
lsblk -o NAME,SIZE,MODEL,TRAN,MOUNTPOINTS
# 2. Unmount any auto-mounted partitions of that device.
sudo umount /dev/sdX?* 2>/dev/null || true
# 3. (Optional) wipe old partition signatures to avoid stale-table confusion.
sudo wipefs -a /dev/sdXDouble-check the device path.
ddto the wrong/dev/sdXwill erase that disk irrecoverably.
# Build the ISO only (prints the built ISO's store path):
build-installer terangreal
# Build, flash to a USB, and write the SOPSKEY partition in one go:
build-installer terangreal /dev/sdX
# Building for a host other than the one you're on → point at its backed-up key:
build-installer tuathaan /dev/sdX /path/to/tuathaan/ssh_host_ed25519_keyIf you built the ISO without a device (first form above) and want to copy it to the stick by hand — note this gives you a bootable installer but without the SOPSKEY partition, so the sops bootstrap falls back to the interactive path:
iso=$(nix build .#nixosConfigurations.installer-terangreal.config.system.build.isoImage \
--no-link --print-out-paths)/iso/*.iso
sudo dd if=$iso of=/dev/sdX bs=4M status=progress oflag=sync conv=fsync
syncWhen a device is given it re-confirms the device, dd’s the ISO, then appends a small vfat SOPSKEY partition containing:
| File on SOPSKEY | Purpose |
|---|---|
keys.txt | the master/admin age key — used by sops on the installer and seeded to the host’s /etc/sops/age/keys.txt |
hostkeys/<host>/ssh_host_ed25519_key{,.pub} | the host’s own sops identity, auto-restored on install |
The host key is grabbed from the live /etc/ssh when you build for the current host, or from the [host-key-path] argument otherwise. Both secrets are written after dd and live only on the physical USB — they never enter the (world-readable) nix store.
On the target machine:
- Boot the USB.
- Run
install-system. - Confirm the disk wipe once.
install-system copies the embedded flake to a writable dir and runs scripts/install.sh <host>. With the host key present on SOPSKEY the sops bootstrap is automatic (Path B, no prompts). For a brand-new host with no key on the stick it falls back to the interactive bootstrap (Path A).
That’s the whole install: disko, the sops bootstrap, Secure Boot key generation (for hosts that enable it), and nixos-install all run unattended. The machine reboots into a working, fully-signed system — no extra commands needed.
The
SOPSKEYpartition holds private keys in plaintext (it’s vfat). Treat the stick as a secret-bearing device and wipe it after installing if you don’t want it lying around.
For a host with hardware.secureboot.enable = true the install signs the boot files but does not enforce Secure Boot yet — the keys aren’t enrolled in firmware. This is a one-time, per-machine step that must be done by hand (it requires physical firmware access; see Secure Boot (lanzaboote) for the full rationale):
- Reboot → firmware/BIOS → put Secure Boot into Setup Mode (erase the Platform Key).
- Boot the installed system, then enroll your keys (keep Microsoft’s too, for option ROMs):
sudo sbctl enroll-keys --microsoft - Reboot → firmware → enable Secure Boot. Confirm with
bootctl status→Secure Boot: enabled (user).
After this, every future install-system / nixos-rebuild on that machine stays signed automatically — you never repeat these steps. Hosts without Secure Boot skip this entirely.
You are usually in the firmware menu anyway: a machine that ships with Secure Boot on (factory Microsoft keys) won’t boot the NixOS installer ISO, so you disable it to install — then just re-enable it after
enroll-keys.
The installer script handles disko formatting and the sops-nix bootstrap that’s otherwise a chicken-and-egg problem on fresh installs (the host has no /etc/ssh/ssh_host_ed25519_key yet, so sops-nix can’t decrypt secrets during activation).
Two USB drives is the simplest setup; you can also use one drive with two partitions:
- Boot USB — the NixOS installer ISO, written with
ddor Rufus. - Secrets USB — a small (FAT32 or ext4) USB containing one or more of:
File When you need it master-age-key.txtRecommended on every install — seeds the host’s /etc/sopsidentity; also lets you runsops updatekeyson the installer (Path A). The script auto-detects it at/mnt/usb/master-age-key.txt,/mnt/usb/keys.txt, or$SOPS_AGE_KEY_FILEhosts/<host>/ssh_host_ed25519_keyPath B (reinstall) — restores the host’s previous age identity hosts/<host>/ssh_host_ed25519_key.pubPath B — companion public key hosts/<host>/ssh_host_rsa_key(opt)Path B — only if you want the host’s old RSA SSH key to keep working too hosts/<host>/ssh_host_rsa_key.pub” master-age-key.txtis one line in the formAGE-SECRET-KEY-1XXXXX.... It is the private half of&master age13krpm9nls3799...in.sops.yaml— the master recipient that decrypts every secrets file. Treat it like the keys to your house: it’s the recovery key for everything sops-encrypted.You don’t need any of this on the USB if you have another machine (your daily driver) within reach that already has the master age key — see Path A note below.
- (Optional) Offline flake copy —
git cloneof this repo onto the secrets USB. Useful if the install machine doesn’t have network access yet. Otherwise, the install script clones over the network.
# Inside the NixOS live installer:
# 1. Mount the secrets USB (adjust device as needed)
sudo mkdir -p /mnt/usb
sudo mount /dev/sdX1 /mnt/usb
# 2. Get the flake
git clone https://github.com/gako358/dotfiles.git
cd dotfiles
# 3. Run the helper
sudo bash scripts/install.shThe script:
- Asks which host you’re installing.
- Runs
diskoagainstmodules/hosts/<host>/_disks.nixto format & mount. - Bootstraps the sops host key into
/mnt/persist/etc/ssh/— branches into one of three paths described below. - Seeds the master age key into
/mnt/persist/etc/sops/age/keys.txt(from$SOPS_AGE_KEY_FILE), so the host’s secondary sops identity exists on first boot. Skipped gracefully if no master key is available. - Seeds Secure Boot signing keys — only if the target host sets
hardware.secureboot.enable = true(see Secure Boot (lanzaboote)). A no-op for every other host. - Runs
nixos-install --flake .#<host>.
The script generates a fresh ssh_host_ed25519_key in /mnt/persist/etc/ssh/, computes its age public key, prints it, then pauses. You then need to:
- Add the new
age1...entry under the&hostsanchor of.sops.yaml. - Add
*<host>tocreation_rules[0].key_groups[0].age. - Run
sops updatekeys secrets/default.yaml— re-encrypts every secret so the new host is in the recipient list. - Commit + push.
- Pull the change into the flake checkout on the installer.
- Press ENTER in the script.
Two ways to do steps 1-5:
- Option A1 — on another machine. Switch to your daily driver (which already has the master age key in ~~/.config/sops/age/keys.txt~), do the edits there, push to your remote. Back on the installer:
git pull. No master key on USB needed. - Option A2 — on the installer itself. Drop the master age key into place so sops on the installer can decrypt:
mkdir -p ~/.config/sops/age cp /mnt/usb/master-age-key.txt ~/.config/sops/age/keys.txt chmod 0600 ~/.config/sops/age/keys.txt # in another shell, while the install script is paused: cd /path/to/dotfiles $EDITOR .sops.yaml # add &<host> + *<host> nix shell nixpkgs#sops -c sops updatekeys secrets/default.yaml git commit -am "sops: add <host>" git push
Then back in the install script’s shell,
git pulland press ENTER.
After ENTER, the script proceeds with nixos-install. On first boot sops-nix has two identities available — the host’s age key derived from /etc/ssh/ssh_host_ed25519_key (bind-mounted from /persist/etc/ssh/) and the seeded master key at /etc/sops/age/keys.txt (bind-mounted from /persist/etc/sops/). Either one decrypts every secret, so secrets install on the first try; the master key even covers the window before the new host is added to .sops.yaml.
The system
sops.age.keyFilepoints at/etc/sops/age/keys.txtrather than a home path on purpose:sops-install-secretsruns at early activation, before per-user/home bind-mounts exist./etc/sopsis system-persisted (mounted early like/etc/ssh), so the key is readable in time. A configured-but-missingkeyFileis fatal, which is why the installer seeds it.
Use this to keep the host’s existing age identity, so you don’t have to re-encrypt anything. Before wiping the old install, back up the SSH host key:
# On the still-running system:
sudo cp /etc/ssh/ssh_host_ed25519_key{,.pub} /tmp/keybackup/
# move /tmp/keybackup onto the secrets USB:
# /mnt/usb/hosts/<host>/ssh_host_ed25519_key{,.pub}During the install, when the script prompts, point it at the saved private-key path on the USB:
Path to backed-up ssh_host_ed25519_key (private): /mnt/usb/hosts/terangreal/ssh_host_ed25519_key
The script copies both halves into /mnt/persist/etc/ssh/. sops-nix derives the same age identity as before, secrets decrypt unchanged, no .sops.yaml edit needed. Total install time: just the disko + nixos-install steps.
If sops is broken in some non-obvious way and you just need the install to finish, set in the host’s _machine.nix:
service.sops.enable = false;Then pick 3) SKIP bootstrap in the script. Re-enable sops once the system is up and you’ve sorted out whatever was broken.
The moment a fresh install of a host is up and running, copy its SSH host keys off:
sudo tar -czf /tmp/<host>-ssh-host-keys.tgz -C /persist/etc/ssh ssh_host_ed25519_key ssh_host_ed25519_key.pubStash that tarball on the secrets USB / in your password manager / on an encrypted backup. Path B (reinstall) is then a five-minute operation; without that backup, every reinstall forces you down Path A and you have to sops updatekeys + push + pull every time.
If for some reason the script isn’t usable, here’s exactly what it does so you can do it by hand:
# 1. partition + mount with disko
nix run github:nix-community/disko -- --mode zap_create_mount \
./modules/hosts/<host>/_disks.nix
# 2. seed the host key (Path A or Path B; the script just automates this)
mkdir -p /mnt/persist/etc/ssh
# Path A: generate
ssh-keygen -t ed25519 -N "" -C "root@<host>" \
-f /mnt/persist/etc/ssh/ssh_host_ed25519_key
nix shell nixpkgs#ssh-to-age -c \
ssh-to-age < /mnt/persist/etc/ssh/ssh_host_ed25519_key.pub
# → add resulting age1... to .sops.yaml; sops updatekeys; push; pull
# Path B: restore
cp /mnt/usb/hosts/<host>/ssh_host_ed25519_key /mnt/persist/etc/ssh/
cp /mnt/usb/hosts/<host>/ssh_host_ed25519_key.pub /mnt/persist/etc/ssh/
chmod 0600 /mnt/persist/etc/ssh/ssh_host_ed25519_key
chmod 0644 /mnt/persist/etc/ssh/ssh_host_ed25519_key.pub
# 3. seed the master age key as the host's secondary identity
# (source = the installer's loaded key, $SOPS_AGE_KEY_FILE / the SOPSKEY USB)
install -d -m 0700 /mnt/persist/etc/sops/age
install -m 0600 "$SOPS_AGE_KEY_FILE" /mnt/persist/etc/sops/age/keys.txt
# 4. install
nixos-install --flake .#<host>/etc/machine-id is persisted via modules/services/impermanence.nix: it is a symlink to /persist/etc/machine-id, and an activation script seeds a fresh id there if one is missing, so fresh installs self-heal.
Because that seed generates a new random id when /persist/etc/machine-id is absent, a host that has been running without a persisted machine-id must have its current id copied into /persist before the first rebuild that enables this — otherwise the host’s machine-id changes. Run once per such host, before nh os switch:
sudo rm -rf /persist/etc/machine-id # drop any stray dir impermanence created
cat /etc/machine-id | sudo tee /persist/etc/machine-idConfirm it is a regular file with the expected id, then rebuild:
cat /persist/etc/machine-id
nh os switchSecure Boot is opt-in per host and off by default. It is implemented with lanzaboote, which replaces systemd-boot with a signed bootloader and packs each generation’s kernel + initrd into a signed Unified Kernel Image — so the initramfs is signed too, not just the kernel. That is the piece LUKS can’t provide: it protects the (unencrypted) boot chain against tampering, e.g. an evil-maid swap of the initrd that sniffs the LUKS passphrase. It complements disk encryption and impermanence rather than replacing them — LUKS protects data at rest, Secure Boot protects boot-chain integrity, and impermanence is not a security boundary.
In the host’s _machine.nix:
hardware.secureboot.enable = true;That option (in modules/hardware/secureboot.nix) forces systemd-boot off, enables boot.lanzaboote with pkiBundle = "/var/lib/sbctl", ships sbctl, and — importantly for impermanence — persists /var/lib/sbctl via environment.persistence. That directory holds the private keys that sign every boot file; the signed outputs land on the (never-wiped) ESP, but the keys live on the impermanent root, so without persisting them a later nixos-rebuild could no longer re-sign.
The keys are a per-host secret, like the SSH host key — but they are generated fresh on each install rather than carried on the SOPSKEY USB. Enrolling keys into firmware needs physical Setup-Mode access anyway, so reusing the same key across reinstalls buys little, and it keeps the boot-signing private key off the (plaintext vfat) stick.
scripts/install.sh does this automatically: when the target host has boot.lanzaboote.enable = true it runs sbctl create-keys and seeds the bundle into two places —
| Path | Why |
|---|---|
/mnt/persist/var/lib/sbctl | the persisted copy, bind-mounted on the running system so nixos-rebuild can keep re-signing across wipes |
/mnt/var/lib/sbctl | the ephemeral root copy, so lanzaboote can sign during nixos-install (impermanence wipes this on first boot) |
Hosts without Secure Boot skip this step entirely.
After the first boot, enrollment is manual — it needs firmware access, so it can’t be automated from the USB:
# 1. confirm the boot files are signed
sudo sbctl verify
# 2. reboot into firmware setup, put Secure Boot into SETUP MODE (clear keys)
# 3. back in Linux, enroll your keys — keep Microsoft's too, so GPU option
# ROMs / firmware blobs aren't rejected
sudo sbctl enroll-keys --microsoft
# 4. reboot, enable Secure Boot in firmware
# 5. verify
bootctl status # → Secure Boot: enabled (user)A natural follow-on is TPM-sealed LUKS (
systemd-cryptenroll --tpm2-device=auto): the TPM only releases the decryption key when the measured boot chain matches, so tampering blocks auto-unlock. That is a separate change with its own reinstall considerations and is not wired up here.
rhuidean is the disposable VM host: a single /dev/vda disk, a virtual-1 monitor, and virtio in initrd. It is pre-wired to exercise the whole stack — LUKS + impermanence + sops + Secure Boot — so you can validate the real install/boot path under QEMU without touching hardware. The flow has two phases: install with the firmware in Setup Mode (Secure Boot can’t be enforced yet — the installer ISO isn’t signed by your keys), then enroll keys and boot the installed disk enforcing.
Because rhuidean is not a recipient in .sops.yaml, sops decrypts via the master age key seeded at install (the same master-key path used elsewhere) rather than a host identity.
# OVMFFull is the Secure-Boot-capable firmware; plain OVMF won't do.
nix shell nixpkgs#qemu nixpkgs#OVMFFull nixpkgs#mtools
cd ~/dotfilesISO=$(nix build --no-link --print-out-paths \
'.#nixosConfigurations.installer-rhuidean.config.system.build.isoImage')/iso/*.isoSame payload build-installer writes: the master age key + a host key (so install.sh runs unattended via Path B). A throwaway host key is fine — the master key is what decrypts.
ssh-keygen -t ed25519 -N "" -C "root@rhuidean" -f /tmp/rhuidean_host_key
truncate -s 64M sopskey.img
mkfs.vfat -n SOPSKEY sopskey.img
mcopy -i sopskey.img ~/.config/sops/age/keys.txt ::keys.txt
mmd -i sopskey.img ::hostkeys ::hostkeys/rhuidean
mcopy -i sopskey.img /tmp/rhuidean_host_key ::hostkeys/rhuidean/ssh_host_ed25519_key
mcopy -i sopskey.img /tmp/rhuidean_host_key.pub ::hostkeys/rhuidean/ssh_host_ed25519_key.pubqemu-img create -f qcow2 rhuidean.img 64G
OVMF=$(nix build --no-link --print-out-paths nixpkgs#OVMFFull.fd)
cp "$OVMF"/FV/OVMF_VARS.fd ./OVMF_VARS.rhuidean.fd # per-VM copy; stores enrolled keys
chmod +w ./OVMF_VARS.rhuidean.fdThe stock OVMF_VARS.fd is in Setup Mode (no Platform Key), so nothing is enforced until you enroll.
Target is virtio (/dev/vda, what disko wipes); the SOPSKEY is USB (found by label, not path); boot the CD first.
qemu-system-x86_64 \
-enable-kvm -machine q35,smm=on -cpu host -m 6144 -smp 4 \
-global driver=cfi.pflash01,property=secure,value=on \
-drive if=pflash,format=raw,unit=0,readonly=on,file="$OVMF/FV/OVMF_CODE.fd" \
-drive if=pflash,format=raw,unit=1,file=./OVMF_VARS.rhuidean.fd \
-drive if=virtio,file=./rhuidean.img \
-device ahci,id=ahci \
-drive if=none,id=cd,media=cdrom,file="$ISO" \
-device ide-cd,drive=cd,bus=ahci.0,bootindex=0 \
-device qemu-xhci,id=xhci \
-drive if=none,id=sopskey,format=raw,file=./sopskey.img \
-device usb-storage,bus=xhci.0,drive=sopskey \
-device virtio-vga-gl -display gtk,gl=on -device usb-tabletIn the guest run install-system. It formats /dev/vda, restores the host key + master key from SOPSKEY, generates & seeds the Secure Boot keys (sbctl create-keys), and nixos-install signs the UKIs. Then poweroff.
Same vars file (so enrollment persists); no CD, no SOPSKEY:
qemu-system-x86_64 \
-enable-kvm -machine q35,smm=on -cpu host -m 6144 -smp 4 \
-global driver=cfi.pflash01,property=secure,value=on \
-drive if=pflash,format=raw,unit=0,readonly=on,file="$OVMF/FV/OVMF_CODE.fd" \
-drive if=pflash,format=raw,unit=1,file=./OVMF_VARS.rhuidean.fd \
-drive if=virtio,file=./rhuidean.img \
-device virtio-vga-gl -display gtk,gl=on -device usb-tabletIt boots unenforced, wipes root (impermanence), mounts /persist, and sops activates. Then:
sudo sbctl status # Setup Mode: Enabled, Secure Boot: Disabled
sudo sbctl verify # nixos-generation-*.efi should be ✓ signed
sudo sbctl enroll-keys --microsoft # enroll PK/KEK/db -> leaves Setup Mode
sudo rebootAfter reboot the firmware enforces; verify:
bootctl status # → Secure Boot: enabled (user)
sudo SOPS_AGE_KEY_FILE=/etc/sops/age/keys.txt \
nix shell nixpkgs#sops -c sops -d secrets/default.yaml | head # sops decryptsHome-manager user secrets additionally need a user age key at ~~/.config/sops/age/keys.txt~ (normally persisted), absent on a fresh VM, so the user sops service warns — the system still boots. To exercise them, copy the master key there (it decrypts everything) and re-activate:
install -d -m700 ~/.config/sops/age && sudo install -m600 -o $USER /etc/sops/age/keys.txt ~/.config/sops/age/keys.txt.Cleanup:
rm -f rhuidean.img sopskey.img OVMF_VARS.rhuidean.fd /tmp/rhuidean_host_key*.
For a quick desktop/app smoke test that does not need Secure Boot, LUKS, impermanence or the installer,
nixos-rebuild build-vm --flake .#rhuidean && ./result/bin/run-rhuidean-vmis far faster — but it bypasses the boot chain, so use the flow above to validate Secure Boot / sops.
After nixos-rebuild build --flake . (which produces a ./result symlink without activating):
# system-wide binaries
ls result/sw/bin/ | grep -iE 'hyprland|emacs'
# the user's home-manager profile
ls result/etc/profiles/per-user/merrinx/bin/ | head
# evaluate options without activating
nix eval .#nixosConfigurations.aanallein.config.programs.hyprland.enable
nix eval --raw .#nixosConfigurations.aanallein.config.home-manager.users.merrinx.programs.emacs.finalPackage
# diff against the currently running system
# (nh os switch / boot / test does this automatically before activation)
nix run nixpkgs#nvd -- diff /run/current-system ./resultnix develop # devShell with nixfmt + pre-commit hooks installed
nix fmt # format all .nix files
nix flake check # run pre-commit (statix, deadnix, nil, nixfmt, shellcheck, beautysh)
nix flake show # list every output (hosts, packages, devShells, formatter, checks)Built per-system via nix build .#<name>:
| Package | What |
|---|---|
repl | nix repl wrapper preloading the flake & nixpkgs |
raiderio-client | RaiderIO desktop client (AppImage wrapped) |
warcraftlogs | Archon Lite / Warcraft Logs uploader (AppImage wrapped) |