Day 9 — Flake Layout, Lock Management & Home Manager

Updated

July 30, 2025

Day 9 — Flake Layout, Lock Management & Home Manager

Day 23 — Flake layout

Stage III · ~4h
Goal: Refactor a Stage II single-host flake into a hosts / modules / homes layout that stays rebuildable, readable, and ready for multi-host growth—without a 2 000-line flake.nix.

Note

Baseline: NixOS / nixpkgs 26.05, flakes, modern nix CLI. Home Manager lands on Day 25; today only create the homes/ slot so later imports have a home.

Why this day exists

Stage II proved you can own one machine from a flake. Stage III is about shape: the difference between a lab notebook and a repo you can still navigate in six months—or hand to a second host.

A common failure mode:

flake.nix          # 1800 lines of mixed host, packages, users, networking
hardware-configuration.nix

That works until the second machine, the second user, or the first “extract this SSH policy” refactor. Layout is not aesthetics; it is dependency direction and import graph hygiene.

NixOS flake layout with hosts modules and homes directories

Flake hosts modules homes layout

Theory 1 — What “layout” means in a flake monorepo

A flake has two layers:

Layer Role
flake.nix outputs Thin wiring: inputs → nixosConfigurations, later devShells / checks / packages
Module tree Almost all real config: options + config fragments imported by hosts

Rule of thumb: if a file is only true for one machine, it lives under hosts/<name>/. If it is true for a role or policy, it lives under modules/. If it is true for a user, it will live under homes/ (HM starts Day 25).

Theory 2 — Dependency direction (prevent circular imports)

Think of imports as a DAG:

flake.nix
  → hosts/lab
       → modules/common/*
       → modules/roles/*
       → modules/services/*
       → hardware-configuration
       → (later) homes via HM module
Allowed Forbidden
Host imports modules Module imports a specific host
Role imports common modules Common imports role
Service module is self-contained Service module reaches into hosts/lab paths
lib pure helpers used by modules lib that evaluates full NixOS configs

Circular imports fail evaluation with confusing “infinite recursion” or “attribute missing” symptoms. Design the graph before you split files blindly.

What belongs where

Concern Place
networking.hostName, hardware, disk labels hosts/<name>/
SSH policy, firewall defaults, time zone, locale modules/common/
“This is a headless server” package set modules/roles/server.nix
One service option bundle modules/services/<name>.nix
User shell, git, editors homes/<user>/ (Day 25+)
specialArgs, input plumbing flake.nix only

Theory 3 — Host entry pattern

A host file is a module, not a second flake:

# hosts/lab/default.nix
{ config, pkgs, lib, ... }:
{
  imports = [
    ./hardware-configuration.nix
    ../../modules/common
    ../../modules/roles/server.nix
    # ../../modules/services/my-timer.nix
  ];

  networking.hostName = "lab";

  # Host-only overrides (mkForce / mkDefault as needed)
  # services.openssh.openFirewall = true;  # if you keep SSH on lab LAN
}

And the flake stays thin:

# flake.nix (sketch — adjust system and input names to your Stage II flake)
{
  description = "90DaysOfX NixOS lab — Stage III layout";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
  };

  outputs = { self, nixpkgs, ... }:
  let
    system = "x86_64-linux"; # or aarch64-linux
  in {
    nixosConfigurations.lab = nixpkgs.lib.nixosSystem {
      inherit system;
      modules = [
        ./hosts/lab
      ];
      # specialArgs = { inherit inputs; }; # when you need inputs in modules
    };
  };
}

default.nix vs explicit path

Importing ./hosts/lab loads ./hosts/lab/default.nix. Prefer that over listing every leaf in flake.nix.


Theory 4 — Module “barrel” files

modules/common/default.nix re-exports fragments:

# modules/common/default.nix
{
  imports = [
    ./nix.nix
    ./users.nix
    ./ssh.nix
    ./networking.nix
  ];
}

Benefits:

  • Hosts import one common entry
  • You can still open a single policy file for review
  • Diffs stay small when only SSH changes

Refactor behavior-preserving first: cut → import → rebuild → only then change options. Never mix layout moves with hardening or new services in one commit.


Theory 5 — Hardware config and purity

hardware-configuration.nix is host-local (UUIDs, firmware). Do not share it across machines. Optional lib/mkHost helpers can wait until you have three hosts—prefer an obvious explicit nixosSystem until then.

Before → after: Stage II points flake.nix at a monolith configuration.nix; Stage III points at ./hosts/lab, which imports hardware + modules/common (+ roles/services). Same behavior; smaller change surface.


Lab 1 — Inventory the monolith

Suggested workspace: ~/lab/90daysofx/02-nixos/day23 (or your real flake repo).

cd /path/to/your-flake
find . -name '*.nix' | sort
wc -l flake.nix configuration.nix hosts/*/*.nix 2>/dev/null

In your journal:

  1. List every concern in the big file (users, SSH, packages, networking, custom module, …)
  2. Mark each as host / common / role / service / user
  3. Note any absolute paths or secrets (flag secrets for Stage IV—do not leave plaintext passwords in git)

Lab 2 — Create the directory skeleton

mkdir -p hosts/lab modules/common modules/roles modules/services homes/alice lib

Move hardware config:

# adjust names to your Stage II layout
mv hardware-configuration.nix hosts/lab/
# if configuration.nix was the monolith, keep a copy for reference
cp configuration.nix /tmp/configuration.nix.bak

Lab 3 — Split common modules

Create focused files. Example SSH fragment:

# modules/common/ssh.nix
{ lib, ... }:
{
  services.openssh = {
    enable = true;
    settings = {
      PasswordAuthentication = false;
      KbdInteractiveAuthentication = false;
      PermitRootLogin = "no";
    };
  };
}

Users (replace with your Stage II usernames/keys—keys only, no password hashes in examples here):

# modules/common/users.nix
{ pkgs, ... }:
{
  users.users.alice = {
    isNormalUser = true;
    extraGroups = [ "wheel" ];
    openssh.authorizedKeys.keys = [
      "ssh-ed25519 AAAA...REPLACE_WITH_YOUR_PUBLIC_KEY alice@lab"
    ];
  };
  # security.sudo.wheelNeedsPassword = true; # your policy
}

Nix settings:

# modules/common/nix.nix
{
  nix.settings = {
    experimental-features = [ "nix-command" "flakes" ];
    # auto-optimise-store = true; # optional
  };
  nixpkgs.config.allowUnfree = false; # be explicit about policy
}

Barrel:

# modules/common/default.nix
{
  imports = [
    ./nix.nix
    ./users.nix
    ./ssh.nix
    ./networking.nix
  ];
}

Host entry:

# hosts/lab/default.nix
{ ... }:
{
  imports = [
    ./hardware-configuration.nix
    ../../modules/common
  ];

  networking.hostName = "lab";
  system.stateVersion = "26.05"; # keep your real stateVersion
}

Point flake.nix at ./hosts/lab only.


Lab 4 — Rebuild and prove equivalence

# dry evaluation / build first if you prefer
nixos-rebuild build --flake .#lab

# then switch on the lab host
sudo nixos-rebuild switch --flake .#lab

Verify:

hostname
systemctl is-active sshd
nixos-version

If something broke: roll back (Day 13 muscle memory), fix one module, retry.


Lab 5 — Document the tree

Write docs/layout.md or a short journal entry:

flake.nix          → outputs only
hosts/lab          → hostname, hardware, host overrides
modules/common     → shared policy
modules/roles      → (empty or one role)
modules/services   → extracted Stage II service if any
homes/alice        → Day 25

Commit the layout as its own commit (message below). Optional stretch: sketch hosts/spare/ reusing modules/common but do not wire a non-bootable nixosConfigurations entry without real hardware.


Common gotchas

Symptom / mistake What to do
Infinite recursion after split Module imports a host or config cycle; straighten the DAG
error: path '…' does not exist Relative paths wrong after move; re-check ../../modules
Flake still points at old configuration.nix Update modules = [ ./hosts/lab ];
Hardware UUIDs missing You moved hardware file but forgot to import it
“Works on eval, fails on boot” Bootloader/filesystem only in hardware host file—verify
Mixed absolute /home/you/... imports Use flake-relative paths only
Giant PR of layout + features Split commits: move-only first

Checkpoint

  • Directory tree matches hosts / modules / homes (homes may be stub)
  • flake.nix is thin wiring, not the bulk of config
  • nixos-rebuild switch --flake .#lab (or your hostname) succeeds
  • Import graph has no host→module reverse deps
  • Layout documented in a short tree note
  • Behavior roughly matches pre-refactor Stage II host

Commit

git add .
git commit -m "day23: flake layout hosts/modules/homes"

Write three personal gotchas before continuing.


Tomorrow

Day 24 — Lock discipline: selective flake update, reading lock diffs, and pinning strategy so “layout clean” does not become “nixpkgs random walk.”


Day 24 — Lock discipline

Stage III · ~4h
Goal: Treat flake.lock as a deliberate pin, not a mysterious generated blob—update one input at a time, read diffs, and explain how locks interact with registries and multi-machine rebuilds.

Important

Blind nix flake update on a production host is how “nothing changed except everything.” Prefer staged, selective updates.

Why this day exists

Flakes solve channel drift only if you respect the lock file. Teams fail when:

  • Someone updates all inputs “because CI was green last week”
  • Lock files are gitignored (never do this for apps/hosts)
  • Two machines use different locks and argue about “Nix being unreproducible”
  • Registries silently redirect nixpkgs away from the flake’s pin

Today is ops hygiene for every later day that adds inputs (Home Manager, sops-nix, …).


Theory 1 — What flake.lock actually pins

For each flake input, the lock records roughly:

Field Meaning
type / owner / repo / url Where the input came from
rev Exact git commit (or equivalent)
narHash Content hash of the fetched tree
lastModified Metadata for humans/tools

Implication: two clones with the same lock should fetch the same input trees (modulo substituted vs rebuilt outputs later). Without the lock, nixos-26.05 is a moving branch tip.

Inputs vs follow-ons

inputs = {
  nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
  home-manager.url = "github:nix-community/home-manager/release-26.05";
  home-manager.inputs.nixpkgs.follows = "nixpkgs";
};

follows means Home Manager reuses your nixpkgs pin instead of its own nested lock entry as a separate copy. That reduces “two nixpkgs versions in one eval” confusion and often saves eval work.


Theory 2 — Update operations (precision toolkit)

Command Effect
nix flake lock Refresh lock metadata without changing intended refs (often no-op if already locked)
nix flake update Update all inputs to latest matching their url refs
nix flake update nixpkgs Update only the nixpkgs input
nix flake update home-manager Update only that input
nix flake metadata Show resolved pins / last modified

Modern CLI also supports updating multiple named inputs; check nix flake update --help on your Nix version.

Mental model

flake.nix   →  policy ("track nixos-26.05")
flake.lock  →  decision ("this commit of that policy")

Changing flake.nix URLs without updating lock can error or keep stale locks depending on change type—always re-lock intentionally and commit both files together when inputs change.


Theory 3 — How to read a lock diff

git diff flake.lock | head -200

