Skip to main content

NixOS (3): Repository Architecture — How One Flake Manages Three Machines

Jin Li
Author
Jin Li
Fate lies within the lightcone.
NixOS Series - This article is part of a series.
Part 3: This Article

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
#

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:

1
sudo nixos-rebuild switch --flake /home/lijin/nixos-config#surface-pro-6

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
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
nixos-config/
├── flake.nix                 # entry point: inputs + three machine outputs
├── flake.lock                # locks every input version (must stay in Git)
├── modules/
│   └── common.nix            # policy shared by all machines
├── hosts/
│   ├── surface-pro-6/        # one directory per machine
│   │   ├── configuration.nix
│   │   └── hardware-configuration.nix   # installer-generated; don't hand-edit
│   ├── halo/
│   └── t460s/
├── home/
│   ├── lijin.nix             # shared user-level configuration (Home Manager)
│   ├── <program>/            # larger programs get their own module (wezterm, plasma…)
│   └── hosts/<machine>.nix   # per-machine user-level overrides
├── secrets/                  # SOPS-encrypted secrets (global/host/service scopes)
├── container-config/         # host inventories for containers v2
├── packages/ scripts/ schemas/ tests/   # our own packages and tooling
└── docs/                     # runbooks, software docs, per-generation release notes

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:

  1. 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.
  2. 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.
  3. Installer-generated hardware data → hosts/<host>/hardware-configuration.nix. Disks, filesystems, EFI, swap — physical properties of the machine — come from nixos-generate-config. Do not edit casually; if the storage layout changes, regenerate and review the diff:
1
2
sudo nixos-generate-config
git 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
mkHost = { hostModule, extraModules ? [ ], homeModules ? [ ], ... }:
  nixpkgs.lib.nixosSystem {
    system = "x86_64-linux";
    specialArgs = { inherit inputs; };
    modules = [
      hostModule
      inputs."shell-config".nixosModules.default   # shared shell configuration
      inputs.sops-nix.nixosModules.sops             # secrets management
      { home-manager.users.lijin.imports = [ ./home/lijin.nix ] ++ homeModules; ... }
    ] ++ extraModules;
  };

Each machine is then one call with its own differences:

1
2
3
4
5
6
7
surfacePro6 = mkHost {
  hostModule = ./hosts/surface-pro-6/configuration.nix;
  extraModules = [ nixos-hardware.nixosModules.microsoft-surface-pro-intel ];
  homeModules = [ ./home/hosts/surface-pro-6.nix ./home/hosts/krdp-autolock.nix ];
};
halo = mkHost { hostModule = ./hosts/halo/configuration.nix; homeModules = [ ./home/hosts/halo.nix ]; };
t460s = mkHost { hostModule = ./hosts/t460s/configuration.nix; };

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:

1
2
3
4
5
6
nixosConfigurations = {
  surf = surfacePro6;
  "surface-pro-6" = surfacePro6;
  nixos = surfacePro6;   # compatibility alias for the old name
  inherit halo t460s;
};

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:

1
2
3
4
5
{
  home-manager.useGlobalPkgs = true;      # user packages follow the system's pkgs
  home-manager.useUserPackages = true;
  home-manager.backupFileExtension = "hm-backup";   # keep a backup of overridden files
}

The Day-to-Day Change Workflow
#

For any configuration change, the standard sequence is:

1
2
3
4
5
6
7
8
9
cd /home/lijin/nixos-config
git pull                                  # if the repository has a remote
$EDITOR modules/common.nix                # or hosts/<machine>/configuration.nix
nix flake check                           # fast evaluation of all host configurations
sudo nixos-rebuild test --flake .#surface-pro-6   # temporary, current boot only
sudo nixos-rebuild switch --flake .#surface-pro-6 # permanent; new generation
git diff
git add home modules hosts flake.nix flake.lock README.md docs
git commit -m "Describe the change"

Key points:

  • nix flake check builds 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.
  • test first, switch second; if it broke something, sudo nixos-rebuild switch --rollback.
  • Always git diff before 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:

1
2
3
4
5
6
df -h /                                   # check free disk first
sudo nix-collect-garbage --delete-older-than 7d   # reclaim old generations (essential on Surf)
nix flake update
git diff                                  # review the lock-file change
sudo nixos-rebuild switch --flake .#<host>
git add flake.lock && git commit -m "Update flake inputs"

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:

1
2
3
4
5
6
7
8
## Generation 115

- **Time:** 2026-09-01 17:14
- **Git commit:** `0652b33` (committed immediately after activation)
- **Main changes:**
  - Completed the packaged MebTTY deployment and made it the active service.
  - Let systemd create the state directory via StateDirectory before namespace hardening, fixing generation 114's startup failure.
  - Verified the health endpoint, PAM terminal creation, PTY reconnect, and file browsing.

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-garbage deletes 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 is nixos-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/lijin may 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.file etc.), never git 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 placeWhere
Policy shared by all machinesmodules/common.nix
One machine’s own policyhosts/<host>/configuration.nix
Installer-generated hardware datahosts/<host>/hardware-configuration.nix (regenerate + review diff; no hand edits)
Shared user-level configurationhome/lijin.nix (large programs split into home/<program>/)
Per-machine user settingshome/hosts/<host>.nix
SecretsSOPS-encrypted files under secrets/
Containers v2 host inventoriescontainer-config/hosts/<host>.yaml
Every activation recorddocs/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 Series - This article is part of a series.
Part 3: This Article