Reproducible development environments: the Nix approach
Dozens of Go microservices in Docker, almost a dozen Node.js UI applications, PostgreSQL, Redis. Extensive setup process. Docker Desktop, Go 1.21 specifically, Node.js 18 specifically, PostgreSQL 14, build tools differing between macOS and Linux. When it breaks, debugging requires understanding which layer failed. Developers spend 10% of working time fighting environment issues.Our application consists of dozens of Go microservices running in Docker containers—several impersonating external servers for testing—almost a dozen Node.js applications handling different UI components, PostgreSQL for persistent storage, and Redis for caching. The development setup required Docker Desktop, Go 1.21, Node.js 18, the correct versions of Docker Compose, PostgreSQL 14, Redis, and various build tools that differed between Linux and macOS.
The setup process was extensive. Installing Docker Desktop. Configuring memory limits. Setting up Go with the correct version—not Go 1.22 which had breaking changes, definitely not Go 1.20 which lacked features the services relied on. Installing Node 18 specifically, not 20, because the UI build tooling hadn't migrated yet. Getting PostgreSQL and Redis running locally or in containers, with the right versions. Configuring Docker Compose files that referenced local volumes with paths that differed between developers. Building dozens of services, which took considerable time on first run and required specific environment variables that weren't always clearly documented.
When it worked, it worked. When it broke—and it broke often—debugging required understanding which layer failed. Was it Docker networking? Go module resolution? Node package conflicts? PostgreSQL connection issues? Redis connectivity? Docker daemon consuming too much memory and throttling? Each developer's environment developed its own quirks based on their installation history. One person had Homebrew's PostgreSQL interfering with the containerized version. Another had Go installed via system packages conflicting with Docker's Go installation. A third had Node version managers creating PATH conflicts that made builds non-deterministic.
This pattern repeats constantly across software teams. Research by DORA found that developers spend an average of 4-5 hours per week—10% of working time—dealing with environment-related issues.1 Not building features. Not fixing bugs. Not improving architecture. Just fighting computers to achieve a baseline state where code runs at all. The problem compounds: different developers have different setups, CI runs in yet another environment, production uses different versions still. Bugs appear in one environment but not others. "Works on my machine" becomes a defence mechanism, not a joke.
Traditional solutions address parts of the problem whilst creating new issues. Documentation becomes outdated the moment someone commits a dependency change. Virtual machines are slow, consume gigabytes of disk space, and provide terrible developer experience. Docker helps with runtime consistency but doesn't address the development environment itself—you still need Node, Python, and build tools installed locally to develop inside containers. Language-specific version managers like nvm, rbenv, or pyenv solve single-language version conflicts whilst leaving system dependencies unmanaged.
The fundamental issue is mutable state. Your development machine accumulates software over time. Installations layer on top of each other. Global packages conflict with project-specific versions. System updates change behaviour. Every machine's environment is unique, shaped by its history of installations, updates, and configurations. Reproducibility becomes impossible when the baseline is unknowable.
Nix: immutability and isolation
Nix solves the mutable state problem by making everything immutable. Instead of installing packages into shared system directories where they can conflict, Nix stores each package in isolation with a unique path derived from its exact dependencies. Node.js 18 gets installed to one path, Node.js 20 to another, and projects specify precisely which they need. No conflicts. No shared state. No "works on my machine" ambiguity because every machine builds the same artefacts from the same inputs.
The model is functional—packages are pure functions of their inputs. Given identical inputs, Nix produces identical outputs regardless of what else exists on the system. This seems obvious but contradicts how traditional package managers work. When you apt install nodejs, the result depends on what's already installed, what version of Ubuntu you're running, which repositories are configured, and what time you run the installation. Nix eliminates this variability. The same Nix expression produces the same package on every machine, every time.
Packages declare their dependencies explicitly. There's no implicit reliance on system libraries, no hope that something useful exists in a standard location, no assumptions about global state. When Nix builds a package, it provides exactly the dependencies declared and nothing else. The isolation is absolute. This makes builds reproducible—the package either works consistently everywhere or fails consistently everywhere. The "works on my machine" problem becomes impossible because there's no hidden difference between machines to create it.
The practical impact: you define your development environment as code. That definition works identically for every developer, CI, and deployment environment. Every developer runs one command and gets precisely the same environment as everyone else. System updates can't break installations because Nix doesn't use system packages—it builds and isolates its own. The documentation never becomes outdated because the environment is the documentation.
Your first Nix environment
Installation is straightforward. On macOS or Linux:
sh <(curl -L https://nixos.org/nix/install)
Windows requires WSL2 first, then follow Linux instructions. The installer sets up the Nix store and package manager without touching your system packages.
Development environments in Nix are defined in a shell.nix file—a declarative specification of everything your project needs. Consider a web application using Node.js and Python for utility scripts:
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
buildInputs = [
# Node.js and npm
pkgs.nodejs_20
# Python with some packages
(pkgs.python3.withPackages (ps: [
ps.requests
ps.pandas
]))
# Additional tools
pkgs.jq
pkgs.ripgrep
];
# Environment variables
shellHook = ''
export NODE_ENV="development"
export API_URL="http://localhost:3000"
echo "Development environment ready!"
'';
}
Any developer runs nix-shell in the project directory:
nix-shell
Nix reads the declaration, builds the required packages if they aren't already cached, and drops you into a shell with Node.js 20, Python 3 with specified packages, and additional tools—all without touching your system installation. Exit the shell, and those packages disappear from your PATH. Enter again, and they return. The environment is ephemeral and perfectly reproducible.
That enterprise application with dozens of Go services and almost a dozen Node applications? The shell.nix file would declare Go 1.21, Node.js 18, Docker, PostgreSQL 14, Redis, and every build tool needed, with exact versions. Every developer runs nix-shell and gets identical environments. No extensive setup process. No version conflicts. No PATH issues from competing installation methods. The environment definition is code committed to the repository, version-controlled alongside the application it supports.
Pinning for absolute reproducibility
Basic shell.nix files reference <nixpkgs>, which uses whatever version of the Nix package collection your system has. This is convenient but not perfectly reproducible—different developers might have different nixpkgs versions. Flakes solve this by pinning exact versions:
{
description = "My web application development environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-22.05";
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 = [
pkgs.nodejs_20
(pkgs.python3.withPackages (ps: [ ps.requests ps.pandas ]))
pkgs.jq
pkgs.ripgrep
];
shellHook = ''
export NODE_ENV="development"
export API_URL="http://localhost:3000"
echo "Development environment ready!"
'';
};
}
);
}
The key advantage here is that the exact version of nixpkgs is pinned, ensuring that everyone gets precisely the same versions of all tools and packages.
To use this flake:
nix develop
A real-world example: setting up a full-stack web project
Let's walk through a real-world example of setting up a development environment for a full-stack JavaScript application with a React frontend and Node.js backend.
First, create a shell.nix file at the root of your project:
{ pkgs ? import <nixpkgs> {} }:
let
nodeVersion = pkgs.nodejs_20;
in pkgs.mkShell {
buildInputs = [
# JavaScript environment
nodeVersion
pkgs.yarn
# Development tools
pkgs.git
pkgs.jq
pkgs.vim
pkgs.ripgrep
pkgs.docker
pkgs.docker-compose
];
shellHook = ''
# Add to PATH
export PATH="$PWD/node_modules/.bin:$PATH"
# Set project-specific environment variables
export API_URL="http://localhost:3001"
export CLIENT_URL="http://localhost:3000"
export NODE_ENV="development"
# Display helpful information
echo "Development environment ready!"
echo "Available commands:"
echo " yarn dev - Start development servers"
echo " yarn build - Build for production"
echo " yarn test - Run tests"
'';
}
This setup provides a complete development environment with:
- Node.js 20.x and Yarn for JavaScript development
- Docker and Docker Compose for containerized services
- Development tools like Git, jq, Vim, and ripgrep
- Project-specific environment variables
- Helpful command reference
To use this environment, a developer would:
- Install Nix (a one-time setup)
- Clone the repository
- Run
nix-shellin the project directory - Run
docker-compose upto start any containerized services - Run
yarn installto install project dependencies - Run
yarn devto start the development servers
This approach ensures that every developer gets exactly the same versions of Node.js 20, Docker, and all development tools, regardless of their host operating system or existing installations.
Integrating Nix with Docker
For teams that rely on Docker for deployment, Nix can be used to create reproducible Docker images:
{ pkgs ? import <nixpkgs> {} }:
let
nodejs = pkgs.nodejs_20;
# Application dependencies
nodeEnv = pkgs.mkYarnModules {
name = "my-app-deps";
packageJSON = ./package.json;
yarnLock = ./yarn.lock;
};
in pkgs.dockerTools.buildImage {
name = "my-app";
tag = "latest";
contents = [
nodejs
nodeEnv
# Include your application files
(pkgs.runCommand "app-files" {} ''
mkdir -p $out/app
cp -r ${./src}/* $out/app/
cp ${./package.json} $out/app/
'')
];
config = {
Cmd = [ "${nodejs}/bin/node" "/app/index.js" ];
WorkingDir = "/app";
ExposedPorts = { "3000/tcp" = {}; };
};
}
This creates a minimal, reproducible Docker image with only the essential dependencies. To build and run this image:
nix-build docker.nix -o docker-image
docker load < docker-image
docker run -p 3000:3000 my-app:latest
The resulting image will be consistent across all environments where it's built, eliminating the "it worked when I built it locally" problem that can plague Docker workflows.
When Nix makes sense
Nix solves real problems but introduces its own complexity. The learning curve exists—the Nix language is functional and unfamiliar, the package repository has its own structure, and debugging build failures requires understanding the isolation model. Whether this trade-off makes sense depends on your situation.
Nix shines for projects with complex, multi-language dependencies where traditional approaches create constant friction. If you're spending 4-5 hours weekly fighting environment issues, Nix pays for itself quickly. Cross-platform development becomes manageable when everyone can achieve identical environments regardless of host OS. CI/CD pipelines that behave differently from development become a solved problem when both use the same Nix definitions.
For simple, single-language projects, Nix might be overkill. A Python web service using only pip-installable dependencies doesn't benefit much from Nix's heavy machinery. Short-lived prototypes where you'll throw away the code in weeks don't justify the setup investment. Small teams already stretched thin might find adopting a new tool disruptive even if it would help long-term.
The decision hinges on where complexity lives. If your complexity comes from environment management—juggling versions, hunting dependencies, maintaining consistency—Nix eliminates that complexity by replacing it with declarative configuration. If your complexity comes from business logic and algorithms whilst your environment is straightforward, Nix adds complexity rather than removing it.
That enterprise application with dozens of Go microservices, almost a dozen Node applications, PostgreSQL, and Redis required extensive setup documentation because every possible failure mode had to be covered. Docker networking issues. Go version conflicts. Node tooling incompatibilities. PostgreSQL connection problems. Redis configuration differences. Build tool variations between operating systems. Each developer's environment accumulated its own quirks—Homebrew's PostgreSQL interfering with containerized versions, system Go conflicting with Docker's Go installation, Node version managers creating PATH chaos.
With Nix, the entire environment—Go 1.21, Node.js 18, Docker, PostgreSQL 14, Redis, build tools, everything—gets declared in one shell.nix file. Every developer runs nix-shell and gets identical environments in minutes. The extensive setup process becomes unnecessary. The version conflicts disappear. The database and cache versions are consistent. The PATH issues can't happen because Nix isolates everything. The same environment works identically for everyone, in CI, in deployment scripts, next week, next month, next year, until someone deliberately changes the environment definition.
The "works on my machine" problem exists because traditional systems accumulate mutable state. Nix eliminates the problem by eliminating the mutable state. Environments become immutable, reproducible, and declarative. What works on your machine works on every machine because every machine builds identical artefacts from identical definitions.
The learning curve is real. Nix requires understanding new concepts and a different way of thinking about package management. But the payoff—eliminating an entire category of frustrating, time-consuming problems—makes the investment worthwhile for teams battling environment inconsistency. The hours spent learning Nix are recovered quickly by hours not spent debugging "works on my machine" issues.
Footnotes
-
DevOps Research and Assessment (DORA). (2023). "Accelerate State of DevOps Report." Google Cloud. ↩
Published on:
Updated on:
Reading time:
9 min read
Article counts:
42 paragraphs, 1,733 words
Topics
TL;DR
DORA research shows developers spend 4-5 hours weekly—10% of working time—fighting environment issues. Traditional solutions create new problems: documentation becomes outdated, VMs are slow and consume gigabytes, Docker addresses runtime but not development environments, language-specific version managers leave system dependencies unmanaged. The fundamental issue is mutable state—machines accumulate software over time, creating unique configurations where reproducibility becomes impossible. Nix solves this through immutability and isolation. Packages are pure functions of inputs, stored with content-addressable paths, built with explicitly declared dependencies. Same Nix expression produces identical results everywhere. Development environments become code: shell.nix files declare dependencies, nix-shell creates ephemeral environments without touching system installations. Flakes pin exact versions for absolute reproducibility. New developers run one command and get working environments in minutes instead of days. macOS updates can't break installations because Nix isolates its own packages. The learning curve exists but hours spent learning are recovered quickly by hours not spent debugging.