Look for:

  1. Which input changed ("nixpkgs": { block)
  2. Old rev vs new rev
  3. narHash change (expected when rev changes)
  4. Nested inputs (if not follows)

Optional deep inspection:

nix flake metadata
nix flake metadata --json | jq '.locks.nodes.nixpkgs'

Map rev to a GitHub compare URL when investigating regressions:

https://github.com/NixOS/nixpkgs/compare/<old>...<new>

Theory 4 — Pin strategies (pick a house rule)

Strategy When Risk
Track release branch (nixos-26.05) + lock commits Default for this book Branch tip moves; you choose when to update
Pin exact rev in flake.nix Freeze hard for a milestone Manual bumps; easy to forget security updates
Track nixos-unstable Bleeding edge lab Breakage rate higher
Multiple flakes (host vs tools) Advanced separation Operational complexity

Recommended for 90DaysOfX lab: track nixos-26.05 (and matching HM release), commit lock, update one input per session, rebuild + smoke-test before updating the next.

Security vs stability

Updating nixpkgs is how you receive package and module fixes. Never freeze a public-facing host forever “because it works.” Discipline is when and how, not whether.


Theory 5 — Registries (light literacy)

Flake registries map short names (nixpkgs) to URLs for ad-hoc commands like nix run nixpkgs#hello.

Concern Practice
Global registry Convenient for exploration
Your host flake Should use explicit inputs, not hope the registry matches
CI Prefer locked flake in repo over registry luck
nix registry list

If global nixpkgs points at unstable while your flake pins 26.05, that is fine for one-off nix run—as long as you never assume those are the same pin.


Theory 6 — Multi-machine and “reproducible”

Same lock + same system (x86_64-linux) → same evaluation of inputs. Realization may still:

  • Download substitutes vs build locally
  • Differ if impure eval sneaks in (builtins.currentSystem misuse, unpinned fetchTarball, IFD surprises)
  • Differ across architectures

Lock discipline is necessary, not always sufficient, for bit-identical everything—but it is the main lever you control daily.


Theory 7 — Commit policy for locks

Do Don’t
Commit flake.lock with the change that needs it Gitignore lock for NixOS host flakes
One logical update per commit when possible Mix lock bumps with huge unrelated refactors
Note why you updated in commit body “chore: update” with no smoke test

Example commit body:

flake: update nixpkgs (26.05 tip)

Smoke: sshd active, rebuild switch OK, hello from devShell OK.

Worked example — Selective update narrative

Monday:  update nixpkgs only → rebuild lab → OK
Tuesday: add home-manager input → lock appears → rebuild
Friday:  update home-manager only → HM activation issue → revert lock hunk

Reverting a bad lock is often:

git checkout HEAD~1 -- flake.lock
sudo nixos-rebuild switch --flake .#lab

That is faster than debugging three simultaneous input moves.


Lab 1 — Snapshot current pins

Suggested workspace: your Stage III flake.

cd /path/to/your-flake
nix flake metadata
cp flake.lock /tmp/flake.lock.day24.bak
git status

Journal:

  1. List every input name and its locked rev (short hash is fine)
  2. Confirm flake.lock is tracked by git
  3. Note whether any input uses follows

Lab 2 — Read the lock structure

# human overview
nix flake metadata

# if jq available
nix flake metadata --json | jq '.locks.nodes | keys'

Open flake.lock and identify:

  • The root node’s inputs
  • The nixpkgs node’s rev and narHash
  • Any nested nixpkgs_2 style duplicates (candidate for follows later)

Lab 3 — Selective update of one input

Only if you accept rebuild churn today. On a disposable lab host:

nix flake update nixpkgs
git diff --stat flake.lock
git diff flake.lock | head -120

Build before switch:

nixos-rebuild build --flake .#lab
# if build OK:
sudo nixos-rebuild switch --flake .#lab

Smoke checklist:

systemctl is-system-running || true
systemctl is-active sshd
nix --version

If break: restore backup lock and re-switch.


Lab 4 — Prove “update all” is louder

Do not leave the system on a broken all-update. On a throwaway branch:

git checkout -b experiment/flake-update-all
nix flake update
git diff --stat flake.lock

Count how many inputs / revs moved. Then:

git checkout main   # or your real branch name
git branch -D experiment/flake-update-all  # if you discard
# ensure flake.lock restored

Write one sentence: why selective update is the default for hosts.


Lab 5 — Document house rules

Create docs/lock-policy.md (or journal):

# Lock policy (lab)

- Track nixpkgs nixos-26.05
- Commit flake.lock always
- Update one input per change window
- Smoke: rebuild + SSH + one app path
- Revert lock on failure before deep debug

Optional: add a shell alias or just muscle memory—not a requirement.


Lab 6 — Registry awareness (read-only)

nix registry list

Compare:

nix flake metadata

Note if the registry’s nixpkgs rev differs from the flake’s. No need to change registries today.


Common gotchas

Symptom / mistake What to do
Everyone’s host differs after clone Missing or uncommitted flake.lock
follows not set → two nixpkgs Add inputs.foo.inputs.nixpkgs.follows = "nixpkgs"
Updated lock, forgot rebuild switch / boot to activate
CI uses nix flake update every run Pin lock in repo; update in deliberate PRs
“I’m on 26.05” but lock is months old Expected until you update; schedule updates
Restored flake.nix but not lock Always restore the pair when debugging inputs
Private input auth fails after update Token/SSH access issue, not lock format

Checkpoint

  • Can explain what rev + narHash buy you
  • Can update one named input and read the lock diff
  • Have a written house lock policy
  • Know how to revert flake.lock quickly
  • Understand registry ≠ flake pin
  • follows planned or applied for future HM input

Commit

git add .
git commit -m "day24: lock discipline and update policy"

Write three personal gotchas before continuing.


Tomorrow

Day 25 — Home Manager on NixOS: add Home Manager as a NixOS module so system and user activation share one rebuild story.


Day 25 — Home Manager on NixOS

Stage III · ~4h
Goal: Wire Home Manager as a NixOS module (not standalone-first), manage one user’s shell/git/ssh client config, and rebuild with a single nixos-rebuild switch --flake activation story.

Note

House default for this volume: Home Manager NixOS module on NixOS hosts. Standalone HM is for non-NixOS machines or special cases—know it exists; do not make it your lab’s primary path.

Why this day exists

Stage II put packages on the system (environment.systemPackages). That is correct for daemons and multi-user tools—and wrong as the only place for personal developer config. Home Manager (HM) owns:

  • User programs (programs.git, programs.bash / zsh / fish, …)
  • Dotfiles and XDG (Day 26)
  • User systemd services (Day 27)

Doing HM as a NixOS module means: one flake, one rebuild, one generation story for system + user.


Theory 1 — Three ways people run Home Manager

Mode How it activates When to use
NixOS module Part of nixos-rebuild Default on NixOS (this book)
Standalone home-manager switch --flake ... Other distros; or isolated user experiments
nix-darwin module macOS analog Out of scope here

Why module mode first

  • Aligns HM with system generations (easier mental model for labs)
  • users.users.<name> and home-manager.users.<name> stay in one repo
  • Avoids “system switched but HM forgotten” dual-track ops

Standalone still matters later if you develop on Ubuntu with HM-only—but your NixOS lab should not require two tools for every change.


Theory 2 — Input wiring (26.05-aligned)

# flake.nix (inputs section — merge with your layout)
{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";

    home-manager.url = "github:nix-community/home-manager/release-26.05";
    home-manager.inputs.nixpkgs.follows = "nixpkgs";
  };

  outputs = { self, nixpkgs, home-manager, ... }: {
    nixosConfigurations.lab = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      modules = [
        ./hosts/lab
        home-manager.nixosModules.home-manager
        {
          home-manager.useGlobalPkgs = true;
          home-manager.useUserPackages = true;
          # home-manager.extraSpecialArgs = { inherit inputs; };
          home-manager.users.alice = import ./homes/alice;
        }
      ];
    };
  };
}
Option Meaning
useGlobalPkgs = true HM uses the same pkgs as the system (consistent overlays later)
useUserPackages = true User packages install into user profile via HM
home-manager.users.alice = ... That user’s HM module tree

