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:

  1. Node.js 20.x and Yarn for JavaScript development
  2. Docker and Docker Compose for containerized services
  3. Development tools like Git, jq, Vim, and ripgrep
  4. Project-specific environment variables
  5. Helpful command reference

To use this environment, a developer would:

  1. Install Nix (a one-time setup)
  2. Clone the repository
  3. Run nix-shell in the project directory
  4. Run docker-compose up to start any containerized services
  5. Run yarn install to install project dependencies
  6. Run yarn dev to 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

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

Latest from the blog

15 min read

AWS sub-accounts: isolating resources with Organizations

Most teams dump client resources into their main AWS account, creating an administrative nightmare when projects end or security issues arise. AWS Organizations sub-accounts provide hard security boundaries that separate resources, limit blast radius from incidents, and make cleanup trivial—yet many developers avoid them, assuming the setup complexity outweighs the benefits.

More rabbit holes to fall down

11 min read

Dotfiles: why and how

Working on someone else's machine feels like writing with their hands—common commands fail, shortcuts vanish, and everything feels wrong. Dotfiles transform this by capturing your accumulated workflow optimisation in version-controlled configuration files, turning any terminal into your terminal within minutes rather than days of manual reconfiguration.
10 min read

Environment reproducibility: Docker vs. Nix vs. Vagrant

Production threw segmentation faults in unchanged code. Four hours revealed the cause: Node.js 18.16.0 versus 18.17.1—a patch version difference in native addon handling exposing a memory corruption issue. Environment drift creates space for bugs to hide. Docker, Nix, and Vagrant solve reproducibility at different levels with distinct trade-offs.
10 min read

The hidden cost of free tooling: when open source becomes technical debt

Adding file compression should have taken a day. Three packages needed different versions of the same streaming library. Three days of dependency archaeology, GitHub issue spelunking, and version juggling later, we manually patched node_modules with a post-install script. Open source is free to download but expensive to maintain.

Further musings for the properly obsessed

15 min read

AWS sub-accounts: isolating resources with Organizations

Most teams dump client resources into their main AWS account, creating an administrative nightmare when projects end or security issues arise. AWS Organizations sub-accounts provide hard security boundaries that separate resources, limit blast radius from incidents, and make cleanup trivial—yet many developers avoid them, assuming the setup complexity outweighs the benefits.
11 min read

The architecture autopsy: when 'we'll refactor later' becomes 'we need a complete rewrite'

Early architectural decisions compound over time, creating irreversible constraints that transform minor technical debt into catastrophic system failures. Understanding how seemingly innocent choices cascade into complete rewrites reveals why future-proofing architecture requires balancing immediate needs with long-term reversibility.
19 min read

The symptom-fix trap: Why patching consequences breeds chaos

In the relentless pressure to ship features and fix bugs quickly, development teams fall into a destructive pattern of treating symptoms rather than root causes. This reactive approach creates cascading technical debt, multiplies maintenance costs, and transforms codebases into brittle systems that break under the weight of accumulated shortcuts.
9 min read

The 2038 problem: when time runs out

At exactly 03:14:07 UTC on January 19, 2038, a significant portion of the world's computing infrastructure will experience temporal catastrophe. Unlike Y2K, this isn't a formatting problem - it's mathematics meets physics, and we can't patch the fundamental laws of binary arithmetic.
20 min read

The velocity trap: when speed metrics destroy long-term performance

Velocity metrics were meant to help teams predict and improve, but they have become weapons of productivity theatre that incentivise gaming the system while destroying actual productivity. Understanding how story points, velocity tracking, and sprint metrics create perverse incentives is essential for building truly effective development teams.
18 min read

Sprint overcommitment: the quality tax nobody measures

Three features in parallel, each "nearly done". The authentication refactor sits at 85% complete. The payment integration passed initial testing. The dashboard redesign awaits final review. None will ship this sprint—all will introduce bugs next sprint. Research shows teams planning above 70% capacity experience 60% more defects whilst delivering 40% less actual value.
12 min read

Technical debt triage: making strategic compromises

Simple CSV export: one day estimated, three weeks actual. User data spread across seven tables with inconsistent types—strings, epochs, ISO 8601 timestamps. Technical debt's real cost isn't messy code; it's velocity degradation. Features take weeks instead of days. Developers spend 17 hours weekly on maintenance from accumulated debt.
10 min read

Avoiding overkill: embracing simplicity

A contact form implemented with React, Redux, Webpack, TypeScript, and elaborate CI/CD pipelines—2.3MB production bundle for three fields and a submit button. Two days to set up the development environment. Thirty-five minutes to change placeholder text. This is overengineering: enterprise solutions applied to problems that need HTML and a server script.
10 min read

Terminal multiplexing: beyond the basics

Network drops during critical database migrations. SSH connections terminate mid-deployment. Terminal crashes destroy hours of workspace setup. tmux decouples your terminal interface from persistent sessions that continue running independently—network failures become irrelevant interruptions rather than catastrophic losses, whilst organised workspaces survive crashes and reconnections.
10 min read

SSH keys in 1Password: eliminating the file juggling ritual

SSH keys scattered across machines create a familiar nightmare—copying files between systems, remembering which key lives where, and the inevitable moment when you need to connect from a new laptop without access to your carefully managed ~/.ssh directory. 1Password's SSH agent transforms this by keeping encrypted keys available everywhere whilst ensuring private keys never touch disk outside the vault.
10 min read

Turbocharge development: the magic of SSH port forwarding

Security policies block database ports. Firewalls prevent external connections. Remote services remain inaccessible except through carefully controlled channels. SSH port forwarding creates encrypted tunnels that make distant services appear local—you connect to localhost whilst traffic routes securely to remote resources, maintaining security boundaries without compromising workflow efficiency.
9 min read

Streamlining local development with Dnsmasq

Testing on localhost hides entire categories of bugs—cookie scope issues, CORS policies, authentication flows that behave differently on real domains. These problems surface after deployment, when fixing them costs hours instead of minutes. Dnsmasq eliminates this gap by making local development behave like production, turning any custom domain into localhost whilst preserving domain-based security policies.
7 min read

SSH dotfiles: unlocking efficiency

Managing dozens of SSH connections means remembering complex hostnames, multiple keys, and elaborate commands you copy from text files. The .ssh/config file transforms this chaos into memorable aliases that map mental shortcuts to complete configurations, reducing cognitive load so you can focus on actual work rather than SSH incantations.
10 min read

Downtime of uptime percentages, deciphering the impact

Understanding the real-world implications of uptime percentages is paramount for businesses and consumers alike. What might seem like minor decimal differences in uptime guarantees can translate to significant variations in service availability, impacting operations, customer experience, and bottom lines.