01 — Introduction
Every developer has heard the phrase: "it works on my machine." Traditional environment management tools — virtualenv, rbenv, nvm, asdf — solve part of the problem, but they all share a fundamental flaw: they are imperative. You run a series of commands to reach a desired state, and there is no guarantee that two machines following the same steps end up in the same place.
System-level dependencies are even harder. A C library compiled against a different glibc version, an openssl mismatch, or a missing pkg-config path can silently break builds in ways that take hours to diagnose. Docker can contain this, but at the cost of heavy runtimes, slow iteration cycles, and an entirely different abstraction layer.
Nix takes a radically different approach. Instead of imperatively installing packages, you declare exactly what a development environment looks like — down to every dependency, every library path, every environment variable — and Nix guarantees that declaration produces the same result everywhere, every time.
02 — Fundamentals
To understand Nix dev environments, you need to understand three core ideas:
The Nix Store
All packages in Nix live in /nix/store, each identified by a cryptographic hash of all their inputs — source code, compiler flags, dependencies. This means /nix/store/wn2raf7c7gkj…-nodejs-20.11.0 is a unique, immutable artifact. Two builds with the same hash are guaranteed to be identical. Two builds with different hashes are guaranteed to be different. There is no "ambient" version of Node.js polluting your PATH.
The Nix Expression Language
Nix uses its own lazy, purely functional expression language (also called Nix) to describe packages and environments. It looks unusual at first — especially its heavy use of attribute sets and let…in expressions — but its rules are simple and consistent once you internalize them.
1# Attribute sets are Nix's primary data structure
2let
3 myPkg = {
4 name = "my-package";
5 version = "1.0.0";
6 deps = [ pkgs.openssl pkgs.zlib ];
7 };
8in
9 myPkg.name # evaluates to "my-package"
Derivations
A derivation is Nix's term for a build action — a description of how to produce an output from a set of inputs. When you run nix-build or nix build, Nix evaluates derivations, checks the store for existing outputs, and only rebuilds what is missing or changed.
03 — Classic Approach
The original way to declare a development environment in Nix is a shell.nix file at the root of your project. It uses pkgs.mkShell to produce a derivation that, when activated, augments your shell with packages and environment variables.
1{ pkgs ? import <nixpkgs> {} }:
2
3pkgs.mkShell {
4 # Packages available in your dev shell
5 buildInputs = with pkgs; [
6 nodejs_20
7 nodePackages.pnpm
8 python311
9 python311Packages.pip
10 postgresql_15
11 redis
12 git
13 curl
14 ];
15
16 # Environment variables injected into the shell
17 DATABASE_URL = "postgres://localhost/myapp_dev";
18 NODE_ENV = "development";
19
20 # Shell hooks run when you enter the environment
21 shellHook = ''
22 echo "→ Dev environment ready"
23 echo "→ Node: $(node --version)"
24 echo "→ Python: $(python --version)"
25 '';
26}
Activate this shell by running nix-shell in the directory. Your PATH, library paths, and environment variables are all set up automatically. When you exit the shell, your system returns to its previous state — nothing was installed globally.
shell.nix with <nixpkgs> is not fully reproducible — it uses whatever version of nixpkgs your channel points to, which can differ between machines. Flakes fix this.
04 — Flakes
Introduced as an experimental feature (and now widely adopted), Nix Flakes solve the reproducibility gap by pinning every dependency — including nixpkgs itself — in a lockfile (flake.lock). They also establish a standard schema for Nix projects.
A flake.nix at the root of your project replaces shell.nix:
1{
2 description = "My project dev environment";
3
4 inputs = {
5 # Pinned to a specific commit — truly reproducible
6 nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
7 flake-utils.url = "github:numtide/flake-utils";
8 };
9
10 outputs = { self, nixpkgs, flake-utils }:
11 flake-utils.lib.eachDefaultSystem (system:
12 let
13 pkgs = nixpkgs.legacyPackages.${system};
14 in {
15 devShells.default = pkgs.mkShell {
16 packages = with pkgs; [
17 nodejs_20
18 go_1_22
19 golangci-lint
20 postgresql_16
21 ];
22
23 shellHook = ''
24 export GOPATH="$PWD/.go"
25 export PATH="$GOPATH/bin:$PATH"
26 '';
27 };
28
29 # Named shells for different roles
30 devShells.ci = pkgs.mkShell {
31 packages = with pkgs; [ go_1_22 golangci-lint ];
32 };
33 }
34 );
35}
Activate with nix develop (default shell) or nix develop .#ci (named shell). The flake.lock file pins the exact commit of nixpkgs, ensuring that a colleague running nix develop six months later gets bit-for-bit identical tooling.
Initialize a new flake
Run nix flake init in your project root. This creates a minimal flake.nix template to start from.
Add your packages
Populate devShells.default.packages with everything your project needs. Browse available packages at search.nixos.org.
Commit the lockfile
Run nix flake lock then commit both flake.nix and flake.lock. The lockfile is the reproducibility guarantee — never gitignore it.
Activate and develop
Run nix develop to drop into your shell. All tools are available immediately, nothing was installed globally.
05 — devenv.sh
devenv is a higher-level tool built on top of Nix Flakes that dramatically reduces the boilerplate required to declare a dev environment. It provides a clean, opinionated interface for declaring services, languages, scripts, and pre-commit hooks — things you'd otherwise wire together manually.
Install devenv once (nix profile install nixpkgs#devenv), then create a devenv.nix at your project root:
1{ pkgs, ... }:
2
3{
4 # ─── Languages ───────────────────────────────────────
5 languages.rust.enable = true;
6 languages.rust.channel = "stable";
7 languages.javascript.enable = true;
8 languages.javascript.npm.enable = true;
9
10 # ─── Services ────────────────────────────────────────
11 services.postgres = {
12 enable = true;
13 package = pkgs.postgresql_16;
14 initialDatabases = [{ name = "myapp_dev"; }];
15 };
16
17 services.redis.enable = true;
18
19 # ─── Packages ────────────────────────────────────────
20 packages = [
21 pkgs.cargo-watch
22 pkgs.sqlx-cli
23 pkgs.just
24 ];
25
26 # ─── Scripts ─────────────────────────────────────────
27 scripts.dev.exec = ''
28 cargo-watch -x run
29 '';
30
31 # ─── Pre-commit hooks ─────────────────────────────────
32 pre-commit.hooks.rustfmt.enable = true;
33 pre-commit.hooks.clippy.enable = true;
34}
Run devenv up to start services, devenv shell to enter the environment, or simply use direnv for automatic activation. Services run in the background without Docker — PostgreSQL and Redis are managed by a process supervisor backed by Nix packages, not container images.
06 — Automation
direnv is a shell extension that watches your current directory and automatically loads/unloads environment variables when you cd into or out of a directory. Combined with Nix, it means your dev environment activates the moment you enter your project folder — no manual nix develop required.
Install direnv and hook it into your shell once:
1# Install direnv
2nix profile install nixpkgs#direnv
3
4# Add to ~/.bashrc or ~/.zshrc
5eval "$(direnv hook bash)" # bash
6eval "$(direnv hook zsh)" # zsh
Then in your project root, create an .envrc file:
1# For a Nix Flake (flake.nix present)
2use flake
3
4# For devenv (devenv.nix present)
5use devenv
6
7# You can also set extra env vars here
8export API_KEY="$(pass myproject/api-key)"
Run direnv allow once to trust the .envrc. From that point on, your Nix environment loads silently every time you enter the directory and unloads when you leave. Your terminal's tool versions change automatically as you navigate between projects.
use flake can be slow as Nix evaluates and builds the environment. Subsequent activations use a cached result and are nearly instant. Use nix-direnv (a drop-in for direnv's built-in Nix support) to cache more aggressively and skip rebuilds on unrelated changes.
07 — Comparison
Here is how the main approaches and tools stack up for common concerns:
| Feature | shell.nix | Flakes devShell | devenv |
|---|---|---|---|
| Reproducible | ⚠ Partial | ✓ Full | ✓ Full |
| Pinned dependencies | ✗ | ✓ flake.lock | ✓ devenv.lock |
| Service management | ✗ | ✗ | ✓ Built-in |
| Pre-commit hooks | ✗ | ⚠ Manual | ✓ Built-in |
| Language modules | ✗ | ✗ | ✓ 60+ langs |
| direnv integration | ✓ | ✓ | ✓ |
| Learning curve | Low | Medium | Low–Medium |
| Suitable for | Simple projects | Any project | Full-stack apps |
For most new projects, Flakes with direnv is the sweet spot — full reproducibility, no extra tooling, low overhead. devenv shines for full-stack projects where you'd otherwise reach for Docker Compose to manage databases and background services.
08 — Advanced
Overlays — Patching Nixpkgs
Sometimes you need a package version that nixpkgs doesn't provide, or you want to apply a patch. Overlays let you modify or extend the nixpkgs package set without forking it:
1let
2 pkgs = import nixpkgs {
3 inherit system;
4 overlays = [(final: prev: {
5 # Override with a custom version
6 my-tool = prev.my-tool.overrideAttrs (old: {
7 version = "2.1.0";
8 src = fetchurl { ... };
9 });
10 })];
11 };
12in ...
Multiple Dev Shells per Project
A single project might have different environment needs for different roles — a minimal CI shell, a full local-dev shell, a documentation-writing shell. Flakes support named outputs:
1nix develop # default shell
2nix develop .#docs # docs shell with mdBook, pandoc
3nix develop .#ci # minimal shell for CI runners
4nix develop .#gpu # shell with CUDA tooling
Sharing Environments as Inputs
A powerful pattern for monorepos or organizations is extracting common tooling into a shared flake and importing it as an input. Teams get identical base environments with the ability to extend locally:
1inputs = {
2 nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
3 # Your org's shared dev environment
4 org-devenv.url = "github:acme-corp/nix-envs";
5};
6
7outputs = { self, nixpkgs, org-devenv }: {
8 devShells.default = org-devenv.devShells.${system}.default.extend {
9 packages = [ pkgs.my-extra-tool ];
10 };
11};