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

Create reproducible development environments with Nix

By updated

Improve this page

A shell.nix file at the root of your project, a nix-shell (or nix develop), and all your tooling is there, same version, same configuration, for every member of the team. No README to reread, no version conflicts, no "it works on my machine". This guide shows you how to build these environments for Python, Node.js, Rust and mixed projects. The outputs were captured on Nix 2.35.2 with the nixpkgs-unstable of 29 September 2026.

What you will learn

  • Understand the role of mkShell and the structure of a shell.nix
  • Create a Python environment with libraries through withPackages
  • Create a Node.js environment and a Rust environment
  • Pass environment variables and a shellHook to the shell
  • Use nix develop with a flake as the modern alternative
  • Load the environment automatically with direnv
  • Compare nix-shell / nix develop with venv, nvm and Docker

In what context?

Every project has its dependencies: a Python version, libraries, CLI tools, a Node.js version… Day to day, you run into these situations:

  • a new developer joins the project and spends half a day installing the right tools,
  • two projects require different versions of Node.js or Python,
  • a CI script fails because the jq version differs from the local workstation,
  • the README says "install gcc, cmake, libssl-dev, python3.12…" but nobody keeps it up to date.

With Nix, a single file (shell.nix or flake.nix) describes the environment reproducibly. It is versioned with the code and guarantees that everyone works with the same tooling.

mkShell: the fundamental building block

pkgs.mkShell is a function that creates a development environment. It expects an attrset with:

AttributeRole
packagesList of packages available in the shell
shellHookScript run when entering the shell
nameDisplayed name (optional)
any other keyBecomes an environment variable

A first minimal shell.nix

shell.nix
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
packages = with pkgs; [
python312
git
vim
];
}

To enter the environment, run nix-shell. To check the versions without opening an interactive shell:

Terminal window
nix-shell --run "python3 --version && git --version"
Result
Python 3.12.14
git version 2.55.0

When you type exit, the tools disappear from your PATH: they are only reachable in the Nix shell, even though they stay in the store until the next garbage collection.

Python environment with libraries

"Bare" Python is not enough: you need requests, flask, pytest… The withPackages function creates a Python that includes the requested libraries, without pip install or a venv.

shell.nix
{ pkgs ? import <nixpkgs> {} }:
let
pythonEnv = pkgs.python312.withPackages (ps: [
ps.requests
ps.flask
ps.pytest
]);
in
pkgs.mkShell {
packages = with pkgs; [
pythonEnv
git
ripgrep
];
PROJECT_NAME = "demo-api";
FLASK_DEBUG = "1";
shellHook = ''
echo "=================================="
echo " Project: $PROJECT_NAME"
echo " Python: $(python3 --version 2>&1)"
echo " Flask debug: $FLASK_DEBUG"
echo "=================================="
'';
}
Terminal window
nix-shell
Result
==================================
Project: demo-api
Python: Python 3.12.14
Flask debug: 1
==================================

Let us check that the libraries are available:

Terminal window
python3 -c "import requests; print(f'requests {requests.__version__}')"
python3 -c "import pytest; print(f'pytest {pytest.__version__}')"
Result
requests 2.34.2
pytest 9.1.1

The libraries are installed in the Nix store, not in a classic venv. They are no longer visible once you leave the shell.

Understand withPackages

withPackages expects a function that receives the set of available Python packages (ps) and returns the list of those you want:

pkgs.python312.withPackages (ps: [
ps.requests # ← picks requests from the Python set
ps.flask # ← picks flask
])

The ps: [ ... ] syntax is an anonymous function (lambda), a concept covered in the Nix language guide. The name ps is a convention, not a keyword.

Find Python package names

The Python packages of nixpkgs live under python3Packages, a set that nix search nixpkgs does not walk on its own: nix search nixpkgs python3Packages.requests answers error: no results for the given search term(s)!. You have to designate this set as the target of the search:

Terminal window
nix search nixpkgs#python3Packages requests

The output lists legacyPackages.x86_64-linux.python3Packages.requests among other packages that mention requests. In a shell.nix, you then use ps.requests (shorthand provided by withPackages).

Node.js environment

The principle is identical for Node.js: the attribute name carries the major version (nodejs_22), which freezes the branch without relying on nvm. The shellHook prints the version on every entry into the shell.

shell.nix
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
packages = with pkgs; [
nodejs_22
jq
curl
];
shellHook = "echo 'Node.js:' $(node --version)";
}
Terminal window
nix-shell --run "node --version && jq --version"
Result
Node.js: v22.23.3
v22.23.3
jq-1.8.2

The first line comes from the shellHook, which also runs with --run.

Rust environment

For Rust, the rustc compiler and the cargo manager come from nixpkgs, and gcc provides the linker some crates need. No rustup: the version is the one of your nixpkgs.

shell.nix
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
packages = with pkgs; [
rustc
cargo
gcc
];
RUST_BACKTRACE = "1";
shellHook = "echo 'Rust:' $(rustc --version)";
}
Terminal window
nix-shell --run "rustc --version && cargo --version"
Result
Rust: rustc 1.98.1 (48a229cea 2026-09-01) (built from a source tarball)
rustc 1.98.1 (48a229cea 2026-09-01) (built from a source tarball)
cargo 1.98.0 (797e8a9bc 2026-08-05)

The RUST_BACKTRACE = "1" variable is automatically exported, any key unknown to mkShell becomes an environment variable. The built from a source tarball mention simply indicates a compiler built by nixpkgs.

Environment variables and shellHook

Environment variables

Simply add keys to the mkShell attrset: each one becomes an environment variable exported in the shell, always as a string.

pkgs.mkShell {
packages = [ ... ];
# Become environment variables
PROJECT_NAME = "mon-api";
FLASK_DEBUG = "1";
FLASK_RUN_PORT = "5000";
DATABASE_URL = "postgresql://localhost:5432/dev";
}
Terminal window
nix-shell --run "echo \$PROJECT_NAME \$FLASK_RUN_PORT"
Result
mon-api 5000

shellHook: initialisation script

The shellHook runs on every entry into the shell. It is used to:

  • print a welcome message,
  • activate an environment,
  • check prerequisites,
  • create temporary files.
shellHook = ''
echo "=================================="
echo " Project: $PROJECT_NAME"
echo " Python: $(python3 --version 2>&1)"
echo "=================================="
'';

nix develop: the modern alternative with flakes

nix develop is the equivalent of nix-shell in the world of flakes. It reads a flake.nix file; in a Git repository, this file and everything it imports must be tracked (git add), otherwise Nix refuses to see them.

