Pavan Rangani

HomeBlogNix Reproducible Dev Environments with Flakes: Complete Setup Guide

Nix Reproducible Dev Environments with Flakes: Complete Setup Guide

By Pavan Rangani · March 26, 2026 · DevOps & Cloud

Nix Reproducible Dev Environments with Flakes: Complete Setup Guide

Nix Reproducible Dev Environments with Flakes

Nix reproducible dev environments have emerged as the gold standard for teams that are tired of “works on my machine” problems. Unlike Docker-based dev containers that abstract the OS, Nix provides bit-for-bit reproducible toolchains that run natively on your machine. With Nix Flakes — the modern interface for Nix — you get lockfiles, composability, and a dramatically improved developer experience.

This guide walks you through setting up Nix Flakes for real-world projects, from a simple Node.js app to a polyglot microservices repository. You will learn how to define dev shells, pin dependencies, integrate with CI pipelines, and avoid common pitfalls that trip up Nix newcomers. Along the way, it explains the mental model that makes Nix click — why the store is content-addressed and why that single design choice gives you reproducibility for free.

Why Nix Over Docker Dev Containers

Docker dev containers run your tools inside a Linux container, which means you lose native macOS/Windows integration — file watching is slow, GUI tools do not work directly, and bind mount performance is poor. Nix takes a fundamentally different approach by installing packages into an immutable store (/nix/store) and creating isolated shell environments that reference those packages.

The result is native performance, instant shell startup, and complete reproducibility across machines. Moreover, every dependency is tracked by a cryptographic hash, so two developers with the same flake.lock are guaranteed to have identical tools — down to the exact compiler version and shared library paths.

The deeper reason this works is the content-addressed store. Each package lives at a path like /nix/store/<hash>-nodejs-20.11.0/, where the hash is derived from every input that built it — source, compiler, flags, and the hashes of its own dependencies. Change any input and you get a different path, so two versions never collide and nothing is ever mutated in place. Consequently, “it works on my machine but not in CI” becomes structurally impossible when both machines resolve to the same store paths.

Nix reproducible dev environments server infrastructure
Reproducible infrastructure powered by declarative Nix configurations

Installing Nix and Enabling Flakes

Start by installing Nix using the Determinate Systems installer, which enables Flakes by default and works on macOS, Linux, and WSL2:

# Install Nix with Flakes enabled (recommended installer)
curl --proto '=https' --tlsv1.2 -sSf \
  -L https://install.determinate.systems/nix | sh -s -- install

# Verify installation
nix --version
# nix (Nix) 2.21.0

# Check Flakes support
nix flake --help

If you already have Nix installed without Flakes, add experimental features to your configuration:

# ~/.config/nix/nix.conf
experimental-features = nix-command flakes

Your First flake.nix

A Flake is defined by a flake.nix file at the root of your project. It declares inputs (dependencies like nixpkgs) and outputs (dev shells, packages, etc.). Here is a practical example for a Node.js + Python project:

{
  description = "Full-stack web application dev environment";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
    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; [
            # Node.js toolchain
            nodejs_20
            nodePackages.pnpm
            nodePackages.typescript

            # Python toolchain
            python312
            python312Packages.pip
            python312Packages.virtualenv

            # Database tools
            postgresql_16
            redis

            # Dev utilities
            jq
            httpie
            just  # command runner
          ];

          shellHook = ''
            echo "Dev environment loaded!"
            echo "Node: $(node --version)"
            echo "Python: $(python --version)"
            echo "PostgreSQL: $(psql --version)"
          '';

          # Environment variables
          DATABASE_URL = "postgresql://localhost:5432/myapp_dev";
          REDIS_URL = "redis://localhost:6379";
        };
      });
}

Run nix develop in the project directory and you will have all tools available instantly. The first run downloads everything; subsequent runs are instant because packages are cached in /nix/store.

# Enter the dev shell
nix develop

# Or run a single command in the shell
nix develop --command just test

# Generate the lockfile (committed to git)
nix flake lock

The Lockfile Is the Whole Point

The flake.lock file is what separates Flakes from older Nix and from version managers like asdf. Each input — nixpkgs, flake-utils, and anything else you reference — is pinned to an exact Git revision and a NAR hash of its contents. When a teammate clones the repo and runs nix develop, Nix resolves inputs from the lock, not from whatever nixos-24.11 happens to point at today.

{
  "nodes": {
    "nixpkgs": {
      "locked": {
        "lastModified": 1730000000,
        "narHash": "sha256-AbCdEf123...",
        "owner": "NixOS",
        "repo": "nixpkgs",
        "rev": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
        "type": "github"
      },
      "original": {
        "owner": "NixOS",
        "ref": "nixos-24.11",
        "repo": "nixpkgs",
        "type": "github"
      }
    }
  },
  "root": "root",
  "version": 7
}

Commit this file. To intentionally move forward, run nix flake update (all inputs) or nix flake lock --update-input nixpkgs (one input), review the diff, and commit it like any other dependency bump. Because the bump is a reviewable change to a single file, upgrades become deliberate rather than accidental — the opposite of a floating latest tag that drifts under you between deploys.

Nix Flakes for Polyglot Monorepos

For monorepos with multiple services, define multiple dev shells in a single flake. This approach lets each team member enter only the shell they need, without downloading unnecessary toolchains.

