Development Environments in Nix

01 — Introduction

The Problem with Traditional Dev Environments

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.

Reproducible Declarative Hermetic No global state Content-addressed Rollback-friendly

02 — Fundamentals

Nix 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.

nix expression language basics
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.

// note
Nix doesn't require you to understand derivations deeply to use dev environments. The tooling handles the heavy lifting. But knowing they exist helps you reason about why your environment is reproducible.

03 — Classic Approach

shell.nix — The Original Dev Shell

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.

shell.nix
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.

// caveat
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

Nix Flakes for Dev Shells

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:

flake.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.sh — Modern Developer Environments

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:

devenv.nix
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.

// highlight
devenv supports over 60 languages and 30 services out of the box, including PostgreSQL, MySQL, Redis, Elasticsearch, MongoDB, Kafka, RabbitMQ, and more. Each is configured with a clean NixOS-style module system.

06 — Automation

Automatic Activation with direnv

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:

bash / zsh profile
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:

.envrc — for flakes
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.

// performance tip
The first 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

Tool 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

Advanced Patterns

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:

flake.nix — overlay example
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:

named shells — activation
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:

consuming a shared org flake
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};