Contents

Nix: Reproducible Development Environments (Once You Survive the Learning Curve)

The package manager that makes "works on my machine" a solvable problem

Contents

Every developer has lived the same small tragedy. A new colleague clones the repo, follows the README to the letter, and nothing works. The wrong version of Node. A missing system library. A make step that needs a tool nobody documented because it’s been on the lead’s laptop since 2019 and they forgot it wasn’t standard. Half a day vanishes into “what version of what do you have installed”, and the answer, always, is different from mine. Nix exists to make this entire category of misery go away — and it does, eventually, after putting you through a learning curve I will not pretend is gentle. This post is both the pitch and the warning, because I think the pitch is real and the warning is earned.

What Nix actually is

Advertisement

Nix is a package manager, but describing it that way undersells what makes it strange and powerful. Where apt or brew mutate a shared global state — installing version 18 of something overwrites version 16, and now everything on the box uses 18 whether it wanted to or not — Nix builds every package in isolation and stores it under a hash of its complete dependency graph. Two versions of the same library coexist perfectly happily because they live at entirely different paths in the Nix store, /nix/store/<hash>-<name>. Nothing is installed globally in the conventional sense at all; instead, an environment is assembled by symlinking the exact store paths you asked for into place.

The consequence is reproducibility that other tools only gesture at. If two people build from the same Nix expression, they get bit-for-bit the same dependency tree, because the inputs are pinned by hash all the way down to the C compiler. “Works on my machine” stops being a defence and becomes a guarantee that extends to everyone else’s machine too. That’s a genuinely different claim from what a Dockerfile gives you, incidentally — a Dockerfile that runs apt-get install produces a different image depending on when you build it, because the upstream package repository moved underneath you. Nix pins the repository state itself, so “when” stops mattering.

The dev shell, which is the part you actually want

Here’s the crucial thing that the intimidating reputation obscures: you do not need to convert your operating system to NixOS, and you do not need to rewrite your build, to get most of the value. The gateway drug is nix develop with a flake. A flake is a single file that pins exactly which version of the entire package set you depend on, and declares what your shell should contain.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-23.11";

  outputs = { self, nixpkgs }:
    let pkgs = nixpkgs.legacyPackages.x86_64-linux;
    in {
      devShells.x86_64-linux.default = pkgs.mkShell {
        packages = [
          pkgs.nodejs_20
          pkgs.postgresql_15
          pkgs.jq
        ];
      };
    };
}

Commit that flake.nix and its generated flake.lock to the repo. Now anyone who runs nix develop drops into a shell with precisely Node 20, Postgres 15, and jq on their PATH — the same versions, on Linux or macOS, today or in two years. The flake.lock file pins the nixpkgs revision by commit hash, so even “the latest patch of Node 20” is frozen until you choose to run nix flake update. There is no global install, no separate version manager fighting for control of PATH, no contamination of the host. Leave the shell and your system is exactly as it was.

Pair it with direnv and a one-line .envrc containing use flake, and the environment activates automatically the instant you cd into the directory and deactivates when you leave. That combination — flakes plus direnv — is genuinely the closest I’ve come to development environments that simply work, with the reproducibility landing at the level of the individual project rather than the whole machine. If the goal of a per-project, pinned, throwaway environment sounds familiar, it should: it’s the same problem devcontainers solve inside VS Code, just approached from the package-manager side rather than the container side, and the two are complementary rather than rivals.

How it compares to the things you’re already using

Advertisement

It’s worth situating Nix against the tools it competes with, because “reproducible dev environment” is a crowded field and Nix is not automatically the answer. Language-specific version managers — nvm for Node, pyenv for Python, rustup for Rust — solve the toolchain-version problem within one language, and if your project is single-language they’re lighter and far easier to learn than Nix. Where they fall down is the moment your project needs a system dependency: a specific libpq, a particular ffmpeg, an image library with a C dependency chain. Version managers shrug at that; Nix pins it with the same mechanism it pins everything else, which is the whole reason polyglot projects gravitate toward it.

Containers are the other obvious comparison, and the honest framing is that they overlap rather than compete. A container gives you an isolated runtime; Nix gives you a reproducible build and shell. You can run a container without understanding a functional language, which is a genuine advantage, but a Dockerfile full of apt-get install is only reproducible until the upstream repository changes, whereas a flake is reproducible by construction. Plenty of serious setups use both — Nix to build the artefact, a container to ship it — and Nix can even produce container images deterministically, which is a nicer story than a layered Dockerfile if you can stomach the learning cost to get there.

The binary cache is what makes it bearable

Here’s a practical point that early tutorials skip and that determines whether your first week with Nix is tolerable or miserable: Nix does not compile everything from source, despite the impression its build-graph purity gives. It fetches prebuilt artefacts from a binary cache — a substituter — keyed by the same hash that identifies the derivation. The official cache.nixos.org is enabled by default and covers the vast bulk of nixpkgs, so most of the time nix develop is downloading finished binaries, not building them.

You feel this immediately the first time you accidentally step off the cached path. Override a package with a custom patch, or pull a dependency the cache has never seen, and suddenly Nix is compiling GCC’s dependencies on your laptop and you’re wondering what you did wrong. Nothing — you just left the paved road. The lesson is to notice when a build starts compiling from source, because that’s almost always the signal that you’ve introduced a custom derivation, whether you meant to or not.

For a team, the natural next step is a shared cache of your own artefacts, so that when one person builds the project’s custom bits, everyone else downloads them instead of rebuilding. Cachix is the hosted option most teams reach for:

1
2
3
# consume a team's shared binary cache
nix run nixpkgs#cachix -- use my-team-cache
# now `nix develop` pulls prebuilt custom deps instead of compiling them

Wiring that up is genuinely the difference between “the reproducible environment everyone loves” and “the reproducible environment everyone waits ten minutes for”, and it’s the piece I’d set up second, immediately after the first working flake.

The part nobody warns you about enough

Now the honesty, because a post that only sold you the upside would be doing you a disservice. The Nix language is a lazy, functional, dynamically-typed language that you will fight. Its error messages are notorious for a reason — a single misplaced semicolon can produce a wall of output that points nowhere near the actual mistake. The documentation has improved a great deal but is still scattered across a wiki, an official manual, a pile of blog posts, and outright tribal knowledge, and you will regularly find three ways to do the same thing with no clear signal as to which is current or blessed. Flakes themselves sat behind an “experimental” flag for years and still require you to enable it:

1
2
mkdir -p ~/.config/nix
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf

The deepest pit is packaging your own software when it does something unusual — a binary that expects shared libraries at hardcoded absolute paths, say. That drags you straight into Nix’s purity model and the patching it demands, because the hashed store paths that give you all that lovely reproducibility also break every assumption a normal binary makes about where its dependencies live. This is where afternoons go to die, and it’s worth setting the expectation now so it doesn’t ambush you: patchelf, wrapProgram, and nix-ld will become words you know, whether you wanted them to or not.

Troubleshooting the early stumbles

Most people wash out on the same three rocks, so aim around them:

  • error: experimental Nix feature 'nix-command' is disabled. You skipped the config above, or you’re on a shell that didn’t reload it. Add the line, restart the shell, and it’s gone.
  • nix develop is glacial the first time. It’s building or fetching the entire pinned toolchain. Add the official binary cache (cache.nixos.org is on by default; a project may add its own) so you download prebuilt artefacts instead of compiling Postgres from source on your laptop. If it’s still slow, someone’s using a package that isn’t cached — that’s the smell of a custom derivation.
  • direnv isn’t activating. You have to direnv allow a new .envrc once, by design, so a repo can’t silently run code on cd. If it still does nothing, the direnv shell hook isn’t installed in your .bashrc/.zshrc.
  • The lock file drifts between machines. It shouldn’t — that’s the whole point — so if it does, someone committed a flake.nix change without the regenerated flake.lock. Commit them together, always.

Is it worth it?

For a solo project on one machine, probably not. The payoff scales with the number of people and machines that have to agree, and a team of one agrees with itself for free. Be ruthless about this: adopting Nix for a repo only you will ever touch is a cost with no matching benefit, and I’ve watched people do it out of aesthetics and regret the maintenance.

For a team, or for anyone juggling more than two or three projects with conflicting toolchains, the calculus flips hard. The reproducible dev shell alone — ignoring everything else Nix can do — has saved me more onboarding pain than any wiki page ever could, and there’s a reliability angle too: an environment that’s identical everywhere is one fewer variable when something misbehaves, which is the same instinct behind treating incident response as something you can reason about systematically rather than a fresh mystery every time. New contributors run one command and are productive, which is a small miracle if you’ve ever written and maintained a setup guide.

My concrete advice: ignore NixOS and the deep end entirely at first. Learn exactly enough to write a flake.nix dev shell for one real project, wire up direnv, and live with it for a month. If it sticks, the rest of the iceberg will still be there when you’re ready. If it doesn’t, you’ve lost a weekend and gained a genuinely reproducible environment for that one repo — which is not a bad trade for the worst-designed best idea in packaging.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.