Version match: HM release-26.05 with nixpkgs nixos-26.05. Mismatched releases are a common source of obscure option errors.


Theory 3 — System user vs HM user

You still need a NixOS user:

# modules/common/users.nix
{
  users.users.alice = {
    isNormalUser = true;
    extraGroups = [ "wheel" ];
    # shell can be set here OR via HM programs.* — pick one source of truth
  };
}

HM configures that user’s home:

# homes/alice/default.nix
{ pkgs, ... }:
{
  home.username = "alice";
  home.homeDirectory = "/home/alice";
  home.stateVersion = "26.05"; # set once; do not casually bump

  programs.home-manager.enable = true;

  programs.git = {
    enable = true;
    userName = "Alice Example";
    userEmail = "alice@example.invalid";
  };

  programs.bash = {
    enable = true;
    enableCompletion = true;
  };

  home.packages = with pkgs; [
    ripgrep
    fd
  ];
}

home.stateVersion

Like NixOS system.stateVersion, this is a compatibility pin for HM stateful defaults—not “current release marketing.” Set it to the HM release you first adopt for that user; read release notes before bumping.


Theory 4 — What belongs in HM vs NixOS

Concern Prefer
sshd, firewall, kernel, system users NixOS modules
Interactive shell, prompt, git, editor config HM
CLI tools only you use HM home.packages
Libraries/services shared by system units NixOS
Secrets for user apps Stage IV patterns (not plaintext in HM)

Anti-pattern: dumping everything into environment.systemPackages because HM felt optional. Opposite anti-pattern: putting nginx only in HM.


Theory 5 — Activation model (what rebuild does)

On nixos-rebuild switch with HM module:

  1. System config builds
  2. HM config for each defined user builds
  3. Activation scripts run: system switch + HM activation for users

Failures can be system or HM. Read the traceback: home-manager-alice.service style units often appear in logs.

journalctl -u home-manager-alice.service -b --no-pager | tail -80

(Username substitution as appropriate.)


Theory 6 — Programs modules vs raw packages

HM programs.* modules often:

  • Install the package
  • Generate config files
  • Wire shell integration

Example: programs.git.enable = true vs only home.packages = [ pkgs.git ] (no managed config). Prefer programs modules when they exist for tools you configure.

Discover options:


Worked example — Minimal useful user

# homes/alice/default.nix
{ pkgs, lib, ... }:
{
  home.username = "alice";
  home.homeDirectory = "/home/alice";
  home.stateVersion = "26.05";

  programs.home-manager.enable = true;

  programs.git = {
    enable = true;
    userName = "Alice";
    userEmail = "alice@example.invalid";
    extraConfig = {
      init.defaultBranch = "main";
      pull.rebase = true;
    };
  };

  programs.ssh = {
    enable = true;
    # Do not put private keys in the flake. Config only:
    matchBlocks = {
      "lab-remote" = {
        hostname = "192.0.2.10"; # documentation range example
        user = "alice";
      };
    };
  };

  programs.bash = {
    enable = true;
    shellAliases = {
      ll = "ls -la";
      ".." = "cd ..";
    };
  };

  home.packages = with pkgs; [ htop ];
}

Private keys stay in ~/.ssh/ with correct permissions—never in git, never as world-readable store strings (Stage IV deep dive).


Lab 1 — Add the input and lock

cd /path/to/your-flake
# edit flake.nix inputs + outputs as in Theory 2
nix flake lock
# or: nix flake update home-manager
git diff flake.lock | head -80

Confirm follows for nixpkgs.


Lab 2 — Stub → real home module

mkdir -p homes/alice

Write homes/alice/default.nix with git + bash + one package. Wire home-manager.nixosModules.home-manager and home-manager.users.<you>.

Ensure users.users.<you> exists on the system side with matching name and home directory.


Lab 3 — Rebuild and verify

sudo nixos-rebuild switch --flake .#lab

As the target user:

git config --global --list | head
which rg || which htop
echo "$HOME"

Check HM generation awareness (command names vary slightly by setup):

home-manager generations 2>/dev/null || true
ls -la ~/.local/state/nix/profiles 2>/dev/null || true

Lab 4 — Change only HM, rebuild once

Edit programs.git.userName, rebuild, confirm new value. You should not need a separate home-manager switch if module mode is correct.


Lab 5 — Deliberate failure drill

Introduce a typo option:

programs.git.enabl = true; # intentional typo

Rebuild, read the error, fix. Note whether the failure is clearly HM evaluation.


Lab 6 — Journal: boundary decisions

List 10 tools you use daily. Mark each NixOS / HM / either. Resolve at least the ambiguous ones with a one-line reason.


Common gotchas

Symptom / mistake What to do
HM release ≠ nixpkgs release Align release-26.05 with nixos-26.05
User name mismatch users.users.x must match home-manager.users.x
home.homeDirectory wrong Must match actual home path
Infinite recursion with useGlobalPkgs Check for pkgs/HM cycles; simplify modules
Expecting standalone CLI only Module mode activates via nixos-rebuild
Private keys in flake Remove immediately; rotate if committed
Bumping home.stateVersion casually Read HM release notes first
Shell set in three places One source of truth (NixOS users.users.shell or HM)

Checkpoint

  • HM input pinned with follows nixpkgs
  • HM NixOS module imported in flake
  • At least one user has git + shell via HM
  • Single nixos-rebuild switch --flake activates system + HM
  • No private key material in the repo
  • home.stateVersion set consciously

Commit

git add .
git commit -m "day25: home-manager as NixOS module"

Write three personal gotchas before continuing.


Tomorrow

Day 26 — HM files & XDG: home.file, xdg.configFile, permissions, and migrating real dotfiles without turning the store into a secret dump.