Skip to content
Go back

Nix Dev Shells for Project Reproducibility

By SumGuy 7 min read
Nix Dev Shells for Project Reproducibility
Contents

The Environment Nightmare That Nix Actually Solves

You’ve been there. Your colleague runs npm install on their Mac and gets version 18.12.0. You pull the same repo on Ubuntu and somehow Node is 16.8.1. Your Docker build works locally but fails on CI because Alpine’s musl libc handles some edge case differently. You’ve got pyenv, nodenv, rbenv, rustup, and homebrew all fighting over /usr/local.

You don’t need a container orchestration platform to fix this. You need a way to declare “this project uses Python 3.12.1, PostgreSQL 16.2, and Rust 1.75” and have those exact versions available the second you enter the directory: on your Mac, your Linux laptop, your CI runner, and your coworker’s workstation.

That’s what Nix dev shells do. Not the NixOS-operating-system angle. Just the clean, reproducible environment part.

What a Nix Dev Shell Actually Is

A dev shell is a declarative environment bundled with a single flake.nix file at your project root. You describe your dependencies once. When you run nix develop, Nix builds an environment with exactly those versions, isolates it from your system, and drops you into a shell where everything is available: no installation, no version conflicts, no “works on my machine.”

Think of it like this: Docker lets you ship a whole OS. Nix dev shells let you ship just the tools. Same reproducibility, zero container overhead.

The best part? It’s not a full NixOS install. You can use it on macOS, Ubuntu, Fedora, anywhere Nix is installed (which is literally everywhere now thanks to the Nix installer). Your system Python is untouched. When you exit the dev shell, all those tools vanish.

Building a Real flake.nix

Let’s say you’re starting a Python project that uses PostgreSQL 16 and needs Python 3.12. Here’s the flake:

{
description = "My Python + Postgres dev environment";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
python312
python312Packages.pip
python312Packages.virtualenv
postgresql_16
git
just
];
shellHook = ''
echo "🐚 Nix dev shell loaded: Python 3.12 + PostgreSQL 16"
export PYTHONPATH="$PWD:$PYTHONPATH"
'';
};
}
);
}

That’s it. One file. Twenty lines. Pin exact versions, declare them once, and you’re done.

How It Works: nix develop

You clone the repo. Your colleague is already on their machine. Your CI system has Nix installed (most modern runners do now).

Terminal window
git clone <repo>
cd <project>
nix develop

Nix reads flake.nix, evaluates the dependencies (Python 3.12, PostgreSQL 16, git, etc.), pulls them from the nixpkgs cache if they exist, and drops you into an isolated shell. Your system Python stays untouched. PostgreSQL is available at postgres but it’s version 16, not your system’s 14.

Everything inside that shell sees the versions you declared. Outside the shell, they’re invisible.

Terminal window
$ python --version
Python 3.10.2 # your system Python
$ nix develop
🐚 Nix dev shell loaded: Python 3.12 + PostgreSQL 16
$ python --version
Python 3.12.1
$ exit
$ python --version
Python 3.10.2 # back to normal

No containers. No sudo. No fighting with your package manager. The isolation is at the shell level, powered by Nix’s store (/nix/store/) and environment variable manipulation.

Real Example: Node + Python Monorepo

Let’s say you’ve got a frontend (Node) and a backend (Python) in the same repo. Different versions. Different dependency trees.

{
description = "Monorepo: Node frontend + Python backend";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
# Backend
python312
python312Packages.pip
python312Packages.poetry
# Frontend
nodejs_20
yarn
# Shared
postgresql_16
redis
git
just
];
shellHook = ''
export PYTHONPATH="$PWD/backend:$PYTHONPATH"
'';
};
}
);
}

Now everyone on the team (Mac, Linux, CI) gets Node 20 and Python 3.12 in the same shell. No “it works on my machine” excuses. Dependency lock files (package-lock.json, poetry.lock) handle the rest.

The direnv Integration: Make It Automatic

