DevOps ·

Nix for Development Environments: Flakes, Dev Shells, and Reproducible Builds That Eliminate Configuration Drift

Docker dev containers and asdf get you close to reproducibility, but not all the way. Nix flakes close the remaining gap: exact package versions, hermetic builds, and per-project toolchains that activate automatically. This guide covers flake.nix structure, devShells, direnv integration, CI parity, overlays, and the real downsides before you commit.

Nix for Development Environments: Flakes, Dev Shells, and Reproducible Builds That Eliminate Configuration Drift

Every team eventually ships a bug that only reproduces on one developer’s machine. The usual suspects: a different Node version, a Postgres minor version mismatch, an OpenSSL library pulled in by a transitive dependency. You fix the immediate issue and add a note to the README: “make sure you’re on Node 20.11.0.” Six months later someone is on 20.14.0 and the cycle repeats.

Docker dev containers and version managers like asdf or mise reduce this drift, but they do not eliminate it. Nix does, with a fundamentally different approach: every tool and library is content-addressed, builds are hermetic, and the exact closure of dependencies is pinned in a lockfile that lives in your repository. If the lockfile matches, the environment matches. Not approximately. Exactly.

This guide covers the modern Nix workflow using flakes, from first principles through a production-ready setup for a TypeScript + Postgres + Redis project.

Why the Alternatives Fall Short

Before committing to Nix’s learning curve, it is worth being specific about what the alternatives actually give you.

ToolWhat it pinsWhat it misses
asdf / miseRuntime versions (Node, Python, Ruby)System libraries, C toolchains, native extensions
Docker dev containersEverything inside the imageImage build inputs drift unless you pin base images and apt packages too
Nix flakesEvery package, library, and transitive dependencyAlmost nothing: the closure is complete

asdf and mise are runtime version managers. They pin your language runtime but not the system libraries those runtimes link against. If your Node addon compiles against libssl on Linux but your CI runs a different glibc version, you will see failures that asdf cannot prevent. The .tool-versions file also says nothing about Postgres, Redis, or any other service your project depends on.

Dev containers are closer. A devcontainer.json with a pinned image gives you a reproducible container filesystem. The problems appear at the edges: your Dockerfile calls apt-get install, which resolves package versions at build time. Two developers who built the same Dockerfile six months apart may have different system library versions. You can mitigate this by pinning apt packages, but that is significant maintenance overhead. Dev containers also require Docker Desktop (or a comparable runtime), which has its own overhead on macOS and is not available everywhere.

Nix takes a different approach. Every package is a pure function of its inputs. The inputs are content-addressed by hash. If the hash matches, the package is byte-for-byte identical regardless of when or where it was built. The lockfile (flake.lock) records the exact revision of every input, including nixpkgs itself. Two developers running nix develop from the same lockfile get an identical shell environment.

Nix Flakes as the Modern Entry Point

Flakes are the current standard for structured Nix projects. Before flakes, Nix used channels (global, mutable, not pinned), which made reproducibility harder to achieve in practice. Flakes replaced channels with a declarative flake.nix and a generated flake.lock that pins every input to a specific git revision.

The anatomy of a flake.nix:

{
  description = "TypeScript + Postgres + Redis 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 = import nixpkgs { inherit system; };
      in
      {
        devShells.default = pkgs.mkShell {
          buildInputs = [
            pkgs.nodejs_20
            pkgs.nodePackages.pnpm
            pkgs.postgresql_16
            pkgs.redis
            pkgs.openssl
            pkgs.pkg-config
          ];

          shellHook = ''
            echo "Dev environment ready."
            echo "Node: $(node --version)"
            echo "Postgres: $(postgres --version)"
            echo "Redis: $(redis-server --version)"
          '';
        };
      }
    );
}

The three sections:

inputs declares external dependencies for your flake. Each input resolves to a git repository at a specific revision, recorded in flake.lock after the first nix flake update. You can pin to a stable nixpkgs branch like nixos-24.11 or follow nixos-unstable for more recent packages.

outputs is a function from resolved inputs to the flake’s public interface. devShells, packages, apps, nixosModules are all valid output attributes. For development environments you care about devShells.

flake-utils is a convenience library that wraps outputs in a per-system loop, so your flake works on x86_64-linux, aarch64-linux, aarch64-darwin, and x86_64-darwin without repeating yourself.

devShells for Per-Project Toolchains

pkgs.mkShell creates a shell environment that makes a specific set of packages available on PATH without installing them globally. The key properties:

  • Packages are isolated to the shell. They do not pollute your user profile.
  • The shell is reproducible: the same buildInputs list with the same flake.lock produces the same environment on any supported system.
  • shellHook runs arbitrary shell commands when you enter the environment, useful for setting environment variables or starting local services.

A more complete devShell for a real TypeScript + Postgres + Redis project:

