Adding a New Host#
This guide walks through adding a new NixOS host from scratch — from creating the config files to a working, post-install system with secrets access.
Adding a Mac?
Steps 1–5 apply to macOS too, but the install/first-boot steps (6–9) are NixOS-only
(disko, nixos-install, nixos-anywhere). For a nix-darwin host — and for how to run
Nix on a Jamf-managed machine without fighting the MDM — see
Managing a Mac with Nix (and Jamf).
Prerequisites#
Before starting, make sure you have:
- The devshell active (
nix developor direnv): gives yousops,gum,just, etc. - Your personal age key at
~/.config/sops/age/keys.txt(runjust sops-initif missing) - Your SSH public key committed under
modules/users/<you>/keys/ - Write access to
secrets.yaml(verify withjust preflight)
Quick environment check:
just preflight
Step 1 — Scaffold the host directory#
Use the scaffold recipe to create the host directory and see the hosts/default.nix
snippet you’ll need to add:
just new-host <name> # x86_64 NixOS (default)
just new-host <name> aarch64 nixos # aarch64 NixOS (e.g. RPi)
just new-host <name> aarch64 darwin # Apple Silicon Mac
This creates hosts/<arch>-<class>/<name>/default.nix with a minimal template and
prints the entry to paste into hosts/default.nix.
If you prefer to do it manually, create the directory under hosts/<arch>-<class>/:
hosts/
├── x86_64-nixos/<name>/default.nix # x86_64 NixOS (also the class-normalised rpi/wsl hosts)
├── aarch64-nixos/<name>/default.nix # aarch64 NixOS (haxo)
├── aarch64-darwin/<name>/default.nix # Apple Silicon Macs
└── x86_64-darwin/<name>.nix # Catalina Intel Macs (flat files, paused)
hosts/default.nix is the source of truth for a host’s arch and class, not the
directory name — octopi is aarch64 but lives under hosts/x86_64-nixos/ for
historical reasons.
Step 2 — Write the host config#
Edit hosts/<arch>-<class>/<name>/default.nix. A minimal NixOS host looks like:
{ inputs, lib, config, pkgs, ... }: {
imports = [
# Hardware quirks (optional — pick from nixos-hardware if applicable)
# inputs.hardware.nixosModules.common-cpu-intel
# Disk layout (required — define a disko config or import one)
# ./disko.nix
# Users on this host
../../../modules/users/tsunami
];
networking.hostName = "<name>";
# Disk / filesystem config here (see hosts/x86_64-nixos/mokou/ for a full example)
tsunaminoai = {
tailscale.enable = true;
# Uncomment to enable Borg backups (see Step 5):
# borg = { enable = true; repo = "<borgwarehouse-repo-id>"; };
# Builder/cache enablement is driven by the host's tags in hosts/default.nix
# (see Step 3), not set here.
};
system.stateVersion = "26.05";
}
This matches what
just new-hostscaffolds. The generated file only setstailscale.enable = trueand leavesborgcommented out.
See hosts/x86_64-nixos/mokou/default.nix for a full desktop example, or
hosts/x86_64-nixos/ereshkigal/ for a server example.
Step 3 — Register in hosts/default.nix#
Add an entry inside the hosts = { ... } attrset in hosts/default.nix.
The just new-host command prints the snippet for you; paste it in alphabetical order
(the file uses # keep-sorted markers):
<name> = {
path = ./<arch>-<class>/<name>;
arch = "<arch>"; # x86_64 (default) or aarch64
class = "<class>"; # nixos (default), darwin, rpi, wsl, catalina
system = "<arch>-linux"; # use <arch>-darwin for darwin/catalina hosts
tags = [ ]; # see Tags section below
};
The class selects which module wiring a host gets. nixos and darwin are the
base classes; rpi, wsl, and catalina are extra classes mapped back to a base
class by additionalClasses in hosts/default.nix (rpi → nixos, wsl →
nixos, catalina → darwin). Setting class = "rpi" is what selects the
Raspberry Pi module wiring — it is a class value, not a tag.
rpi/wsl hosts miss modules/flake/nix/nixos.nix (latent bug)
hosts/default.nix normalises the class correctly, so rpi hosts do get
modules/nixos, disko, sops, and stylix. But modules/flake/nix/default.nix
has its own normaliseClass that reads self.easy-hosts.additionalClasses,
which does not exist (easy-hosts is flake-parts config, not a flake output),
so there rpi never maps to nixos and the host gets none of
modules/flake/nix/nixos.nix: no experimental-features, substituters,
nh, or distributed-build wiring. octopi sets
nix.settings.experimental-features itself as a workaround; copy that for
any new rpi host. Fixing the lookup is tracked in
In-flight work because it changes the rpi closure.
Available tags:
Only builder and cache are functional tags — they are consumed by perTag in
modules/flake/nix/default.nix. All other tags are purely descriptive labels with
no automatic behaviour; the features they describe are enabled by setting the
relevant tsunaminoai.* options and module imports in the host’s own default.nix.
| Tag | Effect |
|---|---|
builder |
Sets tsunaminoai.nix.builds.enable = true (registers host as a Nix remote builder) |
cache |
Sets tsunaminoai.nix.isCache = true (hosts a binary cache other hosts use) |
desktop |
Descriptive label only — enable KDE/Wayland/Pipewire via tsunaminoai.* options yourself |
server |
Descriptive label only — no automatic behaviour |
laptop |
Descriptive label only — no automatic behaviour |
media-acquisition |
Descriptive label only — the *arr stack is enabled via tsunaminoai.servarr.* options (Tdarr is currently force-disabled, lib.mkIf false, because it hangs on reboot) |
The builder / cache tags act through two paths: perTag in
modules/flake/nix/default.nix flips the per-host tsunaminoai.nix options above,
while hosts/default.nix builds discoveredBuilderHosts / discoveredCacheHosts
(lists of host names) as special args so other hosts can wire themselves into the
build and caching mesh.
Step 4 — Verify the flake evaluates#
nix flake show 2>&1 | grep <name>
Fix any evaluation errors before proceeding.
Step 5 — Generate and register a Borg backup key (if using Borg)#
If the host will use Borg backups (tsunaminoai.borg.enable = true):
just borg-generate-secrets <name>
This generates an ed25519 keypair and passphrase, writes them to secrets.yaml, and
prints the public key. Register the public key in your BorgWarehouse instance to get
the repo ID, then put that ID in the host config (tsunaminoai.borg.repo).
Step 6 — First boot: install the system#
Option A — Interactive installer ISO (local machine)#
- Build and write the installer ISO:
nix build .#nixosConfigurations.bootable-iso.config.system.build.isoImage
# Flash result/iso/*.iso to a USB drive
- Boot the target machine from the USB. The flake is included in the ISO at
/etc/nixos-flake. Run the interactive installer:
install.sh
It will prompt for host selection, confirm disk destruction, run disko, and call
nixos-install.
Option B — nixos-anywhere (remote, over SSH)#
From a machine in the devshell:
nix run github:nix-community/nixos-anywhere -- \
--flake .#<name> \
root@<target-ip>
Requires the target to have an SSH server running (minimal NixOS live ISO or existing Linux install with root SSH access).
Step 7 — Register the host’s SOPS age key#
After the first boot, the host has an SSH host key at /etc/ssh/ssh_host_ed25519_key.
sops-nix derives the host’s age key from this.
On the new host, convert the host key to age format:
nix-shell -p ssh-to-age --run \
'cat /etc/ssh/ssh_host_ed25519_key.pub | ssh-to-age'
# → age1...
Back in the flake repo, add the age key to .sops.yaml:
# In the hosts: section:
- &<name> age1<output-from-above>
# In the creation_rules → secrets.yaml key_groups → age: section:
- *<name>
Then update the encrypted file so it’s re-keyed for the new host:
sops updatekeys secrets.yaml
git add .sops.yaml secrets.yaml
git commit -m "feat: add sops age key for <name>"
Save the host key in the repo for deploy-rs:
just get-host-key <name>
git add modules/nixos/security/pubkeys/<name>.pub
git commit -m "chore: save host key for <name>"
Step 8 — Second nixos-install (with secrets)#
Push the commits from Step 7, then on the target machine re-run the install so sops-nix can decrypt secrets during activation:
nixos-install --flake /etc/nixos-flake#<name>
reboot
Or deploy remotely after the system is up:
nix run .#deploy -- .#<name>
Step 9 — Post-install checklist#
- Confirm Tailscale joined the mesh:
tailscale status. Withtsunaminoai.tailscale.enable = true(set by the scaffold template) thetailscale-autoconnectservice runstailscale up --auth-key=...automatically on first boot from thetailscale/auth-keysops secret; only runtailscale up --auth-key <key>manually as a fallback if that secret is missing. Auth keys expire — a stale one fails withinvalid key: API key does not existand the node sits “Logged out”; mint a fresh reusable, tagged key in the admin console andsops setit. - Sign the node under Tailnet Lock. The tailnet has Tailnet Lock
enabled: the new node shows Online in the admin console and
tailscale statuslooks healthy, but it has zero peer connectivity (tailscale ping <peer>times out;journalctl -u tailscaled | grep tailnet-locksays “locked out”) until a trusted node signs it. On the new node runtailscale lock status— it prints the exact command — then on mokou:sudo tailscale lock sign <nodekey> <tlpub>. Same rule for the netns node on ereshkigal (netns exit). - Verify sops secrets decrypted:
ls /run/secrets/ - Verify Borg backup connectivity:
borgmatic check - Verify deploy-rs:
nix run .#deploy -- .#<name> --dry-activate - Enable
tsunaminoai.telemetry.agentif the host should report to the telemetry stack (Grafana Telemetry Overview + alerting; see alerts)
Adding a collaborator user#
To give a new person access to the flake:
- Create
modules/users/<username>/(copymodules/users/bcraton/as a minimal template) - Add their SSH public key to
modules/users/<username>/keys/ - Generate their personal age key on their machine (
just sops-init) and add it to.sops.yamlunder theusers:section and to thesecrets.yamlcreation rules - Run
sops updatekeys secrets.yamland push - Import
../../../modules/users/<username>in any hostdefault.nixwhere they need access