Development environments
Different projects need different tools at different versions. A Node project pinned to version 18 and another pinned to 22 cannot both be satisfied by one globally installed binary. A Python project that needs Flask 3 and an older one that needs Flask 2 will fight over the same site-packages directory. The traditional answer is version managers layered on top of global installs, which every contributor has to configure the same way on their own machine, and which still produce subtle differences between setups.
Nix offers a cleaner answer. A development shell is a closed environment that
puts exactly the packages you declare on PATH and removes them when you leave.
Anyone who clones the repo runs one command and gets the same tools at the same
versions, pinned by the project's flake.lock just like the rest of the build.
No global installs, no version manager, no divergence between machines.
A devShells flake
A dev shell is a devShells output in flake.nix. The layout below is the
minimal working shape, ready to extend with whatever your project needs.
{
description = "Project dev shell";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }:
let
system = "x86_64-linux";
pkgs = import nixpkgs { inherit system; };
in
{
devShells.${system}.default = pkgs.mkShell {
packages = [
pkgs.git
pkgs.curl
pkgs.jq
];
shellHook = ''
echo "dev shell ready"
'';
};
};
}devShells.x86_64-linux.default is the output nix develop finds when you do
not name a specific shell. packages is a list of derivations that land on
PATH inside the environment. shellHook is a bash string Nix executes when
the shell starts, which is where you print versions, export environment
variables, or run a quick project sanity check.
Enter the shell from the directory that holds flake.nix.
nix developType exit or press Ctrl-D to leave and return to your system environment.
The packages are gone the moment you exit.
Real stacks
The sections below show complete mkShell bodies for three common stacks.
Swap the packages into the flake above and the pattern is identical for any
other stack.
Node with pnpm
devShells.${system}.default = pkgs.mkShell {
packages = [
pkgs.nodejs_22
pkgs.pnpm
pkgs.typescript
];
shellHook = ''
echo "node $(node --version)"
echo "pnpm $(pnpm --version)"
'';
};Node follows the naming pattern nodejs_22, nodejs_20, nodejs_18, and so
on in nixpkgs, so each project pins a specific major version in its own flake.
Two projects that need different Node versions can coexist on the same machine
without a version manager because neither one touches the global environment.
Python
python3.withPackages builds an interpreter that already has the listed
packages on its import path. The result is a single derivation you pass into
packages.
devShells.${system}.default = pkgs.mkShell {
packages = [
(pkgs.python3.withPackages (ps: with ps; [
flask
requests
pytest
black
]))
pkgs.ruff
];
shellHook = ''
echo "python $(python --version)"
'';
};ps inside the lambda is the Python package set, equivalent to
pkgs.python3Packages. Tools that are not Python packages but belong in the
project environment go alongside as regular derivations, as ruff shows here.
Rust
The simplest Rust shell pulls the toolchain components from nixpkgs directly.
devShells.${system}.default = pkgs.mkShell {
packages = [
pkgs.rustc
pkgs.cargo
pkgs.rust-analyzer
pkgs.clippy
pkgs.rustfmt
pkgs.pkg-config
pkgs.openssl
];
shellHook = ''
echo "rustc $(rustc --version)"
'';
};For a nightly toolchain or a version that is not current stable, the community flakes fenix and rust-overlay are the standard approaches. Declare one as a flake input and follow its documentation to replace the toolchain packages above.
Environment variables in shellHook
shellHook is also where you export variables the project expects at runtime.
Variables set there are only present inside the dev shell and do not leak into
the rest of the system.
shellHook = ''
export DATABASE_URL="postgres://localhost:5432/mydb"
export LOG_LEVEL="debug"
export NODE_ENV="development"
'';For values that must not appear in source control, keep them out of flake.nix
and load them through direnv (covered below) from an .envrc file you add to
.gitignore. A bare .envrc does nothing on its own. It is read only once
direnv is installed and you have run direnv allow, and nix develop does not
read it.
Automatic activation with direnv
Typing nix develop by hand each time is easy to forget, especially when
switching between projects. direnv solves this by
watching directories and loading an .envrc file the moment you cd into one.
nix-direnv is the fast Nix
driver for direnv that caches dev shells so they appear instantly on subsequent
visits instead of rebuilding from scratch.
Installing through Home Manager
The cleanest way to set up the pair on a Ternix-managed machine is a two-line addition to your Home Manager config.
programs.direnv = {
enable = true;
nix-direnv.enable = true;
};Home Manager writes the direnv hook into your shell's startup file automatically, so no manual sourcing is needed. Run a switch to apply it.
home-manager switch --flake .#userInstalling system-wide
If you prefer a system-wide install, add both packages to your NixOS configuration.
environment.systemPackages = [
pkgs.direnv
pkgs.nix-direnv
];Then add the hook to your shell's rc file.
# ~/.bashrc
eval "$(direnv hook bash)"Replace bash with zsh or fish to match your shell. If you already use
the Home Manager programs.direnv option, skip this step.
Setting up a project
Inside the project directory, write a one-line .envrc and then tell direnv
to trust it.
echo "use flake" > .envrc
direnv allowFrom that point forward, entering the directory loads the dev shell and leaving
unloads it. Your shell prompt updates to show which environment is active. When
you edit flake.nix, direnv detects the change and reloads the shell on the
next directory visit. To re-evaluate without leaving and re-entering, run
direnv reload.
Ad hoc shells without a flake
For a quick experiment that does not belong in any project, nix shell opens a
temporary environment with one or more packages on PATH.
# a shell with jq and ripgrep available
nix shell nixpkgs#jq nixpkgs#ripgrep
# combine several packages in one shell
nix shell nixpkgs#ffmpeg nixpkgs#imagemagick nixpkgs#exiftoolThe packages disappear when you exit. Nothing is installed globally and nothing touches your system profile.
nix run goes one step further and executes a package directly without opening
an interactive shell at all.
# run cowsay without installing it
nix run nixpkgs#cowsay -- "hello from Nix"
# format a Nix file with alejandra
nix run nixpkgs#alejandra -- ./flake.nixEverything after -- is forwarded as arguments to the program. The nixpkgs#
prefix resolves through the flake registry (the system or user nixpkgs entry),
not through your project's flake.lock, so the version is independent of any
project pin. To run a package at a project's locked revision, reference that
flake directly, for example nix run .#mypackage.
Advanced
Multiple named shells
A single flake can expose several shells under different names. This is useful when CI needs a leaner environment without development-only tools, or when different contributors need different toolsets from the same repository.
outputs = { self, nixpkgs }:
let
system = "x86_64-linux";
pkgs = import nixpkgs { inherit system; };
in
{
devShells.${system}.default = pkgs.mkShell {
packages = [ pkgs.nodejs_22 pkgs.pnpm pkgs.typescript ];
};
devShells.${system}.ci = pkgs.mkShell {
packages = [ pkgs.nodejs_22 pkgs.pnpm ];
};
devShells.${system}.docs = pkgs.mkShell {
packages = [ pkgs.mdbook pkgs.pandoc ];
};
};Enter a non-default shell by naming it after the #.
nix develop .#ci
nix develop .#docsIn a project .envrc, pass the shell name to use flake as well.
use flake .#ciPinning a different nixpkgs for one shell
When a project needs a tool at a version only available in a different nixpkgs
branch, declare a second input and import it into its own package set. Both
revisions are tracked in flake.lock and the build stays reproducible.
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
nixpkgs-stable.url = "github:NixOS/nixpkgs/nixos-24.11";
};
outputs = { self, nixpkgs, nixpkgs-stable }:
let
system = "x86_64-linux";
pkgs = import nixpkgs { inherit system; };
pkgs-stable = import nixpkgs-stable { inherit system; };
in
{
devShells.${system}.default = pkgs.mkShell {
packages = [
pkgs.nodejs_22
pkgs-stable.python3
];
};
};
}pkgs and pkgs-stable are two independent package sets imported from two
different nixpkgs revisions. You can mix packages from both in a single
mkShell, and nix flake update nixpkgs-stable moves that input independently
of the main one.
Next
- Flakes explains inputs, the lock file, and how pinning works across a full system config.
- Home Manager covers setting up direnv and managing the rest of your user environment as code.
- Examples has copy-ready configurations including a complete dev shell alongside NixOS and Home Manager setups.
- Commands and scripts collects the Nix commands you reach for day to day.