devShells.default = pkgs.mkShell {
  buildInputs = [
    # Runtime
    pkgs.nodejs_20
    pkgs.nodePackages.pnpm

    # Database
    pkgs.postgresql_16
    pkgs.redis

    # Build tools for native Node addons
    pkgs.python3
    pkgs.gnumake
    pkgs.gcc
    pkgs.pkg-config
    pkgs.openssl

    # Dev utilities
    pkgs.jq
    pkgs.curl
    pkgs.git
  ];

  # Environment variables available inside the shell
  PGDATA = "${toString ./.}/.pgdata";
  PGPORT = "5432";
  REDIS_PORT = "6379";

  shellHook = ''
    # Initialize Postgres data directory if absent
    if [ ! -d "$PGDATA" ]; then
      initdb --auth=trust --username=postgres "$PGDATA"
    fi

    echo ""
    echo "Tools:"
    echo "  node    $(node --version)"
    echo "  pnpm    $(pnpm --version)"
    echo "  psql    $(psql --version)"
    echo "  redis   $(redis-server --version | head -1)"
    echo ""
    echo "Start services:"
    echo "  pg_ctl -D \$PGDATA -l \$PGDATA/logfile start"
    echo "  redis-server --port \$REDIS_PORT --daemonize yes"
    echo ""
  '';
};

The PGDATA variable scopes the Postgres data directory to the project root. Each project has its own database cluster. You never conflict with a system Postgres.

direnv Integration for Automatic Shell Activation

Running nix develop manually every time you enter a project directory gets old quickly. direnv solves this with a .envrc file and a shell hook that activates automatically when you cd into the directory.

Install direnv and the nix-direnv integration:

nix profile install nixpkgs#direnv
nix profile install nixpkgs#nix-direnv

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

eval "$(direnv hook bash)"
# or for zsh:
eval "$(direnv hook zsh)"

Create .envrc in your project root:

use flake

Allow it:

direnv allow

Now whenever you enter the directory, direnv loads the flake’s devShell automatically. When you leave, the environment is restored. No nix develop required.

Add .envrc to .gitignore or commit it alongside the flake. Committing it is the better choice for teams because every developer gets automatic activation without manual setup.

The nix-direnv integration is important: it caches the evaluated environment so that switching into the directory does not trigger a full Nix evaluation every time. Cold loads still take a few seconds on first run or after a lockfile update, but warm loads are instant.

Pinning Exact Versions Across a Team

The flake.lock file is the source of truth. It records the exact git revision of every input:

{
  "nodes": {
    "nixpkgs": {
      "locked": {
        "lastModified": 1712400000,
        "narHash": "sha256-abc123...",
        "owner": "NixOS",
        "repo": "nixpkgs",
        "rev": "a1b2c3d4e5f6...",
        "type": "github"
      }
    }
  }
}

Every developer running nix develop with this lockfile gets Node 20.11.0, Postgres 16.2, and Redis 7.2.4 from the same nixpkgs revision. Not “approximately Node 20” but the exact same binary, verified by content hash.

To update dependencies:

nix flake update        # updates all inputs to latest
nix flake lock --update-input nixpkgs  # updates only nixpkgs

Commit the updated flake.lock. Every developer gets the updated versions on their next direnv reload or nix develop.

If you need a specific package version that is not in the current nixpkgs revision, you have two options: pin a separate nixpkgs input at a specific revision, or use an overlay.

CI Parity with nix develop

The same flake that defines your dev environment can drive CI, which is the actual value of the approach. If CI and local development use the same flake.lock, they run the same tools.

GitHub Actions example:

name: CI

on: [push, pull_request]

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

      - uses: cachix/install-nix-action@v26
        with:
          nix_path: nixpkgs=channel:nixos-unstable
          extra_nix_config: |
            experimental-features = nix-command flakes

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

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

      - name: Type check
        run: nix develop --command pnpm tsc --noEmit

      - name: Lint
        run: nix develop --command pnpm eslint src

nix develop --command runs a single command inside the dev shell without entering an interactive session, which is what you want in CI. The Cachix step caches built packages so subsequent runs download binaries instead of compiling from source.

The first CI run after a lockfile change takes longer because Nix builds or fetches the full closure. After that, the cache keeps subsequent runs fast. Cachix is a hosted binary cache service; you can also run your own with nix-serve if you have infrastructure concerns about a third-party cache.

Nix Overlays for Custom Packages

When nixpkgs does not have the package you need, or when you need a version that is not in the current revision, overlays let you extend or override the package set.

A common case: a tool that is not in nixpkgs at all, or a patched version of an existing package.

