Motivation#
The previous post covered NixOS’s core mechanisms; this one covers the landing: how a single Git repository is actually organized to declaratively manage three very different machines. The repository lives at /home/lijin/nixos-config; its 280+ commits and 174 system generations are all recorded inside it.
Prerequisites#
- You have read “NixOS (2): Core Concepts”
- Basic Git experience
The Repository vs /etc/nixos#
A common question: NixOS’s system configuration normally lives at /etc/nixos/configuration.nix, so why is our repository separate?
This is deliberate. The installer-generated /etc/nixos stays as the initial system’s configuration (and an emergency fallback), while day-to-day we build and activate the flake in Git:
| |
Once you pass an explicit flake path to nixos-rebuild, /etc/nixos no longer participates in the build. The upside: the configuration always has Git as its source of truth — normal git pull, git diff, commit review. The downside is minimal; the only thing to watch is not mixing up edits between the two places.
Directory Layout#
| |
The Three Scopes of Configuration#
The first organizing principle is to put every setting in the narrowest scope that fits it, with exactly three rules:
- Policy needed by every machine →
modules/common.nix. Networking, desktop services, user accounts, the command-line toolkit (bat, fd, fzf, ripgrep, …), SSH policy, KRDP firewall allowances, Tailscale, Nix settings. Note: hardware modules must never go here — a Surface touchpad driver cannot be “shared” with a ThinkPad. - Policy specific to one machine →
hosts/<host>/configuration.nix. For example the Surface-only OpenClaw firewall rule and Plasma Wayland autologin policy; Halo’s local AI services (Ollama, q38rocm, Steam); T460s’s “stay awake with the lid closed” logind policy. - Installer-generated hardware data →
hosts/<host>/hardware-configuration.nix. Disks, filesystems, EFI, swap — physical properties of the machine — come fromnixos-generate-config. Do not edit casually; if the storage layout changes, regenerate and review the diff:
| |
User-level configuration (Home Manager) has the same two-layer split: home/lijin.nix is shared; per-machine user settings go to home/hosts/<machine>.nix. For instance Surf and Halo share one Plasma appearance and panel layout (managed by the plasma-manager module), but monitor topology differs per machine and stays local.
A concrete example: KRDP remote-desktop firewall policy. “Allow ports 3389 etc.” is shared policy (all three machines run KRDP) and lives in common.nix; but Surf additionally runs a Home Manager autolock service after KRDP starts, protecting the unattended physical console — Surface-only, so it lives in home/hosts/krdp-autolock.nix.
mkHost: A Function Removes Triple Duplication#
flake.nix does not write each machine’s configuration three times; it defines one mkHost function:
| |
Each machine is then one call with its own differences:
| |
Note that Surface additionally imports the nixos-hardware module for Surface (IPTS touchpad driver and the patched Linux Surface kernel), while T460s needs no extra module at all — the standard kernel suffices. On the output side, three names plus a compatibility alias are registered:
| |
So nixos-rebuild --flake .#t460s, .#halo, .#surface-pro-6 each build their own machine without interfering with the others.
Home Manager Integration#
Home Manager manages user-level configuration (files under ~/.config, user packages, shell initialization). We integrate it as a NixOS module in the flake rather than using it standalone: nixos-rebuild switch applies system and user configuration together, so no separate home-manager switch is needed. A few related settings:
| |
The Day-to-Day Change Workflow#
For any configuration change, the standard sequence is:
| |
Key points:
nix flake checkbuilds nothing — it only evaluates. In seconds it tells you whether the configuration’s syntax/options are right, and it is the first step both AI and humans should run.testfirst,switchsecond; if it broke something,sudo nixos-rebuild switch --rollback.- Always
git diffbefore committing: in NixOS, one “commit” corresponds to “the shape this whole machine will take at the next activation” — worth a careful look.
Deliberate flake.lock Updates#
Upgrading dependencies (nixpkgs snapshots, home-manager, …) is a different class of change with higher risk, because it may trigger large rebuilds. Our sequence:
| |
Why Surf needs special care: its Linux Surface kernel is compiled locally, and an input update can invalidate the cache and force a full recompile. The generation-103 update took more than four hours and peaked at about 45 GiB of disk; an earlier attempt failed near the end with No space left on device. So before updating Surf we budget at least 50 GiB free (on Surf, /, /nix and the build temp area share one filesystem, so watch /) plus several uninterrupted hours.
Per-Generation Release Notes: A Historical Archive in Git#
Each machine has a “build notes” file (docs/update-surf.md, update-halo.md, update-t460s.md) recording every real activation, newest first:
| |
The conventions:
- Each generation entry must correspond to a real activation (the timestamp comes from the system profile links); never invent one. Commits that only change documentation or tooling go under “Git-only follow-up changes” and do not consume a generation number.
- These notes are a historical record: even if
nix-collect-garbagedeletes an old generation’s store paths, the note stays — it describes “what configuration was actually activated then”, not “where you can roll back today” (that isnixos-rebuild list-generations). - For AI maintainers, these notes plus Git history are the full context: before taking over any machine, read its build notes first.
Git Notes#
- Do not casually commit a whole home directory to Git:
/home/lijinmay contain passwords, tokens, SSH private keys, browser data. Declarative system configuration goes only in this repository; user files you want managed are declared explicitly through Home Manager (home.fileetc.), nevergit add ~. - Early commits were local-only; the backup strategy is a private remote repository, reviewed for leaked secrets before pushing (secrets always go through SOPS encryption — see post 9 of this series).
Summary#
| What to place | Where |
|---|---|
| Policy shared by all machines | modules/common.nix |
| One machine’s own policy | hosts/<host>/configuration.nix |
| Installer-generated hardware data | hosts/<host>/hardware-configuration.nix (regenerate + review diff; no hand edits) |
| Shared user-level configuration | home/lijin.nix (large programs split into home/<program>/) |
| Per-machine user settings | home/hosts/<host>.nix |
| Secrets | SOPS-encrypted files under secrets/ |
| Containers v2 host inventories | container-config/hosts/<host>.yaml |
| Every activation record | docs/update-<host>.md |
The next post covers how we hand this workflow to AI: a “maintainer skill” that defines the standard workflow, validation order, and safety rules.
Related Posts#
- “NixOS (2): Core Concepts — Declarative Configuration, Flakes and Rollback”: the mechanism foundation for this post
- “Oh My Posh shell configuration series”: the upstream repository behind our
shell-configflake input — the source of our Zsh/Oh My Posh configuration
