Flakes are the native reproducibility mechanism of Nix. A flake.nix file declares versioned dependencies (the inputs), and flake.lock locks their exact revisions. The result: every team member, every CI server and every production machine gets exactly the same environment. This guide covers the complete structure of a flake, the management commands, and migrating from shell.nix. The outputs were captured on Nix 2.35.2 with the nixos-unstable of 29 September 2026.
What you will learn
- Create a flake with
nix flake initand understand its structure - Declare inputs (nixpkgs, flake-utils) and lock them with
flake.lock - Define outputs: devShells, packages, apps
- Build, run and inspect a flake
- Convert a classic
shell.nixproject to flakes - Support several architectures with
flake-utils
In what context?
Classic Nix channels, described in Import, factoring and pinning, raise a fundamental problem: each machine can point to a different revision of nixpkgs. Even with fetchTarball and a hash, pinning stays manual and fragile.
Flakes solve this problem in daily practice:
- Your colleague clones your repository and gets the same versions of Python, Node.js and every tool, automatically
- The CI builds with the same dependencies as your workstation, without any extra configuration
- You update nixpkgs with a single
nix flake update, with a reviewable diff inflake.lock - You compose several sources (stable nixpkgs, a private overlay, a tool from GitHub) in a single declarative file
Enable flakes in your Nix configuration
Flakes are an experimental feature that has to be enabled explicitly. If you followed the installation guide, it is already done.
Check your configuration:
grep experimental-features ~/.config/nix/nix.conf 2>/dev/null || \grep experimental-features /etc/nix/nix.conf 2>/dev/nullexperimental-features = nix-command flakesIf the line is missing:
mkdir -p ~/.config/nixecho "experimental-features = nix-command flakes" >> ~/.config/nix/nix.confInitialise a flake with nix flake init
The nix flake init command generates a basic flake.nix in the current directory. A Git repository is not mandatory, but it is recommended: in a repository, Nix only sees tracked files, and flake.lock is versioned with the code.
-
Create a directory and initialise Git:
Terminal window mkdir my-project && cd my-projectgit init -
Generate the basic flake:
Terminal window nix flake initunpacking 'github:NixOS/templates/3348e5b68b7a53c1ef9d20605c3cad169f65899a' into the Git cache...wrote: "/home/lab/my-project/flake.nix" -
Look at the generated file:
Terminal window cat flake.nix{description = "A very basic flake";inputs = {nixpkgs.url = "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.zst";};outputs = inputs: {packages = builtins.mapAttrs (system: pkgs: {hello = pkgs.hello;default = inputs.self.packages.${system}.hello;}) inputs.nixpkgs.legacyPackages;};} -
Check that the file is tracked: in a Git repository,
nix flake initadds it to the index by itself.Terminal window git status --shortA flake.nix
The default template has changed across Nix versions: it reads nixpkgs from an archive on channels.nixos.org and generates the packages for every platform of legacyPackages through builtins.mapAttrs. The examples of this guide keep the explicit form, github:nixos/nixpkgs/... and a hard-coded system, which is easier to read while learning.
Anatomy of the flake.nix file
A flake.nix is a Nix expression that returns an attribute set with three keys, only one of which is really mandatory:
{ description = "Description du projet"; # free text
inputs = { # The dependencies and their source };
outputs = { self, ... }: # What the flake produces { };}| Key | Role | Mandatory |
|---|---|---|
description | Text shown by nix flake metadata | No (recommended) |
inputs | External dependencies with their URL | No (a flake can have none) |
outputs | Function that returns packages, shells, apps… | Yes |
The outputs key is a function that receives an attrset: self (the flake itself) and one attribute per declared input. It is often destructured ({ self, nixpkgs }:), or received as a whole (inputs:), as the default template does.
Declare your dependencies in inputs
Inputs declare the sources your flake depends on. The most common one is nixpkgs, and each input ends up locked in flake.lock.
Source types
Each input is written as a flake URL, a format specific to Nix that encodes the protocol, the location and the wanted revision at the same time. The four tabs below cover the cases you will meet: a GitHub repository (the common case), any Git repository for internal code, a local path for a monorepo, and an archive for code that is not versioned in Git. Above all, remember the third segment of the GitHub URL: it accepts a branch, a tag or a commit, and it determines what nix flake update will fetch.
inputs = { # Specific branch nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
# Stable branch nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
# A third-party tool flake-utils.url = "github:numtide/flake-utils";};Format: github:owner/repository/branch
inputs = { mon-outil.url = "git+https://gitlab.example.com/equipe/outil.git?ref=main";};Format: git+URL?ref=branch
inputs = { mon-lib.url = "path:./lib";};Useful for a monorepo containing several flakes.
inputs = { source.url = "https://example.com/archive.tar.gz"; source.flake = false;};flake = false indicates that the source is not itself a flake.
Deduplicate transitive inputs with follows
Your inputs have transitive inputs of their own. home-manager declares its own nixpkgs, flake-utils declares a systems input. Without intervention, Nix locks two different revisions of nixpkgs in your flake.lock: yours and that of home-manager. You end up with two package sets side by side in the store, and inconsistencies that are hard to diagnose when the two versions diverge. The follows directive redirects a transitive input to an input you control:
inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
home-manager.url = "github:nix-community/home-manager"; # home-manager will use OUR nixpkgs instead of its own home-manager.inputs.nixpkgs.follows = "nixpkgs";};The same mechanism is used to restrict the list of architectures. flake-utils does not depend on nixpkgs, its only input is called systems and points by default to nix-systems/default (the four standard platforms). By making it follow an input of yours, you limit the generated outputs to what you care about:
inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils";
systems.url = "github:nix-systems/x86_64-linux"; flake-utils.inputs.systems.follows = "systems";};Without follows, each input downloads its own copy of its dependencies. With follows, you deduplicate, you shrink the flake.lock and you guarantee that a nixpkgs update applies everywhere at once.
The flake.lock file: guaranteed reproducibility
The first evaluation of a flake automatically generates a flake.lock. This JSON file records the exact revision of each input:
{ "nodes": { "nixpkgs": { "locked": { "lastModified": 1790689126, "narHash": "sha256-ilerN1WLSvF+HMjziC/Wv99J5y02maDH+6hZPwsORKg=", "owner": "nixos", "repo": "nixpkgs", "rev": "b4fd65b198c599cbe814fcb9f42d25d021595ec9", "type": "github" }, "original": { "owner": "nixos", "ref": "nixos-unstable", "repo": "nixpkgs", "type": "github" } }, "root": { "inputs": { "nixpkgs": "nixpkgs" } } }, "root": "root", "version": 7}Three key pieces of information:
rev: the exact nixpkgs commit usednarHash: the cryptographic hash of the content, guarantees integrityoriginal: the source as declared ininputs
Define your outputs: devShells, packages, apps
The outputs function returns an attribute set whose keys follow a standardised convention. Here is a complete flake with the three most common outputs:
{ description = "Python project with devShell and package";
inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; };
outputs = { self, nixpkgs }: let system = "x86_64-linux"; pkgs = nixpkgs.legacyPackages.${system}; python = pkgs.python312; in { # Development environment devShells.${system}.default = pkgs.mkShell { packages = [ (python.withPackages (ps: [ ps.requests ps.pytest ])) pkgs.curl pkgs.jq ]; shellHook = '' echo "Python environment ready" export PROJECT_NAME=my-project ''; };
# Buildable package packages.${system}.default = pkgs.writeShellScriptBin "hello-projet" '' echo "Welcome to my-project!" '';
# Runnable application apps.${system}.default = { type = "app"; program = "${self.packages.${system}.default}/bin/hello-projet"; }; };}Standard output types
Output names are not free: the nix commands look for precise attribute paths. nix develop without an argument looks at devShells.<system>.default, nix build looks at packages.<system>.default. A devShell filed under packages will never be found. Read the table through its middle column: it shows which command triggers which output. The two rows marked "none" (overlays and nixosModules) are outputs consumed by other flakes, never launched directly.
| Output | Associated command | Role |
|---|---|---|
devShells.<system>.default | nix develop | Development environment |
packages.<system>.default | nix build | Package to build |
apps.<system>.default | nix run | Application to run |
overlays.default | none | Modification of nixpkgs |
nixosConfigurations.<name> | nixos-rebuild | Complete NixOS configuration |
nixosModules.default | none | Reusable NixOS module |
Build, run and inspect a flake
Once the flake.nix is written, five commands cover the whole cycle: inspect what it exposes, enter its environment, build, run, then validate everything before the CI.
Visualise the structure with nix flake show
nix flake show evaluates the outputs and prints their tree. It is the first command to run on a flake you do not know: it answers "what does this repository expose, and under which name". The leaves of the tree carry the type of each output (app, package, development environment), and this attribute name is exactly what you will then pass to nix build .#<name>.
nix flake showgit+file:///home/lab/demo-flake-devshell├───apps│ └───x86_64-linux│ └───default: app: no description├───devShells│ └───x86_64-linux│ └───default: development environment 'nix-shell'└───packages └───x86_64-linux └───default: package 'hello-projet'Read the metadata with nix flake metadata
Where nix flake show describes the outputs, nix flake metadata describes the inputs. The line to watch is the Inputs tree: it shows the revision actually locked and its date, not the requested branch. It is the fastest way to answer "how long since this project last updated nixpkgs", without opening the flake.lock. The Path field gives the store path where the copy of the repository was imported.
nix flake metadataResolved URL: git+file:///home/lab/demo-flake-devshellDescription: Python project with devShell and packagePath: /nix/store/mjaigd12ywfzjp4nif9qvgi2imix43mr-sourceInputs:└───nixpkgs: github:nixos/nixpkgs/b4fd65b198c599cbe814fcb9f42d25d021595ec9?narHash=sha256-ilerN1WLSvF%2BHMjziC/Wv99J5y02maDH%2B6hZPwsORKg%3D (2026-09-29 13:38:46)Enter the development environment
nix develop builds all the dependencies of the devShell then opens a sub-shell whose PATH points to the store. The first launch can take several minutes if things have to be downloaded or compiled; the following ones are immediate thanks to the store cache. You stay in your usual shell, only the environment variables change: leave with exit or Ctrl+D and your original PATH comes back.
nix developPython environment readyThe shellHook runs and all the dependencies are available, with the Python version fixed by the lock:
python3 --version && echo $PROJECT_NAMEPython 3.12.14my-projectBuild a package
nix build with no argument builds packages.<system>.default and prints the
derivation being built. With -L (--print-build-logs), the build logs
scroll instead of being summarised, which helps diagnostics.
nix buildbuilding '/nix/store/wf1g9c8n4294f1g6piplsnfica51q0g5-hello-projet.drv'...The result is a ./result symbolic link pointing to the store:
ls -la resultresult -> /nix/store/cwhh0m69l61n0cp3z1bva73njw6fslsz-hello-projet./result/bin/hello-projetWelcome to my-project!Run directly with nix run
nix run with no argument launches apps.<system>.default, here the script
packaged just above, without going through the result link.
nix runWelcome to my-project!nix run combines nix build + running the default binary in a single command, without leaving a result link in the current directory.
Check the validity of the flake
nix flake check evaluates every output, checks that it follows the expected
schema, and builds the derivations of the checks. Warnings point out a
deviation without making the command fail.
nix flake checkevaluating flake...checking flake output 'devShells'...checking derivation devShells.x86_64-linux.default...derivation evaluated to /nix/store/kcv6qakxg5nlvd133hni7i20sxjg8347-nix-shell.drvchecking flake output 'packages'...checking derivation packages.x86_64-linux.default...derivation evaluated to /nix/store/wf1g9c8n4294f1g6piplsnfica51q0g5-hello-projet.drvchecking flake output 'apps'...checking app 'apps.x86_64-linux.default'...warning: app 'apps.x86_64-linux.default' lacks attribute 'meta'all checks passed!nix flake check evaluates every derivation and checks the compliance of the outputs. Essential in a CI pipeline. The warning about meta is harmless: it invites you to describe the application, for example with meta.description.
Update the dependencies
To update all the inputs:
nix flake updateTo update a single input:
nix flake update nixpkgsAfter the update, check the diff of flake.lock with git diff flake.lock before committing: it is where the revision actually adopted can be read.
Convert an existing shell.nix project to flakes
If you have a classic shell.nix that uses <nixpkgs>, here is how to migrate it to a flake without changing the list of packages.
Before: classic shell.nix
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell { packages = with pkgs; [ python312 python312Packages.requests git curl ];
shellHook = '' echo "Classic dev environment" '';}This file works with nix-shell, but the versions depend on the channel active on each machine.
After: flake.nix
The package list is kept as is, including the with pkgs;. Only three things change: the source of pkgs becomes a locked input instead of <nixpkgs>, the mkShell is filed under the devShells.<system>.default attribute expected by nix develop, and the system is hard-coded. The shellHook keeps the same syntax, which makes the migration mechanical on most projects.
{ description = "Project migrated from shell.nix to flake";
inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-24.11"; };
outputs = { self, nixpkgs }: let system = "x86_64-linux"; pkgs = nixpkgs.legacyPackages.${system}; in { devShells.${system}.default = pkgs.mkShell { packages = with pkgs; [ python312 python312Packages.requests git curl ];
shellHook = '' echo "Dev environment (flake, nixos-24.11)" ''; }; };}The concrete differences
The table sums up what the migration really changes. The Versions obtained row is the whole point of the operation: locking removes the dependency on each workstation's channel.
| Aspect | shell.nix | flake.nix |
|---|---|---|
| nixpkgs source | <nixpkgs> (local channel) | github:nixos/nixpkgs/nixos-24.11 (locked) |
| Versions obtained | Vary by machine | Identical everywhere |
| Entry command | nix-shell | nix develop |
| Locking | None (or manual fetchTarball) | Automatic (flake.lock) |
The result with nixos-24.11 locked:
nix develop --command bash -c "python3 --version && git --version"Python 3.12.8git version 2.47.2Whereas with the nixpkgs-unstable of 29 September 2026, the same packages give Python 3.12.14 and git 2.55.0. The nixos-24.11 branch, end of life, is used here to make the gap visible: a project started today targets the current stable branch, nixos-26.05.
Support several architectures
By default, a flake targets a single system (typically "x86_64-linux"). To support Linux and macOS on x86_64 and ARM, use flake-utils or the genAttrs function of nixpkgs.
With flake-utils
eachDefaultSystem calls your function once per platform and files the
result under the right system: you write devShells.default, it produces
devShells.x86_64-linux.default and its three neighbours.
{ description = "Flake multi-architecture";
inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; };
outputs = { self, nixpkgs, flake-utils }: flake-utils.lib.eachDefaultSystem (system: let pkgs = nixpkgs.legacyPackages.${system}; in { devShells.default = pkgs.mkShell { packages = [ pkgs.jq pkgs.curl ]; };
packages.default = pkgs.hello; } );}eachDefaultSystem automatically iterates over the four standard systems; nix flake show only evaluates the one of the current machine and reports the others as omitted:
nix flake show├───devShells│ ├───aarch64-darwin│ │ └───default omitted (use '--all-systems' to show)│ ├───aarch64-linux│ │ └───default omitted (use '--all-systems' to show)│ ├───x86_64-darwin│ │ └───default omitted (use '--all-systems' to show)│ └───x86_64-linux│ └───default: development environment 'nix-shell'└───packages ├───aarch64-darwin │ └───default omitted (use '--all-systems' to show) ├───aarch64-linux │ └───default omitted (use '--all-systems' to show) ├───x86_64-darwin │ └───default omitted (use '--all-systems' to show) └───x86_64-linux └───default: package 'hello-2.12.3'Without flake-utils
If you prefer to avoid the flake-utils dependency, use genAttrs from nixpkgs: it builds the attrset indexed by system from a list you choose.
outputs = { self, nixpkgs }: let supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" ]; forAllSystems = nixpkgs.lib.genAttrs supportedSystems; pkgsFor = system: nixpkgs.legacyPackages.${system}; in { packages = forAllSystems (system: { default = (pkgsFor system).hello; });
devShells = forAllSystems (system: { default = (pkgsFor system).mkShell { packages = [ (pkgsFor system).hello ]; }; }); };Integrate flakes with direnv
direnv automatically activates the flake environment when you enter the project directory. No more typing nix develop by hand.
-
Install direnv and nix-direnv:
Terminal window nix profile add nixpkgs#direnv nixpkgs#nix-direnvOn an installation older than Nix 2.30, the subcommand is called
nix profile install. It is still accepted as a deprecated alias. -
Configure your shell (add to
~/.bashrcor~/.zshrc):Terminal window eval "$(direnv hook bash)" # or hook zshsource $HOME/.nix-profile/share/nix-direnv/direnvrc -
Create an
.envrcfile at the root of the project:Terminal window echo "use flake" > .envrcdirenv allow -
On every
cdinto the project, the environment loads automatically.
Recommended structure for a project with flakes
For a real project, organise your Nix files in a nix/ subdirectory: the root flake.nix stays short and merely delegates.
Répertoiremy-project/
- flake.nix Main entry point
- flake.lock Locked versions (generated)
- .envrc For direnv (
use flake) Répertoirenix/
Répertoiredevshells/
- default.nix devShell definition
Répertoirepackages/
- default.nix Package definitions
Répertoireoverlays/
- default.nix Custom overlays
Répertoiresrc/ Application source code
- …
The flake.nix then imports these files:
{ description = "Organised project";
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }: let system = "x86_64-linux"; pkgs = nixpkgs.legacyPackages.${system}; in { devShells.${system}.default = import ./nix/devshells/default.nix { inherit pkgs; }; packages.${system}.default = import ./nix/packages/default.nix { inherit pkgs; }; };}Comparing flakes and channels
This table sums up what moving to flakes changes on an existing project. The two rows that weigh most in a team decision are Locking and Evaluation: the first explains why CI and the developer workstation end up diverging with channels, the second why a flake refuses to read a file not tracked by Git. The other rows describe comfort, those two describe failures you will avoid.
| Aspect | Channels (classic) | Flakes |
|---|---|---|
| Locking | Manual (fetchTarball + hash) | Automatic (flake.lock) |
| Reproducibility | Hard to guarantee | Guaranteed by default |
| Structure | Free (per-project convention) | Standardised (inputs/outputs) |
| Composition | Complex (manual imports) | Native (follows, multiple inputs) |
| Evaluation | Impure (file system access) | Pure (hermetic) |
| Commands | nix-env, nix-shell, nix-build | nix develop, nix build, nix run |
| Updates | nix-channel --update (global) | nix flake update (per project) |
| CI/CD | Manual configuration | Predictable structure |
Troubleshooting
Three of the six errors below come from pure evaluation in a Git repository: a flake there only sees the files tracked by Git, and a file created but never added to the index does not exist for Nix, even though it is on disk. Nix 2.35.2 says so explicitly and suggests the git add command to run. The Git tree is dirty warning is the exception: it is informational, the build still succeeds, it simply signals that the result matches no commit and is therefore not reproducible elsewhere.
| Symptom | Likely cause | Solution |
|---|---|---|
error: experimental Nix feature 'flakes' is disabled | Flakes not enabled | Add experimental-features = nix-command flakes to ~/.config/nix/nix.conf |
error: Path 'flake.nix' in the repository "…" is not tracked by Git. | flake.nix never added to the repository | git add flake.nix |
error: Path '<file>' in the repository "…" is not tracked by Git. (during an import) | Imported file not tracked | git add <file> |
warning: Git tree '…' is dirty | Uncommitted changes | git add -A && git commit (or ignore the warning) |
| Build fails in CI but not locally | flake.lock missing or out of date | git add flake.lock && git commit |
--update-input prints a warning | Deprecated syntax | Use nix flake update <input> |
Key points
- A flake =
flake.nix(declaration) +flake.lock(locking), preferably versioned together in a Git repository - Inputs declare the sources;
followsavoids duplication - Outputs produce devShells, packages, apps, with a standardised convention
nix developreplacesnix-shell,nix buildreplacesnix-build,nix runruns directlynix flake checkvalidates the structure, essential in CInix flake updateupdates the inputs; theflake.lockdiff serves as a review- flake-utils simplifies multi-architecture support
- direnv + nix-direnv activate the environment automatically
Knowledge check
Check that the essentials of this guide are acquired. The questions only cover what has just been explained here.
Knowledge check
Check what you have learned with this interactive quiz
How it works
- The timer starts when you click Start
- Multiple choice, true/false and short answer questions
- You can move between questions
- Detailed results are shown at the end
Starts the quiz and the timer
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: DevOps use cases with Nix: standardise environments and make CI/CD reliable
A new full quiz with random questions
Work again only on the questions you missed