flake-parts
Prerequisites
- Nix Flakes — understand the raw outputs model before learning what flake-parts abstracts over
What problem it solves
In a raw flake, every per-system output must be written out explicitly for each platform:
outputs = { self, nixpkgs }: {
packages.x86_64-linux.hello = nixpkgs.legacyPackages.x86_64-linux.hello;
packages.aarch64-linux.hello = nixpkgs.legacyPackages.aarch64-linux.hello;
packages.x86_64-darwin.hello = nixpkgs.legacyPackages.x86_64-darwin.hello;
packages.aarch64-darwin.hello = nixpkgs.legacyPackages.aarch64-darwin.hello;
# repeat for devShells, checks, apps...
};This is tedious and error-prone. The first attempt to fix it was flake-utils (by numtide, 2020) — a utility library that iterates over systems for you:
flake-utils.lib.eachDefaultSystem (system: {
packages.hello = nixpkgs.legacyPackages.${system}.hello;
})flake-utils eliminates repetition but it’s just a helper function — you can’t compose multiple files, can’t declare typed options, can’t share configuration between multiple flake outputs cleanly.
What flake-parts is
flake-parts (by Hercules CI, 2022) applies the NixOS module system to flake outputs. Instead of writing one big function that returns an attribute set, you write composable modules — the same { options, config, lib, ... } pattern that NixOS uses for system configuration, but governing the flake’s output structure.
# flake.nix
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
flake-parts.url = "github:hercules-ci/flake-parts";
};
outputs = inputs@{ flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
perSystem = { pkgs, system, ... }: {
# This block is evaluated once per system
packages.default = pkgs.hello;
devShells.default = pkgs.mkShell { buildInputs = [ pkgs.go ]; };
};
flake = {
# This block is evaluated once — system-agnostic outputs
nixosConfigurations.myhost = inputs.nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [ ./configuration.nix ];
};
};
};
}perSystem runs once per entry in systems. flake-parts merges the results into the standard packages.x86_64-linux.default, packages.aarch64-linux.default, etc. structure. The flake block produces outputs that aren’t per-system (like nixosConfigurations, which embed the system string internally).
perSystem arguments
When flake-parts calls your perSystem function, it passes:
| Argument | What it provides |
|---|---|
pkgs | nixpkgs.legacyPackages.${system} — the package set for this system |
system | The current system string, e.g. "x86_64-linux" |
inputs' | Your inputs with per-system nesting removed — inputs'.foo.packages.bar instead of inputs.foo.packages.x86_64-linux.bar |
self' | Same as inputs' but for your own flake’s outputs |
config | The perSystem-level module config (for accessing other perSystem options) |
The trailing apostrophe convention (inputs', self') means “already specialized to the current system.” It saves one level of attribute access in every expression.
When to use it vs raw outputs
Raw outputs — when you’re learning (the mechanics are visible), the flake is tiny (one host, one or two packages), or you want zero additional dependencies.
flake-parts — when you support multiple systems, want to split your flake across multiple files (each file is a flake-parts module), want typed options for configuration, or want to use community modules from the flake-parts ecosystem (treefmt-nix for formatting, devshell for richer dev environments, process-compose-flake for multi-process dev setups).
How rv2ima-dotfiles uses it
The rv2ima-dotfiles flake manages 6+ NixOS servers and 3 macOS laptops from a single flake.nix. It uses flake-parts to separate per-system tooling (a dev shell with Colmena, the deployment tool) from the system-agnostic machine configurations:
flake-parts.lib.mkFlake { inherit inputs; } ({ inputs, ... }: {
systems = [ "x86_64-linux" "aarch64-darwin" ];
flake = {
# Machine configs — system-agnostic (system is inside each config)
nixosConfigurations = builtins.listToAttrs (map ...);
darwinConfigurations = builtins.listToAttrs (map ...);
# Colmena hive for fleet deployment
colmenaHive = colmena.lib.makeHive self.outputs.colmena;
# Helper functions accessible via self.lib
lib = { vars = import ./vars/machines.nix inputs; nixosModule = ...; };
};
perSystem = { pkgs, system, ... }: {
# Everyone who clones this repo gets colmena in their shell
devShells.default = pkgs.mkShell {
packages = [ colmena.packages.${system}.colmena ];
};
};
});The key insight: nixosConfigurations goes in flake (not per-system) because each configuration already knows its target system internally. The perSystem block just provides developer tooling.
See also
- Nix Flakes — the underlying mechanism that flake-parts wraps