NOTE: Code moved to https://git.firecat53.me/firecat53/nixos. Issues and PRs still accepted here for now. Github repo maintained as a read-only mirror.
- Laptop
laptop - Homeserver
homeserver - Backup server
backup - VPS cloud server
vps - Office secondary/spare desktop
office - Examples:
- Flake install w/ home-manager and sops.
- Encrypted or unencrypted
base-btrfsorbase-zfs
- Encrypted or unencrypted
- Bare minimum flake install for testing.
base-minimal
- Flake install w/ home-manager and sops.
Sops-nix secrets live in a private repository nixos-secrets. On the desktops
(laptop and office, the only hosts that keep checkouts) the directory
structure is:
~/nixos
~/nixos/nixos/
~/nixos/nixos-secrets
~/nixos/nix-neovim
These are ordinary clones of the forgejo repos — clone them anywhere you want to work; the servers pull by URL and keep no checkout.
forgejo is the transport and main is the deploy branch: every host pulls it
by URL, and only the desktops (laptop, office) keep a checkout, for editing
and on-demand rebuilds. flake.lock is committed, so main records exactly
what the fleet runs.
- Lock bumps: the
flake-lock-updatetimer onhomeserver(hosts/homeserver/services/flake-lock-update.nix) runsnix flake update --commit-lock-fileat 04:00 daily in its own clone under/var/lib/flake-lock-update, then pushesmain. - All five hosts auto-upgrade at 04:40 from
git+https://git.firecat53.me/firecat53/nixos.git?ref=main. That repo is public, so the config side needs no credentials. - Secrets: the
my-secretsflake input points at the privatenixos-secretsrepo over ssh. Each host authenticates with its own/etc/ssh/ssh_host_ed25519_key, registered on that repo as a read-only deploy key — the same key sops-nix already uses as the host's age identity, so it grants nothing the host didn't already have. The ssh alias lives in/etc/ssh/ssh_config(hosts/modules/common/sshd.nix) rather than~/.ssh/config, becausenixos-upgradeevaluates the flake as root. A newly built host needs its deploy key added before its first build. - Deploy: merge to
mainand push. Hosts pick it up at 04:40, or use the immediate path below. - Rollback:
git revert+ push, ornixos-rebuild --rollbackon the affected host when you need it now.git log -p flake.lockonmainis the audit trail of what every host ran on a given day.
Note the whole path depends on git.firecat53.me, which resolves to the VPS
and proxies back to homeserver over wireguard — so a VPS outage stops
unattended upgrades fleet-wide, including on the host that actually serves
forgejo.
nixos-rebuild test/switch --flake .#<host> still builds the working tree on
demand, so day-to-day testing is unchanged. The catch: a machine left on a test
build silently reverts to main at the next 04:40 upgrade, so re-apply the
working tree after waking a machine you were mid-test on.
To push an urgent change to another host immediately:
nixos-rebuild switch --flake .#<host> --target-host <host> --build-host <host> --sudo--build-host runs nix flake archive --to ssh://<host> under the hood, which
ships the flake and its resolved inputs — so the target needs no checkout,
and no deploy-key access of its own, for this path. --sudo authenticates
through pam_rssh against the ssh agent, so it doesn't prompt. main stays the
unattended path: anything deployed this way is overwritten at 04:40 unless it
is also committed and pushed.
nixos-secrets must be pushed, not just committed, before
nix flake update my-secrets will see the change.
pkgs/ contains derivations for small one-off apps maintained alongside this
repo. Each lives in pkgs/<name>/ with its own default.nix, and is wired up
through pkgs/default.nix ({ pkgs }: { ... = pkgs.callPackage ./<name> {}; }).
Service modules consume them with
localPkgs = import ../../../pkgs { inherit pkgs; }.
today— minimal Flask webapp for quick diary, workout, and book entries into the wiki. Deployed onhomeserverviahosts/homeserver/services/today.nixattoday.lan.firecat53.net.dashboard- static home service bookmark page served athome.lan.firecat53.net
| name | served by | reachable from | what it's for |
|---|---|---|---|
*.lan.firecat53.net |
homeserver Traefik | LAN + wireguard only | the "home" name every homeserver web app gets (real LE certs, but the name resolves to a LAN address) |
*.firecat53.me |
VPS Traefik | internet | public front door: proxies over wireguard to the homeserver .lan name, or to a VPS-local port |
*.firecat53.com |
VPS Traefik | internet | VPS-native public apps (grafana, VPS nextcloud, syncthing, Authelia portal, apex website) |
firecat53.net + subs (matrix., s., nc., …) |
homeserver Traefik directly (443 port-forward) | internet | federated/public apps that must be reached directly (Matrix, Akkoma, Nextcloud) — hand-written, not in the registry |
Rules of thumb:
- At home or on wireguard → use the
.lanname. Direct to the homeserver, no VPS hop; basicAuth prompt if the service has one. - Out in the world → use the
.mename. The VPS terminates TLS, Authelia 2FA's you (ifauth = true), then proxies over wireguard to the same homeserver backend. .lanand.meare two doors to one app — the entry inhosts/modules/service-registry.nixis what ties them together.- Service on the VPS itself →
.meif it's registry-managed (behind Authelia),.comif it's hand-written and fully public. - Not web / not HTTP isn't covered by any of this: ssh, syncthing sync, and
wireguard are direct port openings on their hosts; Forgejo ssh
(
git.firecat53.me:2222) is a TCP passthrough on the VPS Traefik (proxy-me.nix) to the homeserver.
Standard web services on both hosts are driven by a single shared registry,
hosts/modules/service-registry.nix. Derived from it:
| consumer | generates |
|---|---|
homeserver services/lan-proxy.nix |
the .lan Traefik routers/services (+ -me/-noauth companions) |
vps services/proxy-me.nix |
the *.firecat53.me reverse-proxy routers/services |
vps services/authelia.nix |
2FA-protected domains (auth = true) + access_control rules |
vps services/gatus.nix |
homeserver .lan backend resolution for monitoring |
homeserver services/dashboard.nix |
build-time assertion that dashboard tiles point at real services |
Each entry's fields — the attr name is the *.firecat53.me subdomain, and
every flag controls exactly one generated thing (nothing is derived from
another flag):
| field | where | meaning |
|---|---|---|
lan |
homeserver sets | the .lan router host, and the backend the VPS proxies to over wireguard |
port |
all | backend port on localhost |
url |
all | backend URL; overrides port for non-localhost backends (e.g. hass VM) |
auth |
homeserver/vps | true gates <sub>.firecat53.me behind Authelia 2FA |
passHost |
homeserver | VPS forwards the real *.firecat53.me Host header (see below) |
meRouter |
homeserver | homeserver companion router on <sub>.firecat53.me (see below) |
basicAuth |
homeserver | homeserver router gets the auth basicAuth middleware |
vpsBypass |
homeserver | homeserver -noauth companion router skipping basicAuth for VPS traffic |
rules |
homeserver/vps | optional; per-service Authelia access_control rules (see below) |
Service running on homeserver:
- Create
hosts/homeserver/services/<name>.nixdefining just the app (bound to a localhost port), and add it tohosts/homeserver/services/default.nix. No Traefik config. - Add one entry to the
homeserverset in the registry (orlanOnlyfor services that should not be exposed at*.firecat53.me):<sub> = { lan = "<sub>.lan.firecat53.net"; port = <port>; auth = <true|false>; };
- Rebuild
homeserver, thenvps. The.lanrouter, the<sub>.firecat53.merouter, the Authelia rule (ifauth = true), and Gatus resolution appear automatically.
To inspect the fully-rendered result of the generated config:
nix eval --json .#nixosConfigurations.<host>.config.services.traefik.dynamicConfigOptions | jqServices with their own HTTP basic auth (basicAuth = true — e.g. gollum,
today, syncthing, transmission): the auth model is basicAuth on the LAN,
Authelia on the internet — not both. The VPS proxies *.firecat53.me traffic
in from 10.200.200.5, so set vpsBypass = true to generate a companion
router that matches that source IP and omits the auth middleware, letting
Authelia-2FA'd requests (and Gatus probes) through without a second prompt.
LAN/wireguard clients keep hitting the plain Host() router and still get
basicAuth. This relies on the homeserver Traefik not trusting forwarded
headers (so ClientIP is the real TCP source). Services without basicAuth
(app-level login, or auth = false) need no companion router.
Apps that build absolute redirects/URLs from the Host header (e.g. gollum,
sonarr, OAuth redirect flows): the ClientIP trick won't work, because the
backend would see the .lan host and redirect clients there (broken off-LAN).
Instead set passHost = true so the VPS forwards the real
<sub>.firecat53.me host, plus meRouter = true to generate the homeserver
companion router keyed on that host (no ClientIP needed — only the VPS ever
sends that host, already 2FA'd, so it carries no basicAuth).
Service running locally on the VPS:
- Create
hosts/vps/services/<name>.nix(+ add it toservices/default.nix), binding the app to a localhost port. - Add one entry to the
vpsset in the registry:<sub> = { port = <port>; auth = <true|false>; };
- Rebuild
vps.
Removing a service: delete its registry entry (and the service file + its
default.nix import). The .lan router, proxy router, auth rule, and monitor
resolution all disappear with it — and the dashboard assertion fails at build
time if a tile still points at it. Remove OIDC from authelia.nix if necessary.
Oddballs stay hand-written in their own service files (path-prefix rules,
extra middlewares, non-.lan hosts): matrix-synapse, akkoma, nextcloud, and
the nginx lan.firecat53.net apex. If a service outgrows the registry schema,
move it to a hand-written stanza rather than adding fields.
Access Control rules: auth = true entries get a blanket two_factor rule.
To override that for specific paths (e.g. keep some public), add a rules list
to the entry — Authelia access_control rules evaluated before the blanket
rule, first match wins. Omit each rule's domain; authelia.nix derives it
from the attr name. For example, Microbin uses this to keep paste viewing public
but require authentication for submitting/delting pastes.
OIDC (apps that log in through Authelia rather than forward-auth, e.g.
immich, audiobookshelf): the client is defined in
identity_providers.oidc.clients in authelia.nix (hashed client_secret +
redirect_uris), and the app stores the plaintext secret in its own sops
secret. This pair is cross-host (Authelia on the VPS, app on the homeserver), so
it is not registry-derived. See the secret-generation commands at the top of
authelia.nix.
To add a host:
- Add it to
flake.nix(mkSystem). - Set its wireguard address in the host's
configuration.nix(networks."wg0".address). - Add its wireguard/LAN IPs to
networking.hostsinhosts/modules/desktops/networking.nix. - Add sops keys (see the install/post-install sections below).
- Install per General Install Procedures.
- Generate hostId (for ZFS systems):
head -c4 /dev/urandom | od -A none -t x4 - Hetzner VMs apparently require grub instead of systemd-boot (as of 2025-08)
- Available options (defined in
hosts/modules/common/options.nix): a. isRemote (bool) - set for hosts directly exposed to the internet (not behind the LAN firewall). Controls the wireguard endpoint and keeps LAN-only ports (e.g. Eternal Terminal 2022) closed publicly. Default false. b. isVirtual (bool) - set for virtual hardware (VPS or VM). Disables hardware-specific services (fwupd, smartd). Default false. c. latestZFSKernel (bool) - set to use latest available ZFS compatible kernel. Default false. d. tmuxStatusColor (str) - tmux status bar background color, for per-host visual distinction. Default "#cba6f7" (catppuccin mocha mauve).
Installing using nixos-anywhere
- Create new (Ubuntu is fine) cloud server. Add one of the public keys. Adjust DNS 'A' records if needed.
- SSH into the new box and update the disk device name(s) and partition layout (if needed) in disko-config.nix.
nix run github:nix-community/nixos-anywhere -- --generate-hardware-config nixos-generate-config ./hosts/<host>/hardware-configuration.nix --flake .#<host> --target-host root@<ip or domain>- If problems arise, add
--no-rebootto the above command so you can troubleshoot the new install. - [[#post-install]]
- Boot installer.
- Mount flash drive DATA
- Install:
mkdir ./mnt
sudo mount /dev/disk/by-label/DATA /home/nixos/mnt
rsync -av mnt/nixos .
cat /home/nixos/mnt/dotfiles/ssh-scotty/.ssh/id_ed25519.pub | sudo tee /root/.ssh/authorized_keys
# OR sudo passwd rootLogin via ssh from another machine (e.g. ssh root@192.168.200.103)
nix-shell -p git
mount -o remount,size=8G /run/user/0 ## This is to prevent out of space error during build
# Update device(s) in ~/mnt/nixos/nixos/hosts/<host>/disko-config.nix
nix --experimental-features "nix-command flakes" run github:nix-community/disko -- --mode disko /home/nixos/mnt/nixos/nixos/hosts/<host>/disko-config.nix
nixos-generate-config --no-filesystems --show-hardware-config --root /mnt --dir /home/nixos/mnt/nixos/nixos/hosts/<host>/
nixos-install --flake /home/nixos/mnt/nixos/nixos#<host>
cp -a /home/nixos/mnt/nixos /mnt/home/firecat53/ && chown -R 20000:100 /home/mnt/firecat53/nixos
umount /mnt/boot
umount /mnt
zfs export rpool
systemctl reboot- [[#post-install]]
Each desktop/laptop host gets its own SSH keypair — private halves never leave
the box, only pubkeys land in hosts/modules/common/ssh-keys.nix. Sequence
matters because the host can't reach itself via key auth until its pubkey is
authorized elsewhere.
- On the new/rebuilt host, generate the device key as
firecat53:
ssh-keygen -t ed25519 -C "firecat53@<hostname>" -f ~/.ssh/id_ed25519
wl-copy < ~/.ssh/id_ed25519.pub- On a working host with repo access, paste the pubkey into
hosts/modules/common/ssh-keys.nixunder the matchingdevices.<host>attribute. Commit and push. - Add the device pubkey to:
a. GitHub / forgejo account (web UI) — needed for git operations
b. HomeAssistant
~/.ssh/authorized_keysfor therootuser c. Any other external service the host needs to reach - Rebuild every host that should authorize this device:
- (Desktops/laptops using the autossh tunnel only) The passphraseless
autossh private key is shared across all tunnel clients and lives in
sops as
autossh-key. Add it to the host's sops file (the matching pubkey is already inssh-keys.nixasautossh, authorized on homeserver). To rotate, generate one new keypair, updateautosshinssh-keys.nix, and re-encryptautossh-keyinto every desktop sops file.
- new host Change
firecat53user and root (only for local machine) passwords. - new host Generate SSH keys per SSH key generation above.
- existing host Sync ~/nixos/ directory to new machine (including nixos configs and secrets)
- existing host Update sops key after reinstall. Commit and sync then rebuild.
nix shell nixpkgs#ssh-to-age nixpkgs#sops
ssh-keyscan <hostname> | ssh-to-age
# Set `&<hostname> age.....` in nixos-secrets/.sops.yaml
sops updatekeys nixos-secrets/<hostname>/secrets.yml
sops updatekeys nixos-secrets/common/secrets.yml
git add .sops.yaml <homename>/ && git commit -m 'Update sops keys'- existing host After the sops key is updated, refresh the secrets input's lock entry and rebuild on the target machine (commit the lock change):
nix flake update my-secrets- new host
sudo nmcli connection import type wireguard file /etc/wireguard/wg0.conffor networkmanager. - Update syncthing device ID's if necessary. Re-add servers on phones and wife's laptop if needed.
- Copy/rename desired exmaple directory to hosts/xxxxx.
- Update CHANGEME items (disk device id, disk encryption, etc).
- Update configuration as desired.
a. If using base-btrfs with encryption, rename
disko-config-luks.nixtodisko-config.nix - Add new host to flake.nix.
- Sops-nix (if needed):
a. Add any sops-nix keys to nixos-secrets/xxxx/secrets.yml
b. Add new host to nixos-secrets/.sops.yml
c.
sops updatekeyshappens after install d. Update flake inputs - Install using nixos-anywhere
- [[#Installing locally on a new machine using the ISO installer]]
ssh-keygen -f /etc/ssh/backup && chown backup: /etc/ssh/backup. ChangebackupPullto the public key inssh-keys.nixand rebuild all servers.sudo -i -u backup ssh -i /etc/ssh/backup <backup source hostname(s)>and accept fingerprint
- [[#Installing locally on a new machine using the ISO installer]]
- Login to Vaultwarden
- Login to Firefox Sync a. Extensions - ClearURLs, floccus, Gnome Shell integration, Proxy SwitchyOmega 3, Stylus, uBlock Origin, User-Agent switcher and Manager, Vimium
- Open Syncthing on this machine and other machines. Ensure syncing is setup.
- Stow (dotfiles)
cd home/firecat53/docs/family/scott/src/dotfiles
stow -t /home/firecat53/ --dotfiles stow/
stow gomuks music passwords python ssh-scottyThis directory contains disko configuration for homeserver's two-NVMe-drive mirrored ZFS setup with systemd-boot.
Both NVMe drives have identical partition layouts:
| Part | Size | Purpose |
|---|---|---|
| p1 | 1G | EF00 ESP (vfat) - /boot on nvme0 |
| p2 | 4G | Unused (legacy bpool placeholder) |
| p3 | ~1.8T | rpool (ZFS mirror) |
| p4 | 8G | Encrypted swap |
- rpool: Mirrored across both NVMe drives
- ESP: Only the first drive's ESP is mounted at
/boot(systemd-boot) - datapool: Separate SATA drives (not managed by disko)
WARNING: Do NOT run disko --mode disko on an existing system. It would
reformat the drives. The disko config is used only as a NixOS module for
fileSystems generation, and as a reference for future fresh installs.
-
Boot into NixOS installer
-
Clone your configuration
git clone <your-repo-url> /tmp/nixos-config
cd /tmp/nixos-config- Review and adjust disko-config.nix
Check these settings in hosts/homeserver/disko-config.nix:
- Disk devices: Update device paths to match your drives
- Partition sizes: Adjust if needed (swap=8G, rpool uses remaining space)
- Pool/dataset options: Modify compression, reservation, etc. as desired
- Run disko to partition and format
sudo nix run github:nix-community/disko -- --mode disko /tmp/nixos-config/hosts/homeserver/disko-config.nixThis will:
- Partition both NVMe drives
- Create the mirrored rpool ZFS pool and all datasets
- Format the ESP partition
- Set up encrypted swap on both drives
Note: This does NOT touch the SATA drives (datapool). Import datapool separately after install.
- Install NixOS
Disko automatically mounts everything to /mnt.
sudo nixos-install --flake /tmp/nixos-config#homeserver- Reboot
reboot- Post-install
sudo zpool import -f datapool1-4. Follow Scenario A steps 1-4
Run disko to partition, create pools, and mount everything. This creates empty datasets.
- Receive ZFS data into the new pools
Before installing NixOS, populate the datasets with your data:
# Import the old/backup pool with an alternate name
sudo zpool import -R /tmp/oldpool oldrpool
# Recursive send of all data datasets
sudo zfs snapshot -r oldrpool/data@migrate
sudo zfs destroy -r rpool/data
sudo zfs send -R oldrpool/data@migrate | sudo zfs recv rpool/data
# Fix mountpoints to use legacy (disko expects legacy mounts)
sudo zfs set mountpoint=legacy rpool/data/home
sudo zfs set mountpoint=legacy rpool/data/podman_volumes
# ... etc for each datasetNote: You generally don't need to migrate system datasets (rpool/nixos/*)
since NixOS will rebuild those during install. Focus on the rpool/data/*
datasets. datapool lives on separate SATA drives - just import it directly.
After migrating, re-mount everything:
sudo umount -R /mnt
sudo nix run github:nix-community/disko -- --mode mount /tmp/nixos-config/hosts/homeserver/disko-config.nix- Clean up and install
sudo zpool export oldrpool
sudo nixos-install --flake /tmp/nixos-config#homeserver
reboot- Boot: enter the firmware boot menu and select the second NVMe drive. It boots via EFI/BOOT/BOOTX64.EFI (the removable-media fallback).
- Replace the dead drive in the ZFS mirror:
sudo zpool replace rpool <old-nvme0-part3> /dev/disk/by-id/<new>-part3
- Recreate the ESP on the replacement drive and let the next rebuild resync:
sudo mkfs.vfat -F32 -n ESP /dev/disk/by-id/<new>-part1sudo nixos-rebuild boot --flake .#homeserver# repopulates /boot