Nix Flakes

Prerequisites

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:

  • inputs declares every external dependency by URL
  • flake.lock pins each input to an exact content-verified snapshot
  • outputs is 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 flakes in nix.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.1

What happens:

  1. Reads flake.nix in the current directory (the .)
  2. Evaluates the outputs function — runs the Nix interpreter on your expressions, producing a .drv file (a build plan) in the store. This is pure computation: no compilers run, no packages are fetched yet.
  3. Navigates to outputs.packages.<your-system>.hello in the evaluated result
  4. Builds the derivation — executes its build instructions in a sandbox (or downloads the result from a binary cache if it was already built elsewhere)
  5. Creates a ./result symlink 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:

  1. Evaluates outputs.nixosConfigurations.myhost — interprets all your .nix modules, merges them, produces derivation plans
  2. 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.
  3. Activates — runs the switch-to-configuration script: rewrites /etc files, 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 attributeConsumed byWhat it must be
packages.<sys>.<name>nix build .#nameA derivation
devShells.<sys>.<name>nix develop .#nameA derivation (typically from mkShell)
apps.<sys>.<name>nix run .#name{ type = "app"; program = "/nix/store/.../bin/foo"; }
checks.<sys>.<name>nix flake checkA derivation (must succeed to pass)
nixosConfigurations.<name>nixos-rebuild --flake .#nameResult of nixpkgs.lib.nixosSystem { ... }
nixosModules.<name>Other flakes import itA NixOS module (function)
overlays.<name>Other flakes apply itfinal: prev: { ... }
formatter.<sys>nix fmtA derivation (the formatter binary)
templates.<name>nix flake init -t .#name{ path = ./dir; description = "..."; }
libOther flakes call itAny 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 in flake.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 look
  • narHash tells 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

CommandWhat it does
nix flake lockAdds lock entries for new inputs. Does NOT update existing ones. Safe after adding a new input.
nix flake updateRe-resolves all inputs against their URLs. Rewrites the entire lockfile.
nix flake update home-managerRe-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-files reports. A common mistake: create modules/new-thing.nix, reference it in your config with imports = [ ./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 check

See also

  • flake-parts — optional tool that applies the NixOS module system to flake outputs, eliminating per-system repetition