Entering nix develop every time you cd into a project gets old. That’s where direnv comes in. It’s a shell hook that automatically loads your dev shell based on a .envrc file.

Install direnv:

Terminal window
# macOS
brew install direnv
# Ubuntu/Debian
sudo apt install direnv
# Or anywhere: https://direnv.net/docs/installation.html

Add it to your shell config (~/.zshrc or ~/.bashrc):

Terminal window
eval "$(direnv hook zsh)" # or bash, fish, etc.

Create .envrc in your project root:

Terminal window
use flake

That’s it. Now:

Terminal window
$ cd my-project
direnv: loading ~/projects/my-project/.envrc
direnv: export +PYTHONPATH +PATH ...
direnv: watch: /home/user/projects/my-project/flake.nix
$ python --version
Python 3.12.1
$ cd ..
direnv: unloading
$ python --version
Python 3.10.2

Automatic. Transparent. Your shell knows which version is active because direnv modifies $PATH based on what the flake exports.

Why This Beats Language Managers (Most of the Time)

You’ve probably used pyenv, nodenv, rbenv, rustup, nvm. They’re fine for single-language projects. But the moment you need Python 3.12 AND Node 20 AND Postgres 16, they start stepping on each other. Language managers fight over environment variables. Version files in the root become a mess (.python-version, .nvmrc, .ruby-version). CI needs to know about all of them.

Nix has one source of truth: the flake. One language or ten, doesn’t matter. Same mental model. Same reproduction guarantees.

It’s like going from five different parts catalogs to one unified parts list. You know where everything is.

Practical Tips

Keep it simple at first. Your first flake probably needs 4 to 6 packages. Don’t try to manage your entire build system in one flake. Flakes are for environment, not orchestration. Use just, Makefile, or nix run for tasks.

Use nix flake init to scaffold a boilerplate:

Terminal window
nix flake init

It gives you a reasonable starting point. Customize from there.

Commit both flake.nix and flake.lock. The lock file (Nix’s equivalent of package-lock.json) is generated the first time you run a flake command. Committing it pins your dependencies so teammates get the exact same packages. Teammates run nix flake update to get newer versions if they want them explicitly.

Cache hits matter. The first time you run nix develop in a new project, Nix builds/downloads everything (can take 2 to 10 minutes). Subsequent runs are instant (it’s all cached). On CI, use a binary cache like Cachix to avoid rebuilding on every run.

Debugging? Use nix develop --verbose. Shows you what’s happening under the hood.

When Nix Dev Shells Aren’t the Answer

You need Kubernetes? Nix won’t help you there. Nix shells are for local development. If you need to test against a Postgres cluster, Docker networking, or multi-container orchestration, you’ll still reach for Docker Compose or Kubernetes.

You’re deploying Haskell and need a cross-compilation toolchain? Nix is actually incredible here, but we’re out of scope for this article.

Your team refuses to install Nix. That’s fair. Nix has a learning curve and opinions. Some shops stick with Docker, language managers, or containers. Nix is a tool for teams that want one reproducible answer, not an argument about “whose Python is correct.”

The Real Win

This is the part people don’t talk about enough: you stop thinking about environment setup. No more “you have Node 18 but CI has 20” bugs. No more “my Mac is on Python 3.10, the CI container is 3.12, and my VM is 3.11.” You declare once, it works everywhere.

Your future self at 2 AM, debugging an environment issue, will appreciate it.

Your colleague who’s new to the project will run nix develop and suddenly have the right versions, no install docs needed.

Your CI stops wasting time on dependency installation.

Honestly, that’s the whole game. Stop managing versions. Declare them once. Move on.


Next steps: Install Nix via the installer (nixos.org/download), commit a flake.nix to your next project, and see how it feels. If it clicks, you’ll wonder how you ever lived without it. If it doesn’t, Docker works fine too.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
SSH Bastion & Jump Host Patterns That Don't Hurt

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts