Flake Parts
17 min read
flake-parts brings the NixOS module system to flakes themselves.
A flake.nix is configuration, and configuration is what the module system is good at. Where NixOS used modules to stop configuration.nix from becoming one enormous file of glue, flake-parts does the same for flake.nix: outputs become options, files become modules, and third parties can ship modules that plug straight into your flake.
It is deliberately not a monorepo trying to absorb all of open source. It is a small core — the attributes that constitute a flake — that other repositories build on, plus an ecosystem of modules you opt into.
Features:
• Split flake.nix into focused units, each in their own file.
• Take care of system, once, instead of forAllSystems boilerplate in every output.
• Let users of your library flake integrate your outputs into theirs by importing a module.
• Reuse project logic written by others.
Mental Model
It is evalModules with a flake-shaped schema
mkFlake is thin. Stripped down, it is:
nix
mkFlake = args: module:
(lib.evalModules {
specialArgs = { inherit self flake-parts-lib moduleLocation inputs; };
modules = builtins.attrValues builtinModules ++ [ module ];
class = "flake";
}).config.processedFlake;
That is the whole trick. Everything you know about NixOS modules applies verbatim: imports, options, config, mkIf, mkDefault, mkForce, merging, type checking, _module.args. The class = "flake" tag means the module system will refuse a NixOS module accidentally imported into your flake, and vice versa.
The set of "known" flake outputs (packages, checks, apps, devShells, formatter, overlays, nixosConfigurations, …) are just options declared by the built-in modules. Anything not declared still works — flake has a freeform type — but only declared attributes can be merged across modules.
The transposition
This is the central idea, and the reason perSystem exists.
Flake outputs are indexed attribute first, system second:
outputs.packages.x86_64-linux.hello
^^^^^^^^ ^^^^^^^^^^^^
But when you are writing a project, you think system first, attribute second — "for this system, here are my packages, my checks, my shell":
perSystem.x86_64-linux.packages.hello
Swapping those two indices is called transposition, and flake-parts does it for you:
perSystem : .${system}.${attribute}
outputs : .${attribute}.${system}
Declaring transposition.foo = { }; adds, in pseudo-code:
nix
flake.foo.${system} = (perSystem system).foo;
perInput = system: inputFlake: inputFlake.foo.${system};
Note the second line. Transposition also defines the reverse operation, and that reverse is exactly where inputs' and self' come from. inputs'.foo.packages.bar is not special syntax — it is inputs.foo.packages.${system}.bar with the system index already peeled off by perInput.
┌──────────────────────────────┐
perSystem ───▶│ transposition (attr ⇄ sys) │───▶ flake outputs
└──────────────────────────────┘
│
▼
perInput ──▶ inputs' / self'
So a flake-parts module that wants to add a new per-system output does not write forAllSystems — it declares a perSystem option and a transposition, and both directions come for free.
Getting Started
From scratch
shell
nix flake init -t github:hercules-ci/flake-parts
Other templates: #package (a flake around one package), #multi-module (split across files), #unfree.
Adding it to an existing flake
Add the input, then slide mkFlake between your outputs function head and its body:
nix
{
inputs = {
flake-parts.url = "github:hercules-ci/flake-parts";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs = inputs@{ flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } (top@{ config, withSystem, moduleWithSystem, ... }: {
imports = [
# Third-party flake modules, e.g.
# inputs.treefmt-nix.flakeModule
./hello/flake-module.nix
];
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ];
perSystem = { config, self', inputs', pkgs, system, ... }: {
packages.hello = pkgs.callPackage ./hello/package.nix { };
packages.default = config.packages.hello;
};
flake = {
# Anything that isn't per-system: nixosConfigurations, overlays, lib, …
};
});
}
mkFlake arguments
mkFlake takes two arguments: an attrset of "how to evaluate", and the module itself.
| Argument | Purpose |
|---|---|
inputs | The flake inputs. Pass all the outputs-function arguments (inputs@{ ... }), not just self |
self | Legacy. Derived from inputs.self; passing only this warns, because inputs then isn't usable in imports |
specialArgs | Extra arguments injected into every top-level module. Usable in imports, unlike _module.args |
moduleLocation | Overrides the file path used in error messages. Defaults to self.outPath + "/flake.nix" |
Module Arguments
Modules are functions, and flake-parts passes them arguments. Top-level and perSystem get different sets — mixing them up is the most common beginner error.
Top level
| Argument | Meaning |
|---|---|
config | The merged configuration of the whole flake |
options | Option declarations. "${options.foo}" renders the full option path |
lib | Nixpkgs lib, from flake-parts' nixpkgs-lib input |
self | The flake itself |
inputs | The flake inputs |
getSystem | system -> perSystem config. Returns the config attrset |
withSystem | system -> (perSystemArgs -> a) -> a. Enters a system's scope |
moduleWithSystem | Wraps a module so it can close over perSystem values without a global |
flake-parts-lib | importApply, mkPerSystemOption, mkTransposedPerSystemModule, … |
Inside perSystem
| Argument | Meaning |
|---|---|
system | The system string, e.g. "x86_64-linux" |
pkgs | Defaults to inputs.nixpkgs.legacyPackages.${system}. Override via _module.args.pkgs |
config | This system's configuration |
self' | self, with system pre-selected — self'.packages.hello |
inputs' | inputs, with system pre-selected — inputs'.foo.packages.bar |
The ' is pronounced prime.
self, inputs, getSystem, withSystem and moduleWithSystem are deliberately not available in perSystem. flake-parts installs throwing stubs for them, so instead of attribute missing you get a hand-written error explaining that you probably wanted self'/inputs', or the top@ pattern:
nix
top@{ config, inputs, ... }: {
perSystem = { config, inputs', ... }: {
# in scope: inputs, inputs', config (perSystem), top.config
};
}
Only named arguments exist
The module system uses builtins.functionArgs to decide which arguments to pass, so only explicitly named parameters exist:
nix
# BROKEN — args does not contain module arguments
perSystem = args: {
packages.example = args.pkgs.hello; # error: attribute 'pkgs' missing
};
# Correct
perSystem = { pkgs, self', inputs', ... }: {
packages.example = pkgs.hello;
};
This is not an oversight. Nix evaluates the argument of args@{ foo, ... }: strictly, before the function body — but the module system cannot know the full set of module arguments until it has evaluated the modules, which requires calling them. functionArgs breaks the cycle.
If you genuinely need everything, perSystem (and only perSystem) exposes config.allModuleArgs.
Core Options
systems
The list of systems to enumerate in output sub-attributes. Everything in perSystem is instantiated once per entry.
nix
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ];
The nix-systems ecosystem (inputs.systems) lets consumers override this: systems = import inputs.systems;.
perSystem
A module, evaluated once per system, whose options mirror the flake outputs with the <system> level removed.
nix
perSystem = { config, pkgs, self', ... }: {
packages.hello = pkgs.hello;
packages.default = self'.packages.hello;
checks.hello = config.packages.hello;
devShells.default = pkgs.mkShell { inputsFrom = [ config.packages.hello ]; };
apps.greet.program = "${config.packages.hello}/bin/hello";
formatter = pkgs.nixfmt-rfc-style;
};
Because perSystem is a real module, you can use imports, mkIf, and declare your own options inside it.
The flake option
Everything that is not per-system. It is an RFC42-style freeform submodule: declared options merge properly, undeclared attributes are passed through as-is but may only be defined once.
nix
flake = {
nixosConfigurations.laptop = inputs.nixpkgs.lib.nixosSystem { /* … */ };
overlays.default = final: prev: { };
lib.myHelper = x: x;
templates.default = { path = ./template; description = "…"; };
};
Defining an undeclared attribute in two modules produces a pointed error:
No option has been declared for this flake output attribute, so its definitions can't be merged automatically.
The fix is to load a module that declares it, or declare it yourself.
debug
processedFlake
The value mkFlake actually returns. By default it equals flake. The optional touchup module lets you diverge the two — useful to hide an attribute from nix flake check while keeping it visible to your own modules.
Reaching perSystem From the Top Level
Machine configurations, deployments and overlays are not per-system, but they constantly need per-system values. Three tools, same underlying data:
| From the top level | Returns |
|---|---|
(getSystem "x86_64-linux").packages.hello | The config attrset directly |
withSystem "x86_64-linux" ({ config, ... }: config.packages.hello) | Runs a function in the system's full module-argument scope |
self.packages.x86_64-linux.hello | Via the finished flake outputs |
moduleWithSystem (perSystem@{ config, ... }: nixos@{ ... }: { … }) | A NixOS module closing over perSystem |
withSystem is usually the one you want, because you also get pkgs, inputs' and self', not just config.
nix
{ withSystem, inputs, ... }: {
flake.nixosConfigurations.foo = withSystem "x86_64-linux" ({ config, inputs', ... }:
inputs.nixpkgs.lib.nixosSystem {
specialArgs = { packages = config.packages; inherit inputs inputs'; };
modules = [
./nixos-configuration.nix
({ packages, ... }: {
environment.systemPackages = [ packages.hello ];
})
];
});
}
moduleWithSystem does the same for a module you export, without introducing a global:
nix
{ moduleWithSystem, ... }: {
flake.nixosModules.default = moduleWithSystem (
perSystem@{ config, ... }:
nixos@{ ... }:
{
imports = [ ./nixos-foo.nix ];
services.foo.package = perSystem.config.packages.foo;
}
);
}
Splitting Into Files
This is the headline feature, and it has one non-obvious consequence: the moment you move code into another file, you lose the lexical scope of flake.nix. No more inputs, no more let bindings from the outer file.
A plain flake module still gets the module arguments, which covers most cases:
nix
# ./hello/flake-module.nix
{ self, lib, ... }: {
perSystem = { pkgs, ... }: {
packages.hello = pkgs.hello;
};
flake.nixosModules.hello = { pkgs, ... }: {
environment.systemPackages = [
self.packages.${pkgs.stdenv.hostPlatform.system}.hello
];
};
}
But when the separate file is a NixOS module (a different module system evaluation entirely), the flake-parts arguments are gone. Two solutions:
Factor it out
Write a thin inline module that bridges the two scopes, and turn the missing variable into an option:
nix
# ./nixos-module.nix — no knowledge of the flake at all
{ lib, config, ... }: {
options.services.foo.package = lib.mkOption {
type = lib.types.package;
defaultText = lib.literalMD "`packages.default` from the foo flake";
};
config = { /* … */ };
}
nix
# flake module — supplies the default
{ withSystem, ... }: {
flake.nixosModules.default = { pkgs, ... }: {
imports = [ ./nixos-module.nix ];
services.foo.package =
withSystem pkgs.stdenv.hostPlatform.system ({ config, ... }: config.packages.default);
};
}
Make such options as specific as possible — foo.package, not foo.flake. Most modules need only one or two.
importApply
importApply imports a file containing a function to a module and applies it, letting you pass extra values across the file boundary:
nix
{ flake-parts-lib, self, withSystem, ... }:
let inherit (flake-parts-lib) importApply;
in {
flake.nixosModules.default =
importApply ./nixos-module.nix { localFlake = self; inherit withSystem; };
}
nix
# ./nixos-module.nix
{ localFlake, withSystem }: # the importApply argument
{ lib, config, pkgs, ... }: # the regular NixOS module arguments
{
options.services.foo.package = lib.mkOption {
default = withSystem pkgs.stdenv.hostPlatform.system ({ config, ... }: config.packages.default);
defaultText = lib.literalMD "`packages.default` from the foo flake";
};
}
Unlike specialArgs, this survives being imported into someone else's flake, so it is the right choice for modules you publish.
Dogfooding your own flake module
If you both export a flake module and use it locally, you cannot write imports = [ config.flake.flakeModules.default ] — importing from self could change self, which is infinite recursion. Bind it in a let and reference the binding twice:
nix
outputs = inputs@{ flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } ({ withSystem, flake-parts-lib, ... }:
let
inherit (flake-parts-lib) importApply;
flakeModules.default = importApply ./flake-module.nix { inherit withSystem; };
in {
imports = [ flakeModules.default ];
flake = { inherit flakeModules; };
systems = [ "x86_64-linux" ];
perSystem = { pkgs, ... }: { packages.default = pkgs.hello; };
});
┌─────────┐ ┌───────────────────────────────────┐
│ imports │ │ config.flake.flakeModules.default │
└────┬────┘ └─────────────────┬─────────────────┘
│ ┌─────────────────┘
┌────▼────────▼─────────┐
│ let flakeModules = … │
└──────────┬────────────┘
┌──────────▼───────┐
│ flake-module.nix │
└──────────────────┘
Extending the Flake Schema
The genuinely interesting property of flake-parts is that a module can add new kinds of flake output, not just configure existing ones. This is why the ecosystem modules feel like language extensions rather than libraries.
A one-off attribute
If it is project-specific, just set it. flake is freeform:
nix
flake.myThing = { … };
Declaring a proper option
For anything reusable, declare it — you get documentation, type checking and merging. This is disko's entire flake-parts module:
nix
{ lib, ... }: {
options.flake.diskoConfigurations = lib.mkOption {
type = lib.types.lazyAttrsOf lib.types.raw;
default = { };
description = "Instantiated Disko configurations. Used by `disko` and `disko-install`.";
};
}
That is it. Importing inputs.disko.flakeModules.default makes diskoConfigurations a first-class, mergeable flake output that the disko CLI then discovers.
Per-system outputs
For something that varies by system, use mkTransposedPerSystemModule — it declares the perSystem option, the flake.<name>.<system> output, and the perInput reverse mapping in one go. flake-parts' own bundlers module is exactly this:
nix
{ lib, flake-parts-lib, ... }:
flake-parts-lib.mkTransposedPerSystemModule {
name = "bundlers";
option = lib.mkOption {
type = lib.types.lazyAttrsOf (lib.types.functionTo lib.types.package);
default = { };
description = "Bundlers for `nix bundle`.";
};
file = ./bundlers.nix;
}
For options that live under perSystem without being transposed, use flake-parts-lib.mkPerSystemOption — plain perSystem = { options = …; } works but is invisible to documentation generation.
• Never traverse inputs. Scanning them forces fetching every direct dependency, and recursing makes it exponential. It also assumes a role for inputs the user never agreed to.
• Make no assumptions about which inputs exist. Users bundle inputs into company platform modules.
• Prefer perSystem. That is where users expect to define buildable things.
• Namespace your options. perSystem.treefmt.programs, never perSystem.programs.
• Ship the flake module inside the project's own flake, so users wire up one input instead of two.
Publishing Modules
flakeModules
nix
flake.flakeModules.default = ./flake-module.nix;
Consumers write imports = [ inputs.you.flakeModules.default ];. Single-module flakes should use the name default.
flake.modules.<class>.<name>
The generalised form. The outer attribute names the module class, so one flake can publish NixOS modules, Home Manager modules, flake modules and class-agnostic modules through one uniform interface:
nix
flake.modules = {
nixos = {
noBoot = { config, ... }: { boot.loader.enable = false; };
autoDeploy = ./nixos/auto-deploy.nix;
};
homeManager.shell = ./home/shell.nix;
flake.foo = someFlakeModule;
generic.my-pkgs = { _module.args.my-pkgs = …; }; # loadable anywhere
};
generic declares no class, so those modules can be imported into any module system evaluation.
The Dendritic Pattern
dendritic is a convention built on top of flake.modules, popular enough to be worth knowing by name.
The rule is: every Nix file except entry points is a top-level flake-parts module.
• One file = one feature, across every configuration it applies to — not one file per host or per module class.
• NixOS, Home Manager and nix-darwin modules are stored as option values in the top-level config (flake.modules.nixos.<feature>), merged by name via deferredModule.
• Because paths carry only semantic meaning and never appear in imports, the whole tree can be auto-imported, and files can be renamed or moved freely.
• Sharing values between files is just reading the top-level config.
So modules/monitoring.nix might define a NixOS module, a Home Manager module, a package and a check all at once — everything that "monitoring" means — instead of scattering those four fragments across four directories.
The pattern does not technically require flake-parts (plain lib.evalModules works), but flake-parts is the usual host.
NixOS Integration
flake.nixosConfigurations is an ordinary option; the interesting question is where pkgs comes from.
Let NixOS configure its own Nixpkgs
Simplest. perSystem keeps a separate, unaware pkgs.
nix
{ withSystem, inputs, ... }: {
flake.nixosConfigurations.my-machine = inputs.nixpkgs.lib.nixosSystem {
modules = [
({ pkgs, ... }: {
imports = [ ./configuration.nix ];
nixpkgs.config.allowUnfree = true;
nixpkgs.overlays = [ inputs.foo.overlays.default ];
services.foo.package =
withSystem pkgs.stdenv.hostPlatform.system ({ config, ... }: config.packages.foo);
})
];
};
}
Configure pkgs once in perSystem
One Nixpkgs for devShells, packages and machines. readOnlyPkgs stops anything downstream from silently re-instantiating it.
nix
{ withSystem, inputs, ... }: {
perSystem = { system, ... }: {
_module.args.pkgs = import inputs.nixpkgs {
inherit system;
overlays = [ inputs.foo.overlays.default ];
config.allowUnfree = true;
};
};
flake.nixosConfigurations.my-machine = inputs.nixpkgs.lib.nixosSystem {
modules = [
./configuration.nix
inputs.nixpkgs.nixosModules.readOnlyPkgs
({ config, ... }: {
nixpkgs.pkgs =
withSystem config.nixpkgs.hostPlatform.system ({ pkgs, ... }: pkgs);
})
];
};
}
Debugging
Set debug = true;, then explore in the repl — including options that are internal and never reach the outputs.
shell
nix repl
nix-repl> :lf .
# the perSystem config for the current machine
nix-repl> currentSystem.allModuleArgs.pkgs.stdenv.hostPlatform.system
"x86_64-linux"
# a different system
nix-repl> debug.allSystems.aarch64-linux.packages.hello
# a top level option value
nix-repl> debug.systems
[ "x86_64-linux" "aarch64-darwin" ]
# WHERE was this per-system value defined?
nix-repl> currentSystem.options.treefmt.programs.files
[ "/nix/store/…-source/dev-module.nix, via option perSystem"
"/nix/store/…-source/flake-module.nix" ]
# where was a top level option declared?
nix-repl> debug.options.systems.declarations
[ "/nix/store/…-source/modules/perSystem.nix" ]
The .files / .declarations trick is the fastest way to answer "which module set this?" in a flake with a dozen imports.
Ecosystem
Roughly forty flake modules are published and documented at flake.parts. Importing one is always the same shape:
nix
imports = [ inputs.treefmt-nix.flakeModule ];
What makes them worth the trouble is that most do not merely set packages or devShells — they declare new option paths and new flake outputs, so your flake grows a vocabulary for the domain.
| Module | Adds | Effect |
|---|---|---|
| treefmt-nix | perSystem.treefmt.programs.* | Wires up formatter.<system> for nix fmt and a checks.treefmt that fails on unformatted code |
| git-hooks.nix | perSystem.pre-commit.settings | Generates a pre-commit config, a checks entry, and a devShell fragment with the hook tools |
| devshell | perSystem.devshells.<name> | Declarative env / commands / packages, rendered into devShells.<name> with a menu |
| devenv | perSystem.devenv.shells.<name> | Full dev environments — services, languages, processes — rendered into devShells |
| process-compose-flake | perSystem.process-compose.<name> | Declares processes, deps, health probes; produces a runnable package (process-compose) |
| disko | flake.diskoConfigurations | A whole new top-level output the disko CLI discovers |
| home-manager | flake.homeConfigurations, flake.homeModules | Mergeable options instead of hand-rolled attrsets |
| terranix | perSystem.terranix.terranixConfigurations.<name> | Per-config result.{terraformConfiguration,scripts,devShell,app}, and a devShell per configuration |
| nix-topology | perSystem.topology.* → flake.topology | Renders a network/infrastructure diagram from your nixosConfigurations |
| nix-oci | perSystem.oci.containers.<name> | Declarative OCI images: hardening, SBOM, cosign signing, CVE scans — auto-exposed as packages, apps and checks |
| files | perSystem.files.file."<path>" | Generates repo files (READMEs, CI workflows) from Nix, plus a check that the committed copies are in sync |
Others worth knowing: haskell-flake, rust-flake, nix-cargo-integration, pkgs-by-name-for-flake-parts, hercules-ci-effects, nix-unit, ez-configs, easy-hosts, mission-control, agenix-shell, actions.nix.
What that looks like in practice
nix
{
imports = [
inputs.treefmt-nix.flakeModule
inputs.git-hooks-nix.flakeModule
inputs.process-compose-flake.flakeModule
];
systems = [ "x86_64-linux" "aarch64-darwin" ];
perSystem = { config, pkgs, ... }: {
treefmt.programs = {
nixfmt.enable = true;
rustfmt.enable = true;
};
pre-commit.settings.hooks.treefmt.enable = true;
process-compose.dev.settings.processes = {
db.command = "${pkgs.postgresql}/bin/postgres -D ./data";
api = {
command = "cargo watch -x run";
depends_on.db.condition = "process_healthy";
};
};
devShells.default = pkgs.mkShell {
shellHook = config.pre-commit.installationScript;
inputsFrom = [ config.treefmt.build.devShell ];
};
};
}
Three imports produce nix fmt, a formatting check, working git hooks, and nix run .#dev bringing up the stack — with no glue code.
Optional modules shipped with flake-parts
Not enabled by default; import from inputs.flake-parts.flakeModules.<name>.
| Module | Purpose |
|---|---|
easyOverlay | Derives overlays.default from perSystem.overlayAttrs, adding a final module argument |
partitions | Splits the flake into separate evaluations so nix build .#pkg never fetches your dev-only inputs |
flakeModules | Declares the flake.flakeModules option |
modules | Declares the generalised flake.modules.<class> option |
bundlers | perSystem.bundlers for nix bundle |
touchup | Controls which attributes reach processedFlake; hide outputs without hiding them from your own modules |
flake-parts uses partitions on itself: its checks, devShells and tests come from a separate ./dev flake, so importing flake-parts never fetches its development inputs.
Gotchas
• args@{ ... } does not capture module arguments. Name every parameter you use; see Only named arguments exist.
• self and inputs do not exist in perSystem. Use self' / inputs', or bind the top level with top@{ inputs, ... }.
• inputs' on a non-flake input throws. If you set flake = false on an input, it has no per-system attributes to select.
• Undeclared flake.* attributes cannot be merged. Two modules defining flake.foo is an error unless an option declares it.
• Options declared via config.perSystem are invisible to docs generation. Use flake-parts-lib.mkPerSystemOption.
• mkSubmoduleOptions, mkDeferredModuleType and deferredModuleWith are deprecated (January 2026). Declare options.flake.<name> directly, and use Nixpkgs types.deferredModule. Plenty of blog posts still recommend the old forms.
• Don't override lib. Select a version with flake follows instead, so you don't impose odd constraints on modules you import.
• Adding an input to use one flake module fetches that whole flake. Use follows to keep nixpkgs singular, and consider partitions for dev-only inputs.