flake-parts (optional structure)

Updated

July 30, 2026

flake-parts (optional structure)

Goal: Choose a house style for multi-output flakes—either adopt flake-parts to reduce boilerplate, or enforce a hand-rolled structure with equal consistency—and apply it to your host flake.

Note

There is no moral victory in either choice. There is only consistency. Mixing half flake-parts and half ad-hoc outputs = args: … is the failure mode.

Why this chapter exists

By now your flake may export:

  • nixosConfigurations.*
  • devShells.* (host-level tooling)
  • Soon: checks.*, maybe packages.*, formatter

Raw outputs functions grow nested let blocks and copy-pasted system lists. Two responses exist in the ecosystem:

Style Idea
flake-parts Framework: modules for flakes; per-system helper; community modules
Hand-rolled discipline Small local helpers (forAllSystems), strict file layout, no new dependency

You pick one and document it.


Theory 1 — Pain that motivates structure

Typical messy outputs:

outputs = { self, nixpkgs, home-manager, ... }@inputs:
let
  system = "x86_64-linux";
  pkgs = import nixpkgs { inherit system; };
in {
  nixosConfigurations.lab = /* ... */;
  devShells.${system}.default = /* ... */;
  # forgot aarch64; forgot checks; formatter missing
};

Problems:

  • Single-system assumptions
  • Repeated pkgs construction
  • Hard to share patterns across repos
  • Easy to forget an output class

Theory 2 — Hand-rolled discipline (no flake-parts)

Pattern used widely:

{
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
  # home-manager ...

  outputs = { self, nixpkgs, ... }@inputs:
  let
    systems = [ "x86_64-linux" "aarch64-linux" ];
    forAllSystems = nixpkgs.lib.genAttrs systems;
    pkgsFor = system: import nixpkgs {
      inherit system;
      # overlays = [ self.overlays.default ];
    };
  in {
    nixosConfigurations.lab = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      specialArgs = { inherit inputs; };
      modules = [ ./hosts/lab /* hm module wiring */ ];
    };

    devShells = forAllSystems (system:
      let pkgs = pkgsFor system; in {
        default = pkgs.mkShell {
          packages = with pkgs; [ nixpkgs-fmt nil ];
        };
      });

    formatter = forAllSystems (system: (pkgsFor system).nixpkgs-fmt);
  };
}

House rules for hand-rolled

  1. One systems list
  2. One pkgsFor / overlay injection point
  3. Host modules stay under hosts/; flake stays wiring
  4. New output types get a comment in docs/flake-style.md
  5. No deep logic in flake.nix—call into ./lib or ./flake/ if needed

Theory 3 — flake-parts approach

flake-parts turns outputs into a module system for flakes.

{
  description = "lab with flake-parts";

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

  outputs = inputs@{ flake-parts, ... }:
    flake-parts.lib.mkFlake { inherit inputs; } {
      systems = [ "x86_64-linux" "aarch64-linux" ];

      perSystem = { pkgs, system, ... }: {
        devShells.default = pkgs.mkShell {
          packages = with pkgs; [ nixpkgs-fmt nil ];
        };
        formatter = pkgs.nixpkgs-fmt;
      };

      flake = {
        nixosConfigurations.lab = inputs.nixpkgs.lib.nixosSystem {
          system = "x86_64-linux";
          specialArgs = { inherit inputs; };
          modules = [
            ./hosts/lab
            inputs.home-manager.nixosModules.home-manager
            {
              home-manager.useGlobalPkgs = true;
              home-manager.useUserPackages = true;
              home-manager.users.alice = import ./homes/alice;
            }
          ];
        };
      };
    };
}
Concept Role
perSystem Outputs that vary by system (packages, devShells, checks, formatter)
flake = { ... } Top-level flake attrs like nixosConfigurations
Modules You can split flake-parts config across files

Costs

  • Another input to pin and learn
  • Stack traces include framework layers
  • Overkill for a single nixosConfigurations only—but multi-output flakes benefit

Split flake-parts modules (optional)

# flake.nix
outputs = inputs@{ flake-parts, ... }:
  flake-parts.lib.mkFlake { inherit inputs; } {
    imports = [
      ./flake-modules/devshell.nix
      ./flake-modules/hosts.nix
    ];
    systems = [ "x86_64-linux" ];
  };

Theory 4 — Decision rubric

Choose flake-parts if… Choose hand-rolled if…
You expect many per-system outputs You want minimal dependencies
You like module-shaped config everywhere You already understand raw flakes well
You will copy patterns across many repos One lab flake is enough for months
You accept framework upgrades You prefer total local control

Book stance: either is valid. What matters is that you have a style and stick to it.