{
  outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem (system:
      let pkgs = nixpkgs.legacyPackages.${system}; in
      {
        devShells = {
          default = pkgs.mkShell {
            buildInputs = with pkgs; [ just git ];
          };

          backend = pkgs.mkShell {
            buildInputs = with pkgs; [
              jdk21
              gradle
              postgresql_16
            ];
          };

          frontend = pkgs.mkShell {
            buildInputs = with pkgs; [
              nodejs_20
              nodePackages.pnpm
              playwright-driver.browsers
            ];
          };

          infra = pkgs.mkShell {
            buildInputs = with pkgs; [
              terraform
              kubectl
              helm
              awscli2
            ];
          };
        };
      });
}
# Enter specific shells
nix develop .#backend
nix develop .#frontend
nix develop .#infra
Cloud infrastructure development workflow
Managing polyglot development workflows with declarative Nix shells

Integrating with direnv for Automatic Shell Activation

Typing nix develop every time you enter a directory is tedious. The nix-direnv integration automatically activates your dev shell when you cd into the project, and deactivates it when you leave. This is the recommended approach for daily development.

# Install direnv (add to your global Nix profile or system config)
nix profile install nixpkgs#direnv nixpkgs#nix-direnv

# Add to your shell rc file (~/.bashrc or ~/.zshrc)
eval "$(direnv hook bash)"  # or zsh

# In your project root, create .envrc
echo "use flake" > .envrc
direnv allow

Now every time you cd into the project directory, your tools are available. The shell loads in milliseconds because nix-direnv caches the environment profile. For monorepos, you can point a subdirectory’s .envrc at a named shell with use flake .#backend, so opening the backend folder loads the JVM toolchain and opening the frontend folder loads Node — no manual switching.

CI/CD Integration with Nix Flakes

The true power of this approach shines in CI/CD. Your CI pipeline uses the exact same toolchain as local development — no more drift between local and CI environments.

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: cachix/install-nix-action@v26
        with:
          extra_nix_config: |
            access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}

      - uses: cachix/cachix-action@v14
        with:
          name: my-project
          authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}

      - name: Run tests
        run: nix develop --command just test

      - name: Build
        run: nix build

Cachix provides a binary cache so your CI does not rebuild packages from source every run. Consequently, CI times drop substantially after the first cached run — in production teams, builds that took 15+ minutes from source typically fall to a couple of minutes once the cache is warm, because each store path is downloaded as a prebuilt artifact rather than compiled.

Handling Impurities: The Hard Cases

Flakes are pure by default, and that purity is occasionally inconvenient. Two situations bite teams repeatedly, so it is worth naming them. First, language package managers that fetch from the network — npm install, pip install, cargo build — run inside the Nix shell but are not managed by Nix. The shell gives you a pinned nodejs_20 and pnpm, but node_modules is still resolved by pnpm’s own lockfile. That is usually fine: Nix pins the toolchain, the language lockfile pins the libraries, and the two layers compose. Trouble appears only when a native module needs a system library that is not in buildInputs.

Second, native dependencies. A Python wheel or npm package with a compiled component may expect a shared library at link time. Because Nix shells do not use the global /usr/lib, you add the library to the shell and, on Linux, often need an explicit library path so the dynamic loader finds it:

devShells.default = pkgs.mkShell {
  buildInputs = with pkgs; [
    python312
    stdenv.cc.cc.lib   # libstdc++ for many compiled wheels
    zlib
    openssl
  ];

  # Help the loader find Nix-provided shared libraries on Linux
  LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [
    pkgs.stdenv.cc.cc.lib
    pkgs.zlib
    pkgs.openssl
  ];
};

If you hit an ImportError: libstdc++.so.6: cannot open shared object file from a pip-installed package, this is almost always the cause. Add the providing package and the LD_LIBRARY_PATH entry, re-enter the shell, and the import resolves.

When NOT to Use Nix

Nix has a steep learning curve, and the functional language can be intimidating for teams unfamiliar with it. If your team already has a working Docker Compose setup and nobody wants to learn Nix, forcing adoption will cause more friction than it solves. Additionally, Nix on native Windows (without WSL2) has limited support, and the error messages — infinite recursion, missing attributes — are genuinely hard to decipher for beginners.

For simple projects with a single language and runtime (e.g., a basic Node.js app), a .tool-versions file with asdf or mise may be sufficient. Nix shines most in polyglot environments, complex build chains, and organizations that value byte-for-byte reproducibility across dozens of developers. The honest trade-off is up-front complexity for long-term consistency: you pay in onboarding time and a thornier debugging story, and you collect the payoff every time a new hire is productive in one direnv allow instead of a day of environment setup.

DevOps developer environment setup
Developer workstation with reproducible toolchain configuration

Key Takeaways

  • Pinned Nix toolchains guarantee identical environments across all developer machines and CI/CD pipelines
  • Nix Flakes add lockfiles and composability, making Nix practical for production teams
  • Use nix-direnv for automatic shell activation — it eliminates the friction of manual nix develop commands
  • Multiple dev shells in a single flake support polyglot monorepos without wasting disk space
  • Cachix binary caches make CI/CD fast by avoiding redundant package builds
  • Expect to handle impurity at the edges — language package managers and native libraries need explicit care

Related Reading

External Resources

In conclusion, Nix Reproducible Dev Environments is an essential topic for modern software development. By applying the patterns and practices covered in this guide — pinned lockfiles, per-service dev shells, direnv automation, and deliberate handling of impure dependencies — you can build more robust, scalable, and maintainable systems. Start with the fundamentals, iterate on your implementation, and continuously measure results to ensure you are getting the most value from these approaches.

← Back to all articles