Nix Dev Shell Concepts
Global development dependencies lead to conflicts and "works on my machine" problems. Nix dev shells provide isolated, reproducible environments per project.
The Problem with Global Dependencies
Consider a typical development machine:
- Project A needs Node 18
- Project B needs Node 20
- Project C needs Node 16 for legacy reasons
Solutions like nvm help, but:
- You forget to switch versions
- You need multiple version managers (nvm, pyenv, rbenv...)
- New team members need a long setup document
- CI might use different versions
What is a Dev Shell?
A Nix dev shell is a temporary environment containing exactly the tools a project needs. Enter the project directory, get the right tools. Leave, they're gone from your PATH.
# flake.nix
{
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
outputs = { self, nixpkgs }:
let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
in {
devShells.x86_64-linux.default = pkgs.mkShell {
buildInputs = [
pkgs.nodejs_20
pkgs.pnpm
pkgs.postgresql_16
];
shellHook = ''
echo "Node $(node --version)"
echo "Dev environment ready"
'';
};
};
}Enter with:
nix developHow It Works
When you run nix develop:
- Nix reads the flake and builds the shell environment
- A new shell spawns with modified environment variables
- PATH includes only the specified packages (plus system essentials)
- Any
shellHookcommands run - When you exit, your original environment returns
Packages aren't "installed" globally. They exist in /nix/store and are temporarily available.
Key Patterns
mkShell
The standard way to define development environments:
pkgs.mkShell {
# Packages available in the shell
buildInputs = [ pkgs.git pkgs.vim ];
# Native dependencies for building
nativeBuildInputs = [ pkgs.pkg-config ];
# Environment variables
DATABASE_URL = "postgresql://localhost/dev";
# Commands to run on shell entry
shellHook = ''
export CUSTOM_VAR="value"
'';
}Multiple Shells
Define shells for different purposes:
devShells = {
default = pkgs.mkShell { ... };
ci = pkgs.mkShell { ... }; # Minimal for CI
full = pkgs.mkShell { ... }; # Everything for local dev
};Enter with nix develop .#ci or nix develop .#full.
Language-Specific Patterns
For interpreted languages, include the runtime and package manager:
# Python
buildInputs = [
pkgs.python311
pkgs.python311Packages.pip
pkgs.python311Packages.virtualenv
];
# Node
buildInputs = [
pkgs.nodejs_20
pkgs.pnpm
];
# Rust
buildInputs = [
pkgs.rustc
pkgs.cargo
pkgs.rust-analyzer
];Integration with direnv
Typing nix develop every time is tedious. direnv automates this:
# .envrc
use flakeNow, entering the directory automatically activates the dev shell. Your IDE picks up the environment too.
Setup:
# Install direnv (via Home Manager or system)
# Add to your shell config:
eval "$(direnv hook zsh)" # or bash
# In your project:
echo "use flake" > .envrc
direnv allowBenefits for Teams
Onboarding
Old way:
- Read the 47-step setup document
- Install 12 different tools
- Debug why versions don't match
- Give up and ask a colleague
New way:
git clone <repo>
cd <repo>
direnv allow
# DoneConsistency
Every developer has identical tool versions. CI uses the same environment. "Works on my machine" becomes "works on every machine."
Self-Documenting
The flake.nix is the documentation of project requirements. No separate setup guide to maintain.
Beyond Development
Dev shells can include services:
shellHook = ''
# Start Postgres in background
pg_ctl -D .postgres -l .postgres/log start
'';Or use more sophisticated tools like devenv or process-compose for multi-service development environments.