Nix Flakes: The Cult You Should Probably Join
Introduction
"It works on my machine."
This sentence is the tombstone of productivity. It is the lie we tell ourselves when a new hire spends three days trying to get the backend running because they have postgres version 15 installed via Homebrew, but the project requires version 14.
We fixed deployment with Docker. We containerized production. But local development? It’s still the Wild West. We rely on README.md files that are six months out of date, nvm commands we forget to run, and the hope that brew upgrade doesn't break our entire Python environment.
There is a better way. But I must warn you: it requires joining a cult.
The cult is called Nix. And specifically, its modern, controversial, and "experimentally stable" subset: Nix Flakes.
The Problem: Global State is the Enemy
Why is setting up a dev environment so painful? Because your operating system is a giant ball of mutable global state.
When you run npm install -g typescript, you are making a global mutation. When you install Python via Homebrew, you are making a global mutation. Even nvm and rbenv are just shell hacks to patch over the fact that your OS thinks there should only be one version of Node.js installed at a time.
The Docker Trap
"Just use Docker Compose!" I hear you scream.
Docker is amazing for running services. It is terrible for developing them.
- Latency: Running
npm installinside a container on macOS is roughly 10x slower than native due to filesystem bridging. - Tooling: Connecting your IDE (VS Code, JetBrains) to the language server running inside the container is a configuration nightmare.
- Experience: You lose your shell aliases, your
zshtheme, and your dignity.
We need something that feels native (runs on your metal) but is isolated (doesn't touch /usr/local).
The Solution: Nix Flakes + Direnv
Nix is a package manager, but calling it that is like calling the Space Shuttle a "glider." It is a purely functional deployment tool. It treats your environment as a graph of dependencies.
Nix Flakes are the "package.json" for your entire OS. They allow you to define exactly what binaries, compilers, and tools your project needs, pinned to the exact git commit hash.
The Magic Workflow
Imagine this:
- You
cdinto a project directory. - Your prompt changes.
- Suddenly,
nodeis version 20.11.0.postgresis 14.2.cargois available. - You
cdout. They are gone.
No docker-compose up. No nvm use. It just works. This is the power of Direnv hooked into Nix.
# .envrc
use flake
That's it. That is the entire configuration file for Direnv. It tells your shell: "Ask Nix what environment this folder needs, and load it."
The flake.nix (The Contract)
Here is what the configuration looks like. Yes, the syntax is weird. It looks like JSON had a baby with Haskell. Embrace the weirdness.
{
description = "A reproducible 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 = nixpkgs.legacyPackages.${system};
in
{
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
nodejs_20
postgresql_14
go
# Even weird stuff like legacy C libraries
imagemagick
];
shellHook = ''
echo "Welcome to the Cult."
export PGDATA=$PWD/.data/pg
'';
};
}
);
}
This file guarantees that everyone on your team, whether they are on macOS (Apple Silicon), Linux, or (God forbid) Windows WSL, gets the exact same binary version of imagemagick. Not "version 7-ish." The exact hash.
Devenv: Nix for Mortals
If the code snippet above made your eyes bleed, you are not alone. Nix language is... hostile.
Enter devenv.sh. It is a wrapper around Nix Flakes that provides a sane, YAML-like (but actually Nix) syntax for common tasks.
# devenv.nix
{ pkgs, ... }: {
# Enable languages
languages.javascript = {
enable = true;
npm.install.enable = true;
};
languages.rust.enable = true;
# Start services automatically!
services.postgres = {
enable = true;
initialDatabases = [{ name = "mydb"; }];
};
# Project-specific scripts
scripts.hello.exec = "echo Hello from Nix!";
}
With devenv, you get:
- Process Management: It creates a
devenv-upcommand that starts Postgres and Redis in the background (native speed, no Docker overhead). - Pre-commit hooks: Define linters that run in the exact same environment as CI.
Trade-offs: The Price of Admission
I told you this was a cult. Cults require sacrifice.
1. The Learning Curve
Nix is hard. The documentation is scattered across three decades of wikis, Discourse threads, and obscure blog posts. The error messages are cryptic ("infinite recursion encountered at..."). You will spend the first week wondering why you are doing this.
2. Disk Space
Nix does not overwrite files; it versions them. This means /nix/store will grow indefinitely. You will eventually look at your disk usage and see Nix eating 50GB. You will need to learn about nix-collect-garbage.
3. The "Experimental" Flag
Nix Flakes are technically "experimental." You have to enable them explicitly in your config. This has been true for years. It is a running joke. The entire ecosystem depends on an experimental feature. Welcome to modern software engineering.
Conclusion: Stop installing things globally
If you take one thing away from this article: Stop using brew install for project dependencies.
Your project dependencies belong in your project, described by code, versioned by Git. Nix Flakes give you the isolation of Docker with the ergonomics of a native shell.
Is it overkill for a "Hello World" app? Yes. Is it the only sane way to manage a team of 10 engineers working on a monolith with 4 different language runtimes? Absolutely.
What to do next:
Install Nix (use the Determinate Systems installer, it's the only good one). Install direnv. Create a flake.nix in your messiest project. Delete your node_modules. Be free.