Skip to content
Gako358Public

About

dotfiles and nixos system configuration

Resources

Stars

76 stars

Watchers

2 watching

Forks

Latest commit

 

History

1,245 Commits

Folders and files

Repository files navigation

https://img.shields.io/badge/NixOS-unstable-blue.svg?style=flat-square&logo=NixOS&logoColor=white

Disclaimer

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.

My NixOS Configuration

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.

Hosts

HostRole
aanalleinHP server
rhuideanVirtual machine — used for testing
tanchicoGaming PC — 32 GB RAM, Ryzen 7 5800X3D
terangrealDesktop PC — 48 GB RAM, Ryzen 7 5600
tuathaanHP work laptop

Repository Structure

.
├── 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

How modules register themselves

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 NixOS
  • flake.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 ];
  };
}

How a host is built

# 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.

How to do common things

Add a program (home-manager only)

# modules/programs/htop.nix
_: {
  flake.homeModules.programs-htop = _: {
    programs.htop.enable = true;
  };
}

That’s it. Every host picks it up on the next rebuild.

Add a program with both NixOS and home-manager aspects

# 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 ];
  };
}

Add a service

Identical pattern, in modules/services/<name>.nix. Pick the right namespace depending on whether it’s a system service or a user service.

Add a new host

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>"

Disable a module on one host

Edit modules/hosts/<name>/_machine.nix:

{
  service.wireguard.enable = false;
  environment.gaming.enable = false;
}

Stop a file from being auto-imported

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.

How To Install

On an existing NixOS system

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 nixpkgs

If 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.)

Fresh install via a prebuilt installer USB (recommended)

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.

Identify and prepare the USB stick

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/sdX

Double-check the device path. dd to the wrong /dev/sdX will erase that disk irrecoverably.

Build and flash
# 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_key

If 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
sync

When a device is given it re-confirms the device, dd’s the ISO, then appends a small vfat SOPSKEY partition containing:

File on SOPSKEYPurpose
keys.txtthe 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:

  1. Boot the USB.
  2. Run install-system.
  3. 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 SOPSKEY partition 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.

After install: enable Secure Boot (only for Secure Boot hosts)

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):

  1. Reboot → firmware/BIOS → put Secure Boot into Setup Mode (erase the Platform Key).
  2. Boot the installed system, then enroll your keys (keep Microsoft’s too, for option ROMs):
    sudo sbctl enroll-keys --microsoft
        
  3. 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.

Fresh install via the NixOS installer

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).

What to bring on the USB drive

Two USB drives is the simplest setup; you can also use one drive with two partitions:

  1. Boot USB — the NixOS installer ISO, written with dd or Rufus.
  2. Secrets USB — a small (FAT32 or ext4) USB containing one or more of:
    FileWhen you need it
    master-age-key.txtRecommended on every install — seeds the host’s /etc/sops identity; also lets you run sops updatekeys on the installer (Path A). The script auto-detects it at /mnt/usb/master-age-key.txt, /mnt/usb/keys.txt, or $SOPS_AGE_KEY_FILE
    hosts/<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.txt is one line in the form AGE-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.

  3. (Optional) Offline flake copy — git clone of 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.
Boot, mount the secrets USB, run the script
# 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.sh

The script:

  1. Asks which host you’re installing.
  2. Runs disko against modules/hosts/<host>/_disks.nix to format & mount.
  3. Bootstraps the sops host key into /mnt/persist/etc/ssh/ — branches into one of three paths described below.
  4. 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.
  5. 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.
  6. Runs nixos-install --flake .#<host>.
Path A — brand-new host (not yet in .sops.yaml)

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:

  1. Add the new age1... entry under the &hosts anchor of .sops.yaml.
  2. Add *<host> to creation_rules[0].key_groups[0].age.
  3. Run sops updatekeys secrets/default.yaml — re-encrypts every secret so the new host is in the recipient list.
  4. Commit + push.
  5. Pull the change into the flake checkout on the installer.
  6. 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 pull and 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.keyFile points at /etc/sops/age/keys.txt rather than a home path on purpose: sops-install-secrets runs at early activation, before per-user/home bind-mounts exist. /etc/sops is system-persisted (mounted early like /etc/ssh), so the key is readable in time. A configured-but-missing keyFile is fatal, which is why the installer seeds it.

Path B — reinstall of an existing host (already in .sops.yaml)

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.

Path C — escape hatch (sops disabled)

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.

Recommended habit: back up host keys after every successful install

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.pub

Stash 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.

Manual install (no helper script)

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>

Persisted state: machine-id

/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-id

Confirm it is a regular file with the expected id, then rebuild:

cat /persist/etc/machine-id
nh os switch

Secure Boot (lanzaboote)

Secure 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.

Enabling it on a host

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.

Signing keys at install time

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 —

PathWhy
/mnt/persist/var/lib/sbctlthe persisted copy, bind-mounted on the running system so nixos-rebuild can keep re-signing across wipes
/mnt/var/lib/sbctlthe ephemeral root copy, so lanzaboote can sign during nixos-install (impermanence wipes this on first boot)

Hosts without Secure Boot skip this step entirely.

One-time firmware enrollment

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.

Testing a host in QEMU (rhuidean)

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.

1. Tooling (no system change)
# OVMFFull is the Secure-Boot-capable firmware; plain OVMF won't do.
nix shell nixpkgs#qemu nixpkgs#OVMFFull nixpkgs#mtools
cd ~/dotfiles
2. Build the installer ISO
ISO=$(nix build --no-link --print-out-paths \
  '.#nixosConfigurations.installer-rhuidean.config.system.build.isoImage')/iso/*.iso
3. Build the virtual SOPSKEY “USB”

Same 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.pub
4. Target disk + writable firmware vars
qemu-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.fd

The stock OVMF_VARS.fd is in Setup Mode (no Platform Key), so nothing is enforced until you enroll.

5. Phase 1 — install (Secure Boot present, not enforcing)

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-tablet

In 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.

6. Phase 2 — boot installed disk, enroll, enforce

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-tablet

It 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 reboot

After 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 decrypts

Home-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-vm is far faster — but it bypasses the boot chain, so use the flow above to validate Secure Boot / sops.

Inspecting a build

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 ./result

Development

nix 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)

Custom packages

Built per-system via nix build .#<name>:

PackageWhat
replnix repl wrapper preloading the flake & nixpkgs
raiderio-clientRaiderIO desktop client (AppImage wrapped)
warcraftlogsArchon Lite / Warcraft Logs uploader (AppImage wrapped)

About

dotfiles and nixos system configuration

Resources

Stars

76 stars

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages