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
mkShelland the structure of ashell.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 developwith a flake as the modern alternative - Load the environment automatically with direnv
- Compare
nix-shell/nix developwith 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
jqversion differs from the local workstation, - the
READMEsays "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:
| Attribute | Role |
|---|---|
packages | List of packages available in the shell |
shellHook | Script run when entering the shell |
name | Displayed name (optional) |
| any other key | Becomes an environment variable |
A first minimal 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:
nix-shell --run "python3 --version && git --version"Python 3.12.14git version 2.55.0When 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.
{ pkgs ? import <nixpkgs> {} }:
let pythonEnv = pkgs.python312.withPackages (ps: [ ps.requests ps.flask ps.pytest ]);inpkgs.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 "==================================" '';}nix-shell================================== Project: demo-api Python: Python 3.12.14 Flask debug: 1==================================Let us check that the libraries are available:
python3 -c "import requests; print(f'requests {requests.__version__}')"python3 -c "import pytest; print(f'pytest {pytest.__version__}')"requests 2.34.2pytest 9.1.1The 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:
nix search nixpkgs#python3Packages requestsThe 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.
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell { packages = with pkgs; [ nodejs_22 jq curl ];
shellHook = "echo 'Node.js:' $(node --version)";}nix-shell --run "node --version && jq --version"Node.js: v22.23.3v22.23.3jq-1.8.2The 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.
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell { packages = with pkgs; [ rustc cargo gcc ];
RUST_BACKTRACE = "1";
shellHook = "echo 'Rust:' $(rustc --version)";}nix-shell --run "rustc --version && cargo --version"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";}nix-shell --run "echo \$PROJECT_NAME \$FLASK_RUN_PORT"mon-api 5000shellHook: 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.
{ 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)"; }; };}-
Version the flake (recommended: in a Git repository, only tracked files are visible to Nix):
Terminal window git init && git add flake.nix -
Enter the environment:
Terminal window nix developResult Node.js: v22.23.3 -
Check the generated
flake.lock:Terminal window cat flake.lock | head -10Excerpt 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.
| Criterion | nix-shell + shell.nix | nix develop + flake.nix |
|---|---|---|
| Locking | Manual (fetchTarball + sha256) | Automatic (flake.lock) |
| Prerequisites | None | Flakes enabled; tracked files in a Git repository |
| Reproducibility | Good if pinned | Guaranteed by the lock |
| Simplicity | Simpler to start | More verbose at first |
| Recommendation | Simple projects, scripts | Team 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 environmentconfig.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.
{ 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.
{ 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.
{ 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 [];inpkgs.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"}" '';}nix-shell --run "echo \$PROJECT_NAME \$PROJECT_VERSION \$FLASK_RUN_PORT && which htop"Project: demo-api v1.0.0Debug: ONdemo-api 1.0.0 5000/nix/store/w31kqb23ydg3fli2b8dsy0gl0ily15bm-htop-3.5.3/bin/htopinherit 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.
-
Install direnv and nix-direnv:
Terminal window nix profile add nixpkgs#direnv nixpkgs#nix-direnv -
Enable the shell hook (add to
~/.bashrcor~/.zshrc):Terminal window eval "$(direnv hook bash)" # or zsh -
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 -
Create
.envrcat the root of the project, then allow the directory:.envrc use nixOr for flakes:
.envrc use flakeTerminal 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:
=== OUTSIDE nix-shell ===htop: not foundPython 3.11.2 ← Debian Python
=== INSIDE nix-shell ===/nix/store/.../bin/htop ← Nix htopPython 3.12.14 ← Nix Python
=== AFTER nix-shell ===htop: gone ← back to the initial stateEach 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.
| Criterion | venv / nvm | Docker | Nix (shell.nix / flake.nix) |
|---|---|---|---|
| Scope | A single language | Full system | Every tool |
| Overhead | None | Container (RAM, disk) | None (direct access) |
| Reproducibility | requirements.txt / .nvmrc | Dockerfile | shell.nix / flake.lock |
| Isolation | Libraries only | Total (FS, network…) | Binaries and libraries |
| CI | pip install + nvm use | docker run | nix-shell --run |
| Sharing | Text file | Image (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.
| Symptom | Likely cause | Solution |
|---|---|---|
nix-shell cannot find a package | Wrong name | nix search nixpkgs <name> to find the right name |
file 'nixpkgs' was not found in the Nix search path | No channel configured | nix-channel --add https://channels.nixos.org/nixpkgs-unstable nixpkgs && nix-channel --update |
| Python does not see the libraries | withPackages missing | Use python312.withPackages (ps: [ ps.xxx ]) |
Changes in shell.nix not taken into account | direnv cache | direnv reload or leave and run nix-shell again |
nix develop: Path '…' is not tracked by Git | File created but never added to the repository | git add <file> then run again |
| shellHook does not run | Incorrect Nix syntax | Check the '' (multi-line) and the ${} (interpolation) |
Key points
- A
shell.nixorflake.nixfile at the root of the project describes the complete environment: languages, CLI tools, environment variables. pkgs.mkShellis the central function, it takes a list of packages, ashellHookand environment variables.python312.withPackagescreates a Python with its libraries, withoutpip install.- Any key unknown to
mkShellbecomes 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
PATHstays behind, except withnix-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
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: Import, factor and pin your Nix dependencies
A new full quiz with random questions
Work again only on the questions you missed