Reproducible Dev Environments with Nix and direnv

I still remember the morning I joined a new project and spent three hours fighting with Node.js versions. The README said "Requires Node 18," but my system had 20, and nvm was having one of its moods. By the time I got the environment working, I'd forgotten why I wanted to contribute in the first place. That's when I realized: your development environment shouldn't be a puzzle you solve before you can write code.

The Chaos of "Just Install It"

Traditional development setup follows a familiar pattern. Clone the repo, read the README, install the right language version, install dependencies, realize you need a specific database version, install that, configure environment variables, discover the project needs an older version of some CLI tool, fight with your package manager, and eventually—if you're lucky—run the application.

The problems multiply when you switch between projects. That Node 18 project conflicts with your Node 20 side project. The Python 3.9 dependency breaks your system tools. The database version that worked yesterday doesn't match what production uses today. And god help you if you need to onboard a new team member and watch them go through the same three-hour ritual.

What I needed was a way to declare my environment: "This project needs Node 18.17, PostgreSQL 15, Redis 7, and these specific CLI tools." And I needed it to just work when I entered the project directory.

Enter Nix and direnv

Nix solves the first problem. It's a package manager that can install any version of any package side-by-side without conflicts. direnv solves the second—it automatically activates environment variables and tools when you enter a directory.

Together, they create something powerful: a development environment that activates itself when you cd into a project and disappears when you leave. No manual switching. No pollution of your global system. Just clean, isolated, reproducible environments.

How It Actually Works

When I enter a project directory, direnv detects a .envrc file and loads the environment defined there. That file tells Nix to build a shell with exactly the packages specified in the project's flake.nix. When I leave the directory, direnv unloads everything. My system remains pristine.

Here's a typical setup for a Go project I work on:

# flake.nix
{
  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; [
            go_1_22
            golangci-lint
            gotools
            gopls

            # Project-specific tools
            postgresql_16
            redis
            jq

            # These versions are pinned by the flake.lock
            # Everyone on the team gets the exact same versions
          ];

          shellHook = ''
            echo "Go $(go version)"
            echo "PostgreSQL $(pg_config --version)"

            # Set up local project paths
            export PGDATA="$PWD/.postgres"
            export REDIS_DATA="$PWD/.redis"
          '';
        };
      });
}

And the .envrc file that activates it:

# .envrc
use flake

That's it. When I cd into this project, direnv loads the Nix flake, and I suddenly have Go 1.22, the exact linter version we use, PostgreSQL 16, Redis, and all the tools configured. When I cd out, they're gone. My system shell remains untouched.

The Features That Matter

Version Pinning That Actually Works

The flake.lock file pins every dependency to an exact commit. When a teammate runs this project, they get bit-for-bit identical versions of every tool. No "it works on my machine" because everyone literally has the same machine, defined in code.

Want to update dependencies? nix flake update regenerates the lock file. Want to know exactly what changed? git diff flake.lock shows you every package version change. Want to roll back? git checkout the previous lock file. It's version control for your entire development environment.

True Isolation

Each project gets its own namespace of packages. I can have one project on Node 18.17 with a specific npm version, another on Node 20 with a different npm, and a third on some ancient Node 16 for legacy maintenance. They never interfere with each other or my system packages.

This extends to environment variables too. That shellHook in the flake sets project-specific paths and configuration. When I leave the directory, those variables disappear. No more accidentally running production commands with development credentials because you forgot to switch environment files.

Instant Onboarding

New team member joins? They install Nix, clone the repo, cd into it, and run direnv allow. That's it. The entire environment builds automatically. They have the same Go version, the same linter, the same database, the same everything. Onboarding time drops from hours to minutes.

Real-World Project Examples

Different projects need different setups. Here's how I structure a few common scenarios:

Web Project with Database

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

          shellHook = ''
            # Start services automatically if not running
            if [ ! -d "$PGDATA" ]; then
              initdb --auth=trust --no-locale --encoding=UTF8
            fi
            pg_ctl start -l log/postgres.log 2>/dev/null || true
            redis-server --daemonize yes --dir "$PWD/.redis" 2>/dev/null || true

            echo "Node $(node --version)"
            echo "Database ready on localhost:5432"
          '';
        };
      });
}

Now entering the directory starts PostgreSQL and Redis automatically (if they're not already running) and stops them when I leave. The entire development stack is self-contained.

Multi-Language Project

Sometimes you need multiple ecosystems in one project:

{
  devShells.default = pkgs.mkShell {
    buildInputs = with pkgs; [
      # Backend
      go_1_22

      # Frontend
      nodejs_20
      pnpm

      # Infrastructure
      terraform
      kubectl
      awscli2

      # Common tools
      jq
      yq
      git
    ];
  };
}

Everyone on the team uses the exact same Terraform version. No more "upgrade your CLI" Slack messages when someone updates the infrastructure code.

The Honest Trade-offs

This setup isn't free. The first time someone runs a Nix environment, it can take a while to build—especially if binary caches miss. That "instant onboarding" becomes "wait 20 minutes while Nix compiles everything" on the first run. Subsequent runs are fast, thanks to caching, but that initial build can be painful.

There's also the learning curve. Nix has its own language, its own concepts, and documentation that often assumes you already understand those concepts. Teaching a junior developer Nix before they can write their first line of code is probably overkill. For simple projects, the overhead might not be worth it.

Docker Compose solves similar problems and might be a better fit if your team is already comfortable with it. The trade-off there is Docker's overhead and the need to run commands inside containers rather than your native shell. Nix gives you native performance with container-like isolation.

When This Shines

I reach for Nix + direnv when:

  • Multiple projects need different versions of the same tool
  • Team onboarding is painful and inconsistent
  • "Works on my machine" happens regularly
  • I want CI and local environments to match exactly
  • I need complex development stacks (multiple databases, message queues, etc.)

I don't reach for it when:

  • It's a quick one-off script
  • The team is new to development and already overwhelmed
  • The project only needs one common tool (like just Node.js)
  • Everyone is already happy with their setup

Getting Started

If you want to try this yourself:

  1. Install Nix with Flakes enabled: Determinate Systems installer makes this easy
  2. Install direnv: nix profile install nixpkgs#direnv (ironically) or use your system package manager
  3. Hook direnv into your shell: direnv hook bash (or zsh, fish) in your shell config
  4. Create a flake.nix in your project root (start with one of my examples above)
  5. Create .envrc with just use flake
  6. Run direnv allow in the project directory

That's it. Your environment now activates automatically.

Resources

Development environments shouldn't be something you fight with. They should be something you define once and forget about. Nix and direnv give me that—and save me from ever explaining Node version conflicts in a standup meeting again.

Asaduzzaman Pavel

About the Author

Asaduzzaman Pavel is a Software Engineer who actually enjoys the friction of a well-architected system. He has over 15 years of experience building high-performance backends and infrastructure that can actually handle the real-world chaos of scale.

Currently looking for new opportunities to build something amazing.