Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

Understand and use Nix flakes

By updated

Improve this page

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 init and 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.nix project 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 in flake.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:

Terminal window
grep experimental-features ~/.config/nix/nix.conf 2>/dev/null || \
grep experimental-features /etc/nix/nix.conf 2>/dev/null
experimental-features = nix-command flakes

If the line is missing:

Terminal window
mkdir -p ~/.config/nix
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf

Initialise 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.

  1. Create a directory and initialise Git:

    Terminal window
    mkdir my-project && cd my-project
    git init
  2. Generate the basic flake:

    Terminal window
    nix flake init
    unpacking 'github:NixOS/templates/3348e5b68b7a53c1ef9d20605c3cad169f65899a' into the Git cache...
    wrote: "/home/lab/my-project/flake.nix"
  3. 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;
    };
    }
  4. Check that the file is tracked: in a Git repository, nix flake init adds it to the index by itself.

    Terminal window
    git status --short
    A 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
{ };
}
KeyRoleMandatory
descriptionText shown by nix flake metadataNo (recommended)
inputsExternal dependencies with their URLNo (a flake can have none)
outputsFunction 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

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 used
  • narHash: the cryptographic hash of the content, guarantees integrity
  • original: the source as declared in inputs

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.

OutputAssociated commandRole
devShells.<system>.defaultnix developDevelopment environment
packages.<system>.defaultnix buildPackage to build
apps.<system>.defaultnix runApplication to run
overlays.defaultnoneModification of nixpkgs
nixosConfigurations.<name>nixos-rebuildComplete NixOS configuration
nixosModules.defaultnoneReusable 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>.

Terminal window
nix flake show
git+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.

Terminal window
nix flake metadata
Result (excerpt)
Resolved URL: git+file:///home/lab/demo-flake-devshell
Description: Python project with devShell and package
Path: /nix/store/mjaigd12ywfzjp4nif9qvgi2imix43mr-source
Inputs:
└───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.

Terminal window
nix develop
Python environment ready

The shellHook runs and all the dependencies are available, with the Python version fixed by the lock:

Terminal window
python3 --version && echo $PROJECT_NAME
Python 3.12.14
my-project

Build 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.

Terminal window
nix build
building '/nix/store/wf1g9c8n4294f1g6piplsnfica51q0g5-hello-projet.drv'...

The result is a ./result symbolic link pointing to the store:

Terminal window
ls -la result
result -> /nix/store/cwhh0m69l61n0cp3z1bva73njw6fslsz-hello-projet
Terminal window
./result/bin/hello-projet
Welcome 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.

Terminal window
nix run
Welcome 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.

Terminal window
nix flake check
evaluating flake...
checking flake output 'devShells'...
checking derivation devShells.x86_64-linux.default...
derivation evaluated to /nix/store/kcv6qakxg5nlvd133hni7i20sxjg8347-nix-shell.drv
checking flake output 'packages'...
checking derivation packages.x86_64-linux.default...
derivation evaluated to /nix/store/wf1g9c8n4294f1g6piplsnfica51q0g5-hello-projet.drv
checking 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:

Terminal window
nix flake update

To update a single input:

Terminal window
nix flake update nixpkgs

After 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

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.

Aspectshell.nixflake.nix
nixpkgs source<nixpkgs> (local channel)github:nixos/nixpkgs/nixos-24.11 (locked)
Versions obtainedVary by machineIdentical everywhere
Entry commandnix-shellnix develop
LockingNone (or manual fetchTarball)Automatic (flake.lock)

The result with nixos-24.11 locked:

Terminal window
nix develop --command bash -c "python3 --version && git --version"
Python 3.12.8
git version 2.47.2

Whereas 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:

Terminal window
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.

  1. Install direnv and nix-direnv:

    Terminal window
    nix profile add nixpkgs#direnv nixpkgs#nix-direnv

    On an installation older than Nix 2.30, the subcommand is called nix profile install. It is still accepted as a deprecated alias.

  2. Configure your shell (add to ~/.bashrc or ~/.zshrc):

    Terminal window
    eval "$(direnv hook bash)" # or hook zsh
    source $HOME/.nix-profile/share/nix-direnv/direnvrc
  3. Create an .envrc file at the root of the project:

    Terminal window
    echo "use flake" > .envrc
    direnv allow
  4. On every cd into the project, the environment loads automatically.

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.

AspectChannels (classic)Flakes
LockingManual (fetchTarball + hash)Automatic (flake.lock)
ReproducibilityHard to guaranteeGuaranteed by default
StructureFree (per-project convention)Standardised (inputs/outputs)
CompositionComplex (manual imports)Native (follows, multiple inputs)
EvaluationImpure (file system access)Pure (hermetic)
Commandsnix-env, nix-shell, nix-buildnix develop, nix build, nix run
Updatesnix-channel --update (global)nix flake update (per project)
CI/CDManual configurationPredictable 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.

SymptomLikely causeSolution
error: experimental Nix feature 'flakes' is disabledFlakes not enabledAdd 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 repositorygit add flake.nix
error: Path '<file>' in the repository "…" is not tracked by Git. (during an import)Imported file not trackedgit add <file>
warning: Git tree '…' is dirtyUncommitted changesgit add -A && git commit (or ignore the warning)
Build fails in CI but not locallyflake.lock missing or out of dategit add flake.lock && git commit
--update-input prints a warningDeprecated syntaxUse 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; follows avoids duplication
  • Outputs produce devShells, packages, apps, with a standardised convention
  • nix develop replaces nix-shell, nix build replaces nix-build, nix run runs directly
  • nix flake check validates the structure, essential in CI
  • nix flake update updates the inputs; the flake.lock diff 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

6 questions
6 min
70% to pass

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

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn