Reproducible Dev Shells with Nix Flakes and direnv
Before I understood Nix, I had already spent years solving the same problem over and over, one language at a time.
Growing up in the 90s, getting software running meant downloading the right binary for your platform and wiring it into your system yourself. By the early 2010s, Node and npm became my daily tools, but they had the same fundamental problem: global installations. One version of Node, shared across every project on the machine. That worked until two projects needed different versions of the same thing.
NVM was the first tool that changed my relationship with this. It moved the runtime out of the system and into the project. Each project could declare its Node version, and NVM would switch between them. Projects started to feel isolated from each other instead of competing for the same global environment.
Then I started working across more languages — Python, Rust, Go — sometimes all in the same week, sometimes all in the same day. Each ecosystem had its own tools, its own version managers, its own way of doing environments. At some point, the problem stopped being about any one language and started being about the environment itself.
The cost of context switching
All the tools I used solved real problems. NVM solved Node. pyenv solved Python. Homebrew handled system packages. virtualenv handled Python dependencies. Rust had rustup. Go had its own tooling.
The problem was that each one came with its own commands, its own activation step, and its own mental model. The real cost wasn’t learning the tools. It was switching between them all day.
On a normal day, I might move between a Next.js project, a Python service, and a Rust or Go tool. Each one expected a different environment: different runtimes, different tools, different system libraries, different environment variables. Switching projects meant mentally unloading one stack and loading another — remember the Node version here, the Python environment there, which dependency manager this project used, whether there was a system library I installed months ago and forgot about.
Over time I realized I was spending more mental energy managing environments than writing code.
A generalized solution
Eventually I realized all my tools were solving the same class of problem independently, without knowing about each other.
I didn’t have a Node problem or a Python problem. I had an environment problem.
I didn’t need a better tool for each language. I needed a tool that didn’t care which language I was using — a way to describe the entire environment in one place and let the machine adapt to the project instead of the other way around.
That’s what Nix turned out to be.
The weekend problem
I had known about Nix for a while. It always sounded right: declarative environments, reproducible builds, language-agnostic. But it also had a reputation for being difficult to learn, and the documentation often assumed you already understood how Nix worked.1.Zero to Nix — a guided introduction to Nix concepts, from Determinate Systems.1
I had been through enough “I’ll learn this over the weekend” tools to recognize the pattern. So for a long time, Nix stayed in the category of tools that were theoretically perfect and practically too expensive to adopt.5.Determinate Nix Installer — enables flakes by default, cleaner macOS support, and a straightforward uninstall. This is what I used to get started.5
What changed was simple. The cost of getting stuck dropped.
With AI, I could try something, hit an error, describe what I was seeing, and get unstuck quickly. The learning curve was still there, but the risk of losing an entire weekend to confusion was gone. The first time I seriously tried Nix, I had flakes, direnv, and Home Manager working in a few hours. That was the moment it went from theoretical to practical.
What Nix actually is
Nix is a package manager, but that undersells it. With Nix, you describe an environment as code: runtimes, system libraries, CLI tools, environment variables.
Each project declares what it needs, and Nix builds that environment in isolation. Multiple projects can depend on different versions of the same tool without interfering with each other because nothing is shared globally.
Flakes add a lockfile that pins every dependency to an exact revision, so the environment can be reproduced later, even on a different machine.2.Nix Flakes — the modern interface to Nix, providing reproducible, composable builds with a lockfile.2
direnv makes it practical.3.direnv — loads and unloads environment variables depending on the current directory.3 It loads the project’s environment when you enter the directory and unloads it when you leave. The environment follows the folder.
Switching worlds
I started thinking about development environments like video games. Each project is its own game, with its own world, its own rules, its own save files.
One project might be Node and SQLite. Another might be Python with a specific set of system libraries. Another might be Rust and C toolchains. Each project expects a different world to exist around it, and those worlds shouldn’t interfere with each other.
Before, everything shared the same machine state. Changing the Node version for one project could affect another. Installing a system library for one could break something else. Running multiple projects at once meant being careful about what was installed and which versions were active.
With Nix, each project declares its own environment and lockfile. That environment is built in isolation and loaded when I enter the directory.
I can have multiple terminals open, each in a different project, each with a different toolchain, and they don’t touch each other.
Switching projects started to feel less like reconfiguring my machine and more like switching games.
Running multiple worlds at once
I don’t think about activating environments or switching versions anymore. I open a terminal in the project directory and start working. The environment loads automatically and stays isolated to that project.
A frontend in one window, a Python service in another, a Rust tool compiling in a third — all on the same machine, all using different runtimes and dependencies.
That’s when the abstraction clicked. I stopped managing environments and just started moving between projects.
The same character on different machines
Once I started thinking about projects this way, I started thinking about machines the same way too.
Flakes and direnv solved the problem at the project level. But I still had to set up the machine itself — shell configuration, editor, language servers, CLI tools, git configuration, secrets. That setup used to live in dotfiles and in my head.
Home Manager let me describe that environment the same way I describe project environments — as code.4.Home Manager — manage user environments using Nix, from dotfiles to packages to services.4 My shell configuration, Neovim setup, language servers, git configuration, direnv, 1Password CLI integration, and terminal configuration are all declared in Nix now.
I use two macOS machines, and they share the same configuration. Setting up a new machine is one command. My environment comes back.
If projects are like games, then Home Manager is like a player profile. When I sit down at a new machine, I load my environment and everything is there.
Pragmatism over purity
Nix has a reputation for ideological purity. I don’t personally approach it that way.
On macOS, some GUI applications behave better when installed through Homebrew or directly than through Nix. So I split the system deliberately:
- GUI apps and frequently updated desktop software — Homebrew
- CLI tools, runtimes, compilers, language servers, and dev tooling — Nix
The boundary is documented. The goal isn’t purity. The goal is that my machines behave predictably and that my projects open and run without ceremony.
What changed
Once the environment layer is abstracted away, the performance difference is hard to overstate. Context switches that used to take five or ten minutes — checking which versions, activating environments, installing missing dependencies — now take the time it takes to cd into a directory. That time compounds across a day, a week, a month of working across multiple projects.
But the bigger gain is ergonomic. I don’t carry setup knowledge in my head anymore. I don’t maintain READMEs with activation instructions. I don’t debug environment drift when something works on one machine and breaks on another. The environment is declared, versioned, and deterministic. It either builds or it tells me why.
I move between different stacks, different projects, and different machines all the time. Before, every switch meant thinking about environments — which version, which tool, which setup steps. Now I switch directories and the environment follows. I run one command on a new machine and my shell, editor, tools, and configuration come back.
Switching projects becomes cheap. Running multiple systems becomes normal. Coming back to old projects stops feeling risky.
The machine stopped being something I manage. It became something that adapts to the project I’m working on.