Canonical, agent-agnostic procedure for bringing a new box into the flake, from a booted installer through to a deployable system, plus a thin Claude Code skill pointing at it -- same split as the nixpkgs upgrade procedure. Records the conventions that were not written down anywhere: sgdisk plus an LVM PV for the nix and persist volumes, adopting the installer's SSH host keys so secrets can be encrypted before first boot, and taking whatever show-hw-config emits that the flake's own modules do not already set. Also notes in AGENTS.md that a changed recipient list should be re-encrypted per file with ragenix --rekey-one; --rekey rewrites every secret in secrets/ and buries the actual change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 KiB
Installing a box
Procedure for bringing a new NixOS box into this flake, from bare hardware booted into the custom
installer through to a deployable system. Written to be followed by a person or any coding agent; a
Claude Code entry point exists at .claude/skills/install-box/ but the steps below are the
canonical source.
The install is guided, not automated: the mechanical steps (probing hardware, partitioning,
writing the config, evaluating it) can be done straight through, but stop at the judgment points
(marked ⏸) — what the box actually is, wiping disks, and running do-install itself. Keep a running
summary and present it before any destructive step.
For the installer image itself — what it contains, how it is built and released — see
misc/installer.md.
Setup facts
- Installer access: the box boots the custom installer (ISO, kexec or netboot) and is reached
over SSH as
rootwith.keys/deploy.key. The devshell setsINSTALLER_SSH_OPTS = "-i .keys/deploy.key"; setINSTALLERto the address, andINSTALLER_SSH_PORTif it is not 22. - Devshell commands (from
devshell/install.nix):installer-shell [cmd]anddo-install [--no-bootloader] [--no-substitute] <system>. INSTALL_ROOTis/mntin the installer's environment — everything is mounted under it anddo-installreads it from the installer rather than assuming.show-hw-configis a shell alias in the installer (wrappingnixos-generate-config --show-hardware-config --root $INSTALL_ROOT), so it needs an interactive shell:installer-shell bash -lic show-hw-config. A plaininstaller-shell show-hw-configwill not find it.- Validation:
check-system <host>evaluates a system without building it — use it while iterating. Onlybuild-systemwhen you need the artifact. - Nix reads the flake through git, so
git addnew files before evaluating — an untracked box directory fails with "Path … is not tracked by Git", not a Nix error.
Phase 1 — Establish what the box is
⏸ Settle these before writing anything; they decide where every file goes and they are not recoverable from the hardware:
- Name and site — the box name doubles as the
nixos.systems.<name>attribute, the deploy node name and the docs page name. The site decides the directory (nixos/boxes/<site>/), the constants block inlib/constants.nixit draws prefixes from, and the docs directory (docs/sites/<site>/,docs/remote/,docs/mobile/). - Role — what it does, which decides its modules, networking and firewall config.
- nixpkgs channel —
unstable/stable/mine/mine-stable; match the site's other boxes unless there is a reason not to. - Networking — whether it gets
assignmentsnow, or bootstraps on DHCP because it is being staged somewhere other than its final home. A box with no assignment still needs a reachablemy.deploy.node.hostname, since the default (config.networking.fqdn) will not resolve.
Phase 2 — Reach the installer and inventory the hardware
With the box booted into the installer and INSTALLER set:
- Confirm you are talking to the right thing —
installer-shell hostnamereportsinstaller, and/etc/os-releasecarriesVARIANT_ID=installer. - Collect the inventory you will need for both the config and the docs page:
lscpu,free -h,lsblk -o NAME,SIZE,TYPE,FSTYPE,MODEL,SERIAL,ip -br link,ip -br addr,lspci -nn | grep -Ei 'ethernet|network|nvme|sata|raid', and whether/sys/firmware/efiexists. - Record every NIC's permanent MAC against its PCI address — interface naming in Phase 5 pins names to MACs, and the PCI order tells you which physical port is which.
- Run
installer-shell bash -lic show-hw-confignow for the kernel-module lists. Filesystems are not mounted yet, so run it again in Phase 4 for those.
Phase 3 — Partition, format and mount
⏸ Destructive. Check the target disks are the ones you think they are and that nothing on them is wanted, then show the exact command sequence and get confirmation before running it.
The house layout is a tmpfs root (my.tmproot) with three mounts: an ESP at /boot, /nix, and
/persist (neededForBoot = true). Use sgdisk for partitioning and put /nix and /persist
on LVM so they can be resized later:
sgdisk -Z /dev/<disk>
sgdisk \
-n 1:0:+2G -t 1:ef00 -c 1:esp \
-n 2:0:0 -t 2:8e00 -c 2:lvm \
/dev/<disk>
partprobe /dev/<disk>
pvcreate /dev/<disk>p2
vgcreate main /dev/<disk>p2
lvcreate -L 48G -n <host>-nix main
lvcreate -l 100%FREE -n <host>-persist main
mkfs.vfat -n ESP /dev/<disk>p1
mkfs.ext4 -L nix /dev/main/<host>-nix
mkfs.ext4 -L persist /dev/main/<host>-persist
Conventions worth keeping: volume group main, logical volumes <host>-nix / <host>-persist,
and ext4 filesystem labels nix and persist. Size the ESP and /nix to the box — 2 GiB and
48 GiB suit a small single-disk box.
Then mount everything under $INSTALL_ROOT, with a tmpfs standing in for the eventual tmpfs root:
mount -t tmpfs -o size=2G tmpfs "$INSTALL_ROOT"
mkdir -p "$INSTALL_ROOT"/{nix,persist,boot}
mount /dev/main/<host>-nix "$INSTALL_ROOT/nix"
mount /dev/main/<host>-persist "$INSTALL_ROOT/persist"
mount /dev/<disk>p1 "$INSTALL_ROOT/boot"
Seed the SSH host key
The installer generates fresh host keys on every boot, so adopt them as the box's own rather than letting it generate another set on first boot. Copy them onto the persist volume now:
install -d -m 0755 "$INSTALL_ROOT/persist/etc/ssh"
for t in ed25519 rsa; do
install -m 0600 "/etc/ssh/ssh_host_${t}_key" "$INSTALL_ROOT/persist/etc/ssh/ssh_host_${t}_key"
install -m 0644 "/etc/ssh/ssh_host_${t}_key.pub" "$INSTALL_ROOT/persist/etc/ssh/ssh_host_${t}_key.pub"
done
my.tmproot persists services.openssh.hostKeys at exactly those paths, so the installed system
picks them up. This means the box's key is known before it first boots, so my.secrets.key can
be set and its secrets encrypted as part of the same pass — no install, boot, re-encrypt, re-deploy
round trip. (For a box already up, the ssh-get-ed25519 <host> devshell command prints the same
value in the form my.secrets.key wants.)
Phase 4 — Capture the hardware config
Re-run installer-shell bash -lic show-hw-config with the filesystems mounted.
Read the whole generated file and carry over anything in it that the flake does not already provide — it reflects what was actually detected on this hardware, and the list below is just what usually shows up, not a limit:
boot.initrd.availableKernelModulesandboot.initrd.kernelModules(LVM addsdm-snapshot)boot.kernelModules(kvm-intel/kvm-amd) and the microcode attribute- the ESP's
by-uuiddevice, and the device paths for/nixand/persist - anything else it emits —
boot.extraModulePackages,hardware.*attributes,swapDevices, additional detected filesystems,importssuch asnot-detected.nix
The test is conflict, not familiarity: drop an option only when a nixfiles module already sets it,
and keep it otherwise. The flake's own modules cover the bootloader, initrd.systemd,
initrd.services.lvm, the kernel package and nixpkgs.hostPlatform (see
nixos/modules/common.nix and
nixos/default.nix), so those are the ones to leave out. Don't paste the
file in wholesale either — translate it into the box's own style, and reference LVM volumes as
/dev/main/<host>-nix rather than the generated /dev/mapper/main-<host>--nix.
Phase 5 — Write the box config
Create nixos/boxes/<site>/<host>/default.nix (a directory, so per-topic files can be added
alongside it later) declaring nixos.systems.<host>, and add its path to the configs list in
flake.nix. Then git add it.
The minimum is system, nixpkgs, home-manager and a configuration with the hardware from
Phase 4, the three filesystems, and networking. Beyond that:
- Interface naming: pin names to hardware with
.linkfiles matchingPermanentMACAddress, named for speed and index —et1g0,et2g5-0,et10g-1. Never rely on predictable-interface names in the.networkfiles. - Servers set
my.server.enable = true. - Secrets: set
my.secrets.keyto the ed25519 public key seeded in Phase 3 (the key only, noroot@installercomment). Note that every box declares at least one secret even if its own config declares none:nixos/modules/user.nixaddsuser-passwd.txtwhenevermy.user.enableis on, which is the default. So settingmy.secrets.keyalways adds the box to that file's recipients, andragenix --rekey-one secrets/user-passwd.txt.ageis required — skip it and the box cannot decrypt its user password on first boot. Confirm what the box actually declares withnix eval .#nixosConfigurations.<host>.config.age.secrets --apply builtins.attrNames, and re-encrypt each of those files the same way. Create any new secrets withragenix -e <path>. Never use--rekey, which rewrites every secret insecrets/. - A box staged away from its final home gets a bootstrap
.networktaking DHCP, plussystemd.network.wait-online.anyInterface = trueso boot does not block on unpatched ports, and an explicitmy.deploy.node.hostname. Comment it as temporary and say what replaces it.
Validate with check-system <host> and fix eval errors before going near the target.
Phase 6 — Install
⏸ The maintainer may want to run this step themselves; ask rather than assume.
do-install <host> builds the system's toplevel, nix copys the closure into the installer's
$INSTALL_ROOT store, points /nix/var/nix/profiles/system at it, touches /etc/NIXOS, and runs
switch-to-configuration boot with NIXOS_INSTALL_BOOTLOADER=1. It prompts for confirmation and
prints the target it resolved.
--no-bootloaderskips the bootloader install (for a box that boots by other means).--no-substitutecopies everything from the local store instead of letting the target substitute.
Phase 7 — First boot and post-install
- Reboot the box off the installer and confirm it comes up: it should get its address, and
hostnameshould be the system name. Its SSH host key is the one seeded in Phase 3, so it presents the same fingerprint the installer did. - Secrets. If Phase 5 set
my.secrets.key, they already decrypt.secrets.nixcomputes the ragenix recipient list from that key at evaluation time, so nothing needs regenerating — but any secret added to the box later must be re-encrypted for the new recipient list withragenix --rekey-one <path>, one file at a time. Never reach forragenix --rekey: it rewrites every secret insecrets/and buries the actual change in churn. - Deploy.
deploy .#<host>should now work over thedeployuser. If the box is staged somewhere without its final DNS name,deploy --hostname <address> .#<host>overrides the node hostname for one run.
Phase 8 — Document it
Per AGENTS.md, a new box means:
- a box page under the right docs directory, following the standard layout (H1 + one-line intro;
Source/Host/nixpkgsbullets; hardware inventory;## Role;## Network assignmentslinking tonetworking.md#box-assignments, or a short explanation if it has none yet; one##per topic;## Notable config fileslast); - a row in the site index
README.mdboxes table; - affected prose in
networking.md— the assignment tables themselves are CI-generated, so write the prose and leave the tables alone; - the site diagram in
README.mdif the box changes its layout.