Flake repository layout

Updated

July 30, 2026

Flake repository layout

Goal: Refactor a 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 is wired in the Home Manager chapter; in this chapter only create the homes/ slot so later imports have a home.

Why this chapter exists

Owning one machine from a flake proves control. Shape is what keeps that control after a second host, a second user, or the first “extract this SSH policy” refactor.

A common failure mode:

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

That works until the import graph becomes a ball of yarn. Layout is not aesthetics; it is dependency direction and change-surface control.

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/ (Home Manager starts in the next chapters).

Target tree

flake.nix
flake.lock
hosts/
  lab/
    default.nix              # host entry: imports modules + hardware
    hardware-configuration.nix
modules/
  common/
    default.nix              # baseline shared by all hosts
    nix.nix
    users.nix
    ssh.nix
    networking.nix
  roles/
    workstation.nix
    server.nix
  services/
    # empty or one tiny service extracted from the monolith
homes/
  alice/
    default.nix              # stub until HM chapter
lib/
  default.nix                # optional helpers (mkHost, …)
docs/
  layout.md

Names are conventions, not law. The invariant is layering, not exact folder names.


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>/
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;
}

And the flake stays thin:

# flake.nix
{
  description = "NixOS lab — hosts/modules/homes 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 modules need inputs
    };
  };
}

default.nix vs explicit path

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

specialArgs vs module arguments

Mechanism Use
specialArgs = { inherit inputs; } Pass flake inputs into all host modules
_module.args More advanced; avoid until needed
import of pure lib/ Helpers that need no config

Do not thread half the world through specialArgs “just in case.” Add when a module actually needs an input (Home Manager, sops-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.

Optional deeper split

modules/common/
  default.nix
  nix.nix
  users.nix
  ssh.nix
  networking.nix
  locale.nix
  time.nix

Split when a file exceeds ~150 lines or two people edit conflicting concerns—not on principle alone.


Theory 5 — Hardware config and purity

hardware-configuration.nix is host-local (UUIDs, firmware). Do not share it across machines.

Practice Why
Keep under hosts/<name>/ Machine-specific
Generated by installer / nixos-generate-config Not hand-invented blindly
Import only from that host Avoid wrong disk UUIDs

Optional lib/mkHost helpers can wait until you have three hosts—prefer an obvious explicit nixosSystem until then.

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


Theory 6 — Roles vs common vs services

Kind Semantics Example
common True for nearly every host flakes enabled, base SSH policy
role Class of machine server.nix disables Bluetooth; workstation.nix enables NetworkManager
service One workload option bundle modules/services/caddy.nix
# modules/roles/server.nix
{
  imports = [ ../common ];
  # server-lean defaults
  # hardware.bluetooth.enable = false;
  # services.printing.enable = false;
}

Hosts compose roles; roles compose common. Services are imported by hosts or roles when that workload is on.


Theory 7 — Multi-host readiness (without building the second machine)

Sketch without wiring a non-bootable config:

hosts/
  lab/
  spare/          # optional empty skeleton
    default.nix   # imports common + own hardware later
# flake.nix (future)
nixosConfigurations = {
  lab = nixpkgs.lib.nixosSystem {modules = [ ./hosts/lab ]; };
  # spare = nixpkgs.lib.nixosSystem { … modules = [ ./hosts/spare ]; };
};

Do not invent fake hardware UUIDs “for symmetry.” Document the intended second host in docs/layout.md instead.


Theory 8 — Relative paths and flake root

After moves, relative imports break silently until eval:

From Import
hosts/lab/default.nix ../../modules/common
modules/common/ssh.nix no host paths
flake.nix ./hosts/lab only

Avoid absolute /home/you/... imports. Flake evaluation should work from any clone path.


Worked example — Minimal SSH + users + nix split

# modules/common/ssh.nix
{ lib, ... }:
{
  services.openssh = {
    enable = true;
    settings = {
      PasswordAuthentication = false;
      KbdInteractiveAuthentication = false;
      PermitRootLogin = "no";
    };
  };
}
# 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"
    ];
  };
}
# modules/common/nix.nix
{
  nix.settings = {
    experimental-features = [ "nix-command" "flakes" ];
  };
  nixpkgs.config.allowUnfree = false;
}
# modules/common/networking.nix
{
  # hostName stays on the host; put only shared defaults here
  networking.firewall.enable = true;
  time.timeZone = "UTC"; # or your zone
}
# hosts/lab/default.nix
{ ... }:
{
  imports = [
    ./hardware-configuration.nix
    ../../modules/common
  ];

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

Worked example — Thin flake with optional lib

# lib/systems.nix
{
  defaultSystem = "x86_64-linux";
}
# flake.nix
{
  description = "NixOS lab";

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

  outputs = { self, nixpkgs, ... }:
  let
    inherit (import ./lib/systems.nix) defaultSystem;
  in {
    nixosConfigurations.lab = nixpkgs.lib.nixosSystem {
      system = defaultSystem;
      modules = [ ./hosts/lab ];
    };
  };
}

Keep lib/ free of full system evaluation until you know you need it.


Exercises

Exercise 1 — Inventory the monolith

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

Journal:

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

Exercise 2 — Create the directory skeleton

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

Move hardware config:

# adjust names to your layout
mv hardware-configuration.nix hosts/lab/ 2>/dev/null || true
cp configuration.nix /tmp/configuration.nix.bak 2>/dev/null || true

Exercise 3 — Split common modules

Create ssh.nix, users.nix, nix.nix, networking.nix, and the barrel default.nix from the worked examples. Replace keys and usernames with yours—public keys only.

Exercise 4 — Host entry + thin flake

Write hosts/lab/default.nix. Point flake.nix at ./hosts/lab only. Remove or stop importing the old monolith path.

Exercise 5 — Build before switch

nixos-rebuild build --flake .#lab
sudo nixos-rebuild switch --flake .#lab

Verify:

hostname
systemctl is-active sshd
nixos-version

If something broke: roll back, fix one module, retry.

Exercise 6 — Document the tree

Write docs/layout.md:

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

Exercise 7 — Role extract (optional stretch)

Move 2–3 server-lean settings into modules/roles/server.nix and import from the host. Rebuild. Confirm behavior unchanged.

Exercise 8 — Path-break drill

Temporarily wrong a relative import (../common instead of ../../modules/common). Run nixos-rebuild build, read the error, fix. Note the message for future you.

Exercise 9 — Diff against backup

# conceptual: compare options that matter
diff -u /tmp/configuration.nix.bak hosts/lab/default.nix | head -80 || true

List any intentional deltas; there should be none for a pure layout move.

Exercise 10 — Second-host skeleton (docs only)

Write five lines in docs/layout.md about what a second host would import. Do not invent hardware.

Exercise 11 — Secrets flag list

From Exercise 1, list every sensitive-looking string. Either remove now or ticket for secrets chapters. No live tokens in git.

Exercise 12 — Commit hygiene

git add hosts modules homes lib docs flake.nix flake.lock
git status
git commit -m "flake: hosts/modules/homes layout (behavior-preserving)"

Prefer one commit for layout-only moves.


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 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
stateVersion changed “to match release” Do not casual-bump; keep original
Barrel forgets a file Missing policy after split—add to imports
Role imports host Invert: host imports role

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 docs/layout.md
  • Behavior roughly matches pre-refactor host
  • No plaintext secrets introduced during the move
  • stateVersion unchanged unless you know why

Journal (optional)

Write three personal gotchas before continuing—especially path errors and anything you almost put in the wrong plane.