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 flakeThat'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:
- Install Nix with Flakes enabled: Determinate Systems installer makes this easy
- Install direnv:
nix profile install nixpkgs#direnv(ironically) or use your system package manager - Hook direnv into your shell:
direnv hook bash(or zsh, fish) in your shell config - Create a flake.nix in your project root (start with one of my examples above)
- Create
.envrcwith justuse flake - Run
direnv allowin the project directory
That's it. Your environment now activates automatically.
Resources
- direnv documentation - Hook setup for your shell
- nix.dev - Practical Nix guide, especially the flakes tutorial
- Zero to Nix - Beginner-friendly intro
- My dotfiles - Real-world examples of development shells
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.