flake.nix
{
description = "Node.js development environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs = { self, nixpkgs }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
in {
devShells.${system}.default = pkgs.mkShell {
packages = with pkgs; [
nodejs_22
jq
curl
];
shellHook = "echo 'Node.js:' $(node --version)";
};
};
}
  1. Version the flake (recommended: in a Git repository, only tracked files are visible to Nix):

    Terminal window
    git init && git add flake.nix
  2. Enter the environment:

    Terminal window
    nix develop
    Result
    Node.js: v22.23.3
  3. Check the generated flake.lock:

    Terminal window
    cat flake.lock | head -10
    Excerpt of flake.lock
    {
    "nodes": {
    "nixpkgs": {
    "locked": {
    "lastModified": 1790689126,
    "narHash": "sha256-ilerN1WLSvF+HMjziC/Wv99J5y02maDH+6hZPwsORKg=",
    "owner": "NixOS",
    "repo": "nixpkgs",
    "rev": "b4fd65b198c599cbe814fcb9f42d25d021595ec9",

The flake.lock automatically freezes the nixpkgs version. It is the main advantage over a classic shell.nix, which follows the channel of each machine.

nix-shell vs nix develop: when to use which?

Both commands open the same kind of environment; they differ in the way the nixpkgs version is fixed. Read the Locking row above all: it decides whether two machines get the same tools.

Criterionnix-shell + shell.nixnix develop + flake.nix
LockingManual (fetchTarball + sha256)Automatic (flake.lock)
PrerequisitesNoneFlakes enabled; tracked files in a Git repository
ReproducibilityGood if pinnedGuaranteed by the lock
SimplicitySimpler to startMore verbose at first
RecommendationSimple projects, scriptsTeam projects, CI

Modularise your environment

When the shell.nix grows, split the configuration into dedicated files: each file carries a single responsibility, and shell.nix merely assembles them.

Project structure

The idea of modularisation is to separate what changes often from what stays stable. The project variables live in config.nix, the definition of the Python environment in python-env.nix, and shell.nix merely assembles these building blocks. The entry point remains shell.nix: it is the only name nix-shell looks for by default, the two other files are only loaded because it imports them explicitly.

mon-projet/
├── shell.nix # entry point
├── config.nix # project variables
└── python-env.nix # Python environment

config.nix, the variables

This file is a simple attrset of values, with no logic at all: it is the single place where you adjust the project name, its version or the port. Gathering them like this saves digging through shell.nix at every change, and makes the gap between two environments readable at a glance. Note that flaskPort is an integer here; it will have to be converted with toString before being passed as an environment variable, because those are always strings.

config.nix
{
projectName = "demo-api";
projectVersion = "1.0.0";
debugMode = true;
flaskPort = 5000;
}

python-env.nix, the Python environment

This second file isolates the choice of the interpreter and its libraries. It knows nothing about the project: it receives pkgs and returns a ready-to-use Python.

python-env.nix
{ pkgs }:
pkgs.python312.withPackages (ps: [
ps.requests
ps.flask
ps.pytest
])

This file is a function that expects pkgs as argument.

shell.nix, assembling

The entry point imports both files, calls the function of python-env.nix with pkgs, and adds debugging tools depending on debugMode: this is where the building blocks come together.

shell.nix
{ pkgs ? import <nixpkgs> {} }:
let
config = import ./config.nix;
pythonEnv = import ./python-env.nix { inherit pkgs; };
debugTools = if config.debugMode then with pkgs; [
htop
ncdu
] else [];
in
pkgs.mkShell {
name = "${config.projectName}-dev";
packages = with pkgs; [
pythonEnv
git
ripgrep
] ++ debugTools;
PROJECT_NAME = config.projectName;
PROJECT_VERSION = config.projectVersion;
FLASK_RUN_PORT = toString config.flaskPort;
shellHook = ''
echo "Project: ${config.projectName} v${config.projectVersion}"
echo "Debug: ${if config.debugMode then "ON" else "OFF"}"
'';
}
Terminal window
nix-shell --run "echo \$PROJECT_NAME \$PROJECT_VERSION \$FLASK_RUN_PORT && which htop"
Result
Project: demo-api v1.0.0
Debug: ON
demo-api 1.0.0 5000
/nix/store/w31kqb23ydg3fli2b8dsy0gl0ily15bm-htop-3.5.3/bin/htop

inherit pkgs is a shorthand for pkgs = pkgs, it passes the variable of the same name. Modularisation is covered in more depth in the guide on import, factoring and pinning.

Load the environment automatically with direnv

direnv automatically loads the Nix environment when you enter the project folder. No more typing nix-shell by hand; nix-direnv adds a cache that avoids re-evaluating the environment every time.

  1. Install direnv and nix-direnv:

    Terminal window
    nix profile add nixpkgs#direnv nixpkgs#nix-direnv
  2. Enable the shell hook (add to ~/.bashrc or ~/.zshrc):

    Terminal window
    eval "$(direnv hook bash)" # or zsh
  3. Plug in nix-direnv in ~/.config/direnv/direnvrc, otherwise direnv uses its built-in functions, without a cache:

    Terminal window
    source $HOME/.nix-profile/share/nix-direnv/direnvrc
  4. Create .envrc at the root of the project, then allow the directory:

    .envrc
    use nix

    Or for flakes:

    .envrc
    use flake
    Terminal window
    direnv allow

From now on, every cd mon-projet/ automatically loads the Nix environment, and cd .. unloads it. The tools appear and disappear without any action.

Isolation in practice

Outside the Nix shell, only the system Python (here a Debian one) is available. In the Nix shell, Nix puts its own versions at the front of the PATH:

Isolation demo
=== OUTSIDE nix-shell ===
htop: not found
Python 3.11.2 ← Debian Python
=== INSIDE nix-shell ===
/nix/store/.../bin/htop ← Nix htop
Python 3.12.14 ← Nix Python
=== AFTER nix-shell ===
htop: gone ← back to the initial state

Each project can have its own version of Python, Node.js or Rust without conflicts. No more need for pyenv, nvm, rustup to manage versions per project. Beware though: nix-shell adds tools, it does not remove those of the system. The original PATH stays behind, and a command missing from your shell.nix can still come from the distribution. The --pure option starts again from a PATH stripped of everything that is not in the store, to check that an environment is self-sufficient.

Comparing with venv, nvm and Docker

The table places Nix against the three tools it most often replaces. The Isolation row is the most important one: Nix isolates binaries and libraries, not the file system or the network as Docker does.

Criterionvenv / nvmDockerNix (shell.nix / flake.nix)
ScopeA single languageFull systemEvery tool
OverheadNoneContainer (RAM, disk)None (direct access)
Reproducibilityrequirements.txt / .nvmrcDockerfileshell.nix / flake.lock
IsolationLibraries onlyTotal (FS, network…)Binaries and libraries
CIpip install + nvm usedocker runnix-shell --run
SharingText fileImage (Docker Hub)Versioned Nix file
  • venv / nvm: limited to a single language; Nix handles everything.
  • Docker: total isolation but heavier; Nix adds no layer.
  • Nix: the best compromise for development environments.

Troubleshooting

Two families of blockers dominate with Nix environments: a package name that matches nothing in nixpkgs, and a constraint specific to flakes, namely that in a Git repository every referenced file must be tracked (git add). Faced with a package that cannot be found, the reflex is always nix search nixpkgs <name> before assuming it does not exist, the real attribute often differs from the command name. The table below links each symptom to its most likely cause.

SymptomLikely causeSolution
nix-shell cannot find a packageWrong namenix search nixpkgs <name> to find the right name
file 'nixpkgs' was not found in the Nix search pathNo channel configurednix-channel --add https://channels.nixos.org/nixpkgs-unstable nixpkgs && nix-channel --update
Python does not see the librarieswithPackages missingUse python312.withPackages (ps: [ ps.xxx ])
Changes in shell.nix not taken into accountdirenv cachedirenv reload or leave and run nix-shell again
nix develop: Path '…' is not tracked by GitFile created but never added to the repositorygit add <file> then run again
shellHook does not runIncorrect Nix syntaxCheck the '' (multi-line) and the ${} (interpolation)

Key points

  • A shell.nix or flake.nix file at the root of the project describes the complete environment: languages, CLI tools, environment variables.
  • pkgs.mkShell is the central function, it takes a list of packages, a shellHook and environment variables.
  • python312.withPackages creates a Python with its libraries, without pip install.
  • Any key unknown to mkShell becomes an exported environment variable.
  • nix develop (flakes) adds automatic locking of versions, prefer it for team projects.
  • direnv loads the environment automatically on cd, the ultimate day-to-day comfort.
  • The added tools only exist in the Nix shell; the system PATH stays behind, except with nix-shell --pure.

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