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:

ArgumentWhat it provides
pkgsnixpkgs.legacyPackages.${system} — the package set for this system
systemThe 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
configThe 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