Nix Flakes
Prerequisites
- Nix Language — attribute sets, functions, lazy evaluation
- Nix Store and Derivations — what the store is, what a derivation is, the evaluation/build split
Intuition
Before flakes, a Nix project’s dependencies were implicit and mutable. The system relied on channels — mutable pointers to a nixpkgs revision, set per-user or per-system via nix-channel --update. Two people building the same default.nix on the same day could get different results because their channels pointed to different commits. There was no lockfile, no pinning, no way to say “this project uses exactly this version of nixpkgs.”
Flakes replace this with an explicit contract:
inputsdeclares every external dependency by URLflake.lockpins each input to an exact content-verified snapshotoutputsis a pure function from inputs to build artifacts — no ambient state
The result: given the same flake.nix + flake.lock, you get bit-for-bit identical builds regardless of what machine you’re on or what channels it has configured.
Flakes are still "experimental"
Despite being the dominant way to write Nix configurations since ~2021, flakes are gated behind
experimental-features = nix-command flakesinnix.conf. The API is stable in practice but technically subject to change. Every serious NixOS configuration enables this flag.
The two commands that consume flakes
Before understanding the schema, it helps to know what actually uses a flake — because the output structure only makes sense once you know which command reads which attribute.
nix build .#somePackage
A pure build command:
$ nix build .#hello
$ ls -la result
result -> /nix/store/abc123-hello-2.12.1What happens:
- Reads
flake.nixin the current directory (the.) - Evaluates the
outputsfunction — runs the Nix interpreter on your expressions, producing a.drvfile (a build plan) in the store. This is pure computation: no compilers run, no packages are fetched yet. - Navigates to
outputs.packages.<your-system>.helloin the evaluated result - Builds the derivation — executes its build instructions in a sandbox (or downloads the result from a binary cache if it was already built elsewhere)
- Creates a
./resultsymlink pointing to the built output in/nix/store/
Nothing on your running system changes. The artifact sits in the Nix store.
nixos-rebuild switch --flake .#myhost
Builds and activates an entire OS:
$ sudo nixos-rebuild switch --flake .#myhost
building the system configuration...
activating the configuration...
setting up /etc...
reloading systemd...What happens:
- Evaluates
outputs.nixosConfigurations.myhost— interprets all your.nixmodules, merges them, produces derivation plans - Builds
.config.system.build.toplevel— the derivation representing your entire OS (kernel, systemd units,/etc, packages, services). Downloads pre-built packages from the binary cache where possible; compiles anything not cached. - Activates — runs the switch-to-configuration script: rewrites
/etcfiles, starts/stops/restarts systemd units, updates the bootloader, registers a new generation in/nix/var/nix/profiles
The critical difference: nix build produces a store path and stops. nixos-rebuild switch produces a store path and then surgically applies it to the running system.
Other variants: nixos-rebuild boot updates the bootloader but doesn’t touch the running system (changes take effect on reboot — useful for risky changes where you want the previous generation as fallback). nixos-rebuild test activates immediately but doesn’t update the bootloader (the change won’t survive a reboot).
The flake.nix structure
Every flake has exactly this shape:
{
description = "My system flake"; # optional, for humans
inputs = {
# Where to fetch dependencies from
};
outputs = { self, nixpkgs, ... }: {
# What this flake produces
};
}Inputs — declaring dependencies
Each input is a URL pointing to another flake (or a non-flake source):
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
home-manager = {
url = "github:nix-community/home-manager/release-25.05";
inputs.nixpkgs.follows = "nixpkgs"; # explained below
};
# Non-flake inputs (raw source trees) need flake = false
fish-plugin = {
url = "github:someone/fish-plugin";
flake = false;
};
};URL schemes: github:owner/repo/ref, git+https://..., path:/local/dir, sourcehut:~user/repo.
Outputs — what the flake produces
The outputs function receives all resolved inputs as arguments and returns an attribute set. Nix defines well-known attributes that specific CLI commands know how to consume:
| Output attribute | Consumed by | What it must be |
|---|---|---|
packages.<sys>.<name> | nix build .#name | A derivation |
devShells.<sys>.<name> | nix develop .#name | A derivation (typically from mkShell) |
apps.<sys>.<name> | nix run .#name | { type = "app"; program = "/nix/store/.../bin/foo"; } |
checks.<sys>.<name> | nix flake check | A derivation (must succeed to pass) |
nixosConfigurations.<name> | nixos-rebuild --flake .#name | Result of nixpkgs.lib.nixosSystem { ... } |
nixosModules.<name> | Other flakes import it | A NixOS module (function) |
overlays.<name> | Other flakes apply it | final: prev: { ... } |
formatter.<sys> | nix fmt | A derivation (the formatter binary) |
templates.<name> | nix flake init -t .#name | { path = ./dir; description = "..."; } |
lib | Other flakes call it | Any Nix value (functions, constants) |
<sys> is a Nix system string: "x86_64-linux", "aarch64-linux", "x86_64-darwin", "aarch64-darwin".
The default name is special: nix build with no #name looks for packages.<sys>.default. Same for devShells and apps.
You can add arbitrary extra attributes — Nix ignores them, but other flakes can read them via inputs.yourFlake.whatever. The rv2ima-dotfiles flake uses lib.vars and lib.nixosModule as custom helpers.
self — the fixed-point self-reference
outputs = { self, nixpkgs, ... }: {
packages.x86_64-linux.myApp = ...;
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
modules = [ self.nixosModules.base ]; # ← referencing own output
};
nixosModules.base = import ./modules/base.nix;
};self is a lazy reference to the flake’s own outputs. It’s how a flake consumes its own modules, packages, or lib functions. The Nix evaluator resolves this through fixed-point semantics — the same mechanism that makes let x = { a = 1; b = x.a + 1; }; in x work. As long as you don’t create a circular value dependency (A needs B’s value which needs A’s value), it evaluates fine.
self.outPath is the path to your flake’s source tree in the Nix store — useful for referencing config files: source = "${self}/dotfiles/gitconfig";.
follows — why it exists and what it does
The duplication problem
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
home-manager.url = "github:nix-community/home-manager/release-25.05";
};home-manager is itself a flake. Its own flake.nix declares inputs.nixpkgs. Without intervention, Nix resolves your nixpkgs and home-manager’s nixpkgs independently — they may pin to different commits. You now have two separate nixpkgs trees in your closure:
- Doubled download and evaluation time
- Two different versions of the same library can coexist, causing type mismatches deep in module evaluation
- Drift worsens every time you run
nix flake update
The fix
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
home-manager = {
url = "github:nix-community/home-manager/release-25.05";
inputs.nixpkgs.follows = "nixpkgs";
};
};This instructs Nix: “When resolving home-manager’s nixpkgs input, don’t use its own URL — use my top-level nixpkgs instead.” One download, one evaluation, no version skew.
How it appears in flake.lock
Without follows, the lockfile has a separate node for home-manager’s nixpkgs:
"home-manager": {
"inputs": { "nixpkgs": "nixpkgs_2" }
},
"nixpkgs_2": {
"locked": { "rev": "ddeeff...", "narHash": "sha256-BBB..." }
}With follows, the separate node disappears — replaced by an array (a path reference from the root):
"home-manager": {
"inputs": { "nixpkgs": ["nixpkgs"] }
}The array ["nixpkgs"] means “follow the path nixpkgs from the root node.” No duplicate node, no duplicate fetch.
When to always use it
Any input that itself depends on nixpkgs should follow your nixpkgs. The common ones:
home-manager.inputs.nixpkgs.follows = "nixpkgs";
sops-nix.inputs.nixpkgs.follows = "nixpkgs";
disko.inputs.nixpkgs.follows = "nixpkgs";The lockfile — flake.lock
What it stores
A JSON file that pins every input in the dependency graph to an exact, content-verified snapshot:
{
"version": 7,
"nodes": {
"root": {
"inputs": { "nixpkgs": "nixpkgs", "home-manager": "home-manager" }
},
"nixpkgs": {
"original": {
"type": "github", "owner": "NixOS", "repo": "nixpkgs",
"ref": "nixos-25.05"
},
"locked": {
"type": "github", "owner": "NixOS", "repo": "nixpkgs",
"rev": "a3a3dda3bacf61e8a39258a0ed9c924eeca8e293",
"narHash": "sha256-rjoowCx/qhuxCC5zvKeXWTqNGHDw8HHodNJvyRDm4U=",
"lastModified": 1715000000
}
}
}
}Each node has:
original— what you wrote inflake.nix(the intent, e.g. “nixos-25.05 branch”)locked— the exact resolved snapshot (the fact: a specific commit + content hash)
Why both rev and narHash?
NAR (Nix ARchive) is Nix’s canonical serialization format for file trees — deterministic, no timestamps, fixed ordering. The narHash is the SHA-256 of the NAR-serialized source tree.
rev(git commit SHA) tells Nix where to looknarHashtells Nix what content to expect
If someone force-pushes a different tree to the same commit (or a mirror serves corrupted data), the narHash won’t match and Nix rejects the fetch. This is the tamper-proofing layer.
Managing the lockfile
| Command | What it does |
|---|---|
nix flake lock | Adds lock entries for new inputs. Does NOT update existing ones. Safe after adding a new input. |
nix flake update | Re-resolves all inputs against their URLs. Rewrites the entire lockfile. |
nix flake update home-manager | Re-resolves only home-manager. Everything else stays pinned. |
The workflow: commit flake.lock to version control. Run nix flake update when you want newer versions of your dependencies. Review the diff — it shows which commits changed.
One flake = one lockfile
There is no mechanism to share a lockfile between separate flakes. But a monorepo where one flake.nix defines multiple nixosConfigurations (common pattern for managing a fleet) naturally shares its single lockfile — all machines use the same pinned dependencies.
The evaluation sequence in detail
What happens when you run nixos-rebuild switch --flake .#myhost:
1. Locate the flake. The . means “current directory.” Nix looks for flake.nix there.
2. Copy the source into the Nix store. Your flake directory is NAR-serialized and stored. Critical detail: only git-tracked files are included. If you wrote a new .nix file but didn’t git add it, Nix cannot see it. This is the single most common “why is my file not found?” confusion for beginners.
3. Read and verify the lockfile. If flake.lock is missing or doesn’t cover a declared input, Nix errors (unless you pass --recreate-lock-file).
4. Fetch all inputs. Each input is downloaded (if not already cached locally) and verified against its narHash.
5. Call the outputs function. All fetched inputs are passed as arguments. Evaluation is lazy — only attributes actually needed are computed.
6. Navigate to nixosConfigurations.myhost. The #myhost part of the flake reference selects this attribute.
7. Build .config.system.build.toplevel. This is the derivation representing the complete OS — every package, every config file, every systemd unit.
8. Activate. The switch-to-configuration script:
- Diffs the new generation against the current one
- Writes new symlinks in
/etc - Runs
systemctl daemon-reload - Starts new services, stops removed ones, restarts changed ones
- Updates the bootloader entry
- Registers the generation in
/nix/var/nix/profiles/system
Untracked files are invisible to flakes
Step 2 copies only what
git ls-filesreports. A common mistake: createmodules/new-thing.nix, reference it in your config withimports = [ ./modules/new-thing.nix ], run rebuild, get “file not found.” Fix:git add modules/new-thing.nix(you don’t need to commit — staging is enough).
Practical workflow
# Initial setup — enable flakes
echo "experimental-features = nix-command flakes" >> /etc/nix/nix.conf
# Create a new flake
nix flake init # creates a minimal flake.nix
# Check what a flake exposes
nix flake show github:nix-community/home-manager
# Build your NixOS config without activating (safe to test)
nixos-rebuild build --flake .#myhost
# Apply it
sudo nixos-rebuild switch --flake .#myhost
# Update one dependency
nix flake update home-manager
# Update everything
nix flake update
# Enter a dev shell defined in the flake
nix develop
# Run a quick check
nix flake checkSee also
- flake-parts — optional tool that applies the NixOS module system to flake outputs, eliminating per-system repetition