{
  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
        overlay = final: prev: {
          # Override a package version
          nodejs_20 = prev.nodejs_20.overrideAttrs (old: {
            version = "20.11.0";
            src = prev.fetchurl {
              url = "https://nodejs.org/dist/v20.11.0/node-v20.11.0.tar.xz";
              sha256 = "sha256-<hash>";
            };
          });

          # Add a custom package
          my-tool = prev.stdenv.mkDerivation {
            name = "my-tool-1.0.0";
            src = prev.fetchurl {
              url = "https://example.com/my-tool-1.0.0.tar.gz";
              sha256 = "sha256-<hash>";
            };
            buildInputs = [ prev.openssl ];
            installPhase = ''
              mkdir -p $out/bin
              cp my-tool $out/bin/
            '';
          };
        };

        pkgs = import nixpkgs {
          inherit system;
          overlays = [ overlay ];
        };
      in
      {
        devShells.default = pkgs.mkShell {
          buildInputs = [
            pkgs.nodejs_20
            pkgs.my-tool
          ];
        };
      }
    );
}

Overlays compose. You can layer multiple overlays to apply different customizations without conflict. The final package set is the result of applying all overlays in sequence.

The Real Downsides

Nix is not a drop-in replacement for asdf. The tradeoffs are real and worth understanding before rolling it out to a team.

Learning curve. The Nix expression language is a pure, lazy, dynamically-typed functional language that evaluates to a set of derivations. Coming from YAML-based tooling, this is a significant shift. Error messages are often cryptic. Debugging a failed derivation requires understanding how Nix evaluates expressions, which takes time. Expect one to two weeks before a developer comfortable with DevOps tooling can write and debug overlays confidently.

Disk space. Nix stores every version of every package in /nix/store. Packages are never updated in place; new versions are stored alongside old ones. A busy development machine accumulates gigabytes. nix-collect-garbage -d removes unreferenced store paths, but you need to run it periodically. On a team with many projects sharing a Nix store, 50-100GB is realistic. On macOS, this is stored on a separate APFS volume, which adds setup overhead.

macOS quirks with nix-darwin. On macOS, Nix works but requires additional setup. The Nix installer creates a synthetic volume at /nix because macOS’s system volume is read-only. This survives reboots but adds configuration steps during initial install. For teams that want to manage macOS system configuration declaratively (Homebrew packages, system defaults, launch agents), nix-darwin extends the NixOS module system to macOS. It is powerful but adds another layer to learn. Most teams start with Nix purely for dev shells and skip nix-darwin until they have more comfort with the language.

Build times for the first run. If your CI cache is cold or a package is not in the Nixpkgs binary cache, Nix compiles from source. Most packages in nixpkgs have pre-built binaries in cache.nixos.org, but custom packages in overlays compile locally. A complex overlay with native dependencies can take ten minutes on a fresh CI runner. Cachix or a self-hosted binary cache is necessary to keep CI fast.

Flakes are still technically experimental. Despite widespread adoption, flakes remain behind the experimental-features = nix-command flakes flag. This has been the case for years and is unlikely to change the actual behavior, but it does mean you need to enable the flag in /etc/nix/nix.conf on every machine. The cachix/install-nix-action action handles this in CI; for local machines, it is a one-time step.

When Nix Is the Right Call

The return on investment is highest when:

  • Your project has native dependencies that differ subtly between developer machines and CI
  • You have more than three or four developers and environment setup is a recurring onboarding cost
  • You want CI and local development to use provably identical tool versions
  • You are managing multiple projects with different toolchain requirements on the same machine

For a solo developer on a greenfield TypeScript project with no native extensions, asdf or mise is probably enough. For a team of eight shipping a product with Postgres, Redis, and a Rust sidecar, the week you spend setting up Nix pays off in the months of “works on my machine” incidents you avoid.

The configuration drift problem is not theoretical. Every team that has been around long enough has shipped a production incident that traced back to a subtle environment difference. Nix makes that class of incident structurally impossible.

That is the actual value proposition: not a faster workflow, not a nicer developer experience, but the removal of an entire category of problems from the possible failure space.

More in DevOps

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code
DevOps ·

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code

A deep dive into Terraform's internal architecture covering HCL parsing, the expression evaluation engine, DAG-based dependency resolution, the gRPC provider plugin protocol, state mechanics, and the plan/apply lifecycle.

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container
DevOps ·

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container

A deep dive into what happens when you run docker run: Linux namespaces for process isolation, cgroups v2 for resource enforcement, overlay2 union filesystem for layered images, the OCI runtime spec and how runc spawns containers, content-addressable storage, the containerd shim architecture, and networking with veth pairs.

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop
DevOps ·

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop

A deep dive into Kubernetes internals covering the control plane architecture, etcd watch mechanics, the scheduler's filter and score phases, controller manager reconciliation loops, kubelet pod assignment, the CRI and CNI plugin models, and production considerations for resource requests, pod disruption budgets, and cluster autoscaler behavior.

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution
DevOps ·

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution

A deep dive into Docker internals covering Linux namespace isolation, cgroup resource constraints, OverlayFS copy-on-write layer mechanics, Dockerfile build cache invalidation rules, the containerd and runc OCI runtime stack, and production considerations for image hardening and startup latency.