Theory 5 — What not to do

  • Rewrite layout (hosts/modules/homes) and adopt flake-parts in the same panicked hour without rebuild
  • Import flake-parts and still paste a second full outputs tree beside it
  • Put NixOS module logic inside perSystem incorrectly (hosts are usually under flake.nixosConfigurations)
  • Enable every community flake-parts module on day one
  • Change house style every weekend

Theory 6 — Formatter as a style signal

Regardless of framework, expose a formatter:

nix fmt

With formatter output set, this becomes a team habit before checks enforce it.

Formatter Notes
nixpkgs-fmt Common, stable
alejandra Opinionated alternative
nixfmt Ecosystem evolving—pin deliberately

Pick one; put it in formatter and in the devShell.


Theory 7 — Overlay injection point

Whichever style you pick, define one place overlays are applied to pkgs:

pkgsFor = system: import nixpkgs {
  inherit system;
  overlays = [ (import ./overlays) ];
};

flake-parts: configure perSystem pkgs via its nixpkgs module patterns, or construct consistently—document how.


Worked example — Split hand-rolled helpers

# lib/flake-utils.nix
{ nixpkgs }:
rec {
  systems = [ "x86_64-linux" "aarch64-linux" ];
  forAllSystems = nixpkgs.lib.genAttrs systems;
  pkgsFor = system: import nixpkgs { inherit system; };
}
# flake.nix uses:
# let u = import ./lib/flake-utils.nix { inherit nixpkgs; }; in …

Keeps flake.nix readable without flake-parts.


Worked example — Decision record template

# Flake style

Decision: hand-rolled | flake-parts
Date: YYYY-MM-DD
Rationale: …

Rules:
- systems = …
- overlays applied in …
- hosts defined under …
- how to add a new nixosConfiguration
- how to add a devShell

Exercises

Exercise 1 — Audit current outputs

cd /path/to/your-flake
nix flake show

Journal:

  1. Which outputs exist today?
  2. Which will exist after checks/overlays chapters?
  3. Is flake.nix already painful?

Exercise 2 — Pick and write the decision

Create docs/flake-style.md with the template above. Commit this even before code changes—forces intentionality.

Exercise 3A — If flake-parts: adopt on a branch

git checkout -b experiment/flake-parts
# add input, rewrite outputs via mkFlake
nix flake lock
nix flake show
nixos-rebuild build --flake .#lab
sudo nixos-rebuild switch --flake .#lab   # if build OK

Prove HM + host still activate.

Exercise 3B — If hand-rolled: extract helpers

git checkout -b experiment/flake-discipline
# add systems + forAllSystems + formatter + devShells
nix flake show
nix fmt 2>/dev/null || true
nix develop -c true
nixos-rebuild build --flake .#lab

Exercise 4 — Merge the winner

Merge the experiment branch to your main lab branch. Delete half-finished alternatives. One style in flake.nix.

Exercise 5 — Teach the style to “future you”

In docs/flake-style.md, document how to:

  1. Add a new nixosConfigurations host
  2. Add a per-system devShell
  3. Update flake-parts (if used) or helpers (if not)

Exercise 6 — Formatter end-to-end

nix fmt
git diff

Format should be deterministic. Fix any files the formatter rewrites.

Exercise 7 — specialArgs survival

After refactor, confirm modules that need inputs still receive them (HM, future sops).

Exercise 8 — Show vs build

nix flake show
nixos-rebuild build --flake .#lab -L

If show looks empty-ish, build often has the real trace.

Exercise 9 — follows still holds

nix flake metadata

Confirm companion inputs still follows nixpkgs after the rewrite.

Exercise 10 — Negative hybrid cleanup

Search for leftover dual patterns (outputs = dead code, unused flake-parts import). Remove.

Exercise 11 — Systems honesty

If you only ever build x86_64-linux, either keep one system or document why two systems exist without CI.

Exercise 12 — Style PR description

Write a short paragraph as if reviewing your own style adoption: risks, rollback, smoke tests.


Common gotchas

Symptom / mistake What to do
perSystem vs flake confusion Hosts go under flake; shells under perSystem
Lost specialArgs / inputs Thread inputs explicitly after rewrite
HM module dropped in refactor Re-check modules list post-migration
Double nixpkgs without follows Keep follows on all companion inputs
nix flake show empty-ish Eval error—run build to see full trace
Framework without docs Write house docs the same day
Layout + framework same commit Split; prove rebuild between
Formatter wars in one PR Pick one tool; migrate separately
Community modules kitchen-sink Add one optional module at a time
Infinite recursion after pkgs hook Simplify pkgs construction

Checkpoint

  • Explicit house style documented
  • nix flake show reflects multi-output intent
  • Host rebuild still works
  • formatter and/or devShells exist in the chosen style
  • No hybrid mess left on main branch
  • How-to for adding a host written
  • specialArgs/inputs still correct for HM
  • follows discipline intact

Journal (optional)

Write three personal gotchas before continuing.