Every project has a setup document. It starts with “install Ruby 3.3”, wanders through Homebrew formulas, Node version managers, a Postgres database, and a handful of environment variables, and it is out of date by the second commit. Nix flakes replace that document with a single file that declares exactly which tools a project needs, pins their versions in a lock file, and produces the same shell on every developer’s machine, every CI/CD runner, and every AI agent’s sandbox. The result is a reproducible, disposable development environment that is isolated from everything else installed on the host. When a new engineer, a build agent, or an AI coding assistant needs to work on the project, the environment is one command away and guaranteed to match everyone else’s environment.
A bit of history
Nix started in 2003 as Eelco Dolstra’s PhD research at Utrecht University. His 2006 thesis, The Purely Functional Software Deployment Model, made an observation that seems obvious in hindsight: most of the pain in installing software comes from packages sharing a global namespace like /usr/lib. Two programs that need different versions of the same library cannot coexist; upgrades are destructive, and there is no reliable way to roll back.
Nix’s answer was to treat a package the way a functional language treats a value. Every package is built from a pure function of its inputs, and the output lands in a directory whose name includes a cryptographic digest of every input that went into it. The Nix Reference Manual describes a store path as a 20-byte digest “for identification” followed by “a symbolic name for people to read”:
/nix/store/q06x3jll2yfzckz2bzqak089p43ixkkq-firefox-33.1
Change a single dependency, and you get a different digest and a different directory. Nothing is ever modified in place, so any number of versions of Ruby can live side by side, and “installing” a program is just a matter of putting a symlink to the right store path on your PATH. NixOS, an entire Linux distribution built on this idea, started as Armijn Hemel’s master’s thesis in 2006 and shipped its first stable release in 2013.
The development environment use case was almost an accident. Because Nix creates a complete, isolated build environment for every package it builds, someone eventually asked the obvious question: what if we set up that environment and then dropped into a shell instead of running the build? That question became nix-shell.
Developers began writing shell.nix files that declared a project’s toolchain, and a language designed for packaging operating systems became one of the most reliable ways to say “this project needs Go 1.25, PostgreSQL 18, and golangci-lint, and nothing you have installed on your laptop should matter.” If you want the long version of this story, Nix Pills walks through it from first principles.
How do shell hooks work anyway?
The trick behind nix-shell and its successor nix develop is that a development shell is just a package that never gets built. When we write a mkShell, Nix creates a derivation (its word for a build recipe) the same way it would for any package. Instead of executing the build, it performs the setup phase and then hands us the resulting environment:
- Every package listed in
packagesis realized in the Nix store, downloading it from a binary cache or building it if necessary. - The
bindirectories of those packages are prepended toPATH, and any other attributes we set on the shell (PGHOST,LIBCLANG_PATH, and so on) are exported as environment variables. - Finally, the contents of
shellHookare run as bash inside the new shell.
The shellHook is where the shell stops being a static list of packages and starts behaving like a project. Because it runs as ordinary bash in the shell we are about to use, it can export variables that depend on the current directory, create cache folders inside the repo, print a menu of available commands, or verify that the toolchain is the one we expect. Here is the hook from one of our Ruby on Rails projects:
shellHook = ''
# Keep gems inside the repo so nothing is installed into $HOME
export GEM_HOME="$PWD/.gem"
export GEM_PATH="$GEM_HOME"
export BUNDLE_APP_CONFIG="$GEM_HOME"
export PATH="$GEM_HOME/bin:$PATH"
# Use the Nix toolchain for native extensions
export CC="${pkgs.stdenv.cc}/bin/cc"
export CXX="${pkgs.stdenv.cc}/bin/c++"
# Avoid host/Homebrew flags leaking into extconf/mkmf
unset SDKROOT
unset CFLAGS CXXFLAGS CPPFLAGS LDFLAGS
set -a
source ./server/.env.development
set +a
'';
Notice the two kinds of interpolation at play. ${pkgs.stdenv.cc} is evaluated by Nix before the shell ever runs and becomes an absolute /nix/store/... path. $PWD is left alone for bash to expand at runtime. Getting comfortable with that boundary is most of what it takes to write good hooks. The nix.dev tutorial Declarative shell environments with shell.nix is a good place to practice.
What makes flakes better than the old way?
shell.nix worked, but it had a dirty secret. The first line of nearly every shell was:
{ pkgs ? import <nixpkgs> {} }:
That <nixpkgs> is resolved through an environment variable called NIX_PATH, which points to whatever channel the user happens to subscribe to. Two developers running the same shell.nix on the same day could get different versions of every tool, and the person who set up the project six months ago couldn’t reproduce their own environment. Pinning was possible, but it meant hand-writing fetchTarball calls with hashes or adopting a third-party tool like niv. When Dolstra introduced flakes in a 2020 post on the Tweag blog, he summarized the two old options bluntly: “The former has poor reproducibility, while the latter provides a bad user experience because of the need to update Git hashes manually to update dependencies.”
RFC 49 proposed flakes in 2019, and Nix shipped them as an experimental feature in 2.4 in November 2021. They fix the problem by making inputs explicit and locking them:
“A flake is a filesystem tree (typically fetched from a Git repository or a tarball) that contains a file named
flake.nixin the root directory.”
—Nix Reference Manual, nix flake
In practice, that gives us four things the traditional workflow never had:
- A lock file. The first time we run
nix develop, Nix writesflake.lockwith the exact git revision (rev) and content hash (narHash) of every input. Commit it, and every developer and every CI run evaluates the samenixpkgsuntil someone deliberately runsnix flake update. This is the same idea asGemfile.lockorpackage-lock.json, except it also locks the compilers and system libraries. - Explicit inputs.
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"replaces the implicitNIX_PATH. Third-party inputs likerust-overlayare declared the same way, andinputs.nixpkgs.follows = "nixpkgs"ensures they all share one copy ofnixpkgsinstead of each dragging in their own. - A standard output schema. Flakes have well-known output names:
devShells,packages,apps,formatter,checks. Tooling can rely on them, sonix develop,nix run,nix build, andnix fmtall work on any flake without reading its source.nix flake showprints everything a flake offers. - Pure evaluation by default. Flake commands evaluate Nix code in pure mode, so they can’t read environment variables, the
NIX_PATHsearch path, or files outside the flake’s tree unless you pass--impure. That is what makes the lock file a complete description of the inputs and lets evaluation results be cached. This applies only to evaluation. The shellnix developopens still inherits your hostPATHand environment; more on that later.
Why separate the dev environment from the host?
The main benefit is the obvious one: “works on my machine” becomes a thing of the past. But a few second-order benefits are just as valuable.
The environment is reusable in CI. Because the flake is a complete description of the toolchain, a CI job does not need a Dockerfile or a pile of setup-* actions. It installs Nix with the nix-installer-action and runs the same commands a developer runs:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main
- run: nix develop --command devops-check
If it passes locally, it passes in CI, because it is literally the same Go binary and the same golangci-lint down to the store hash.
Many disparate projects coexist on one machine. We work across a Rails app on Ruby 3.3 and Postgres 18, a Go CLI that needs goreleaser and the AWS session manager plugin, and a Rust service with a pinned LLVM and a Java 21 runtime for architecture tooling. None of that is installed globally. Each project’s shell brings its own toolchain, and leaving the directory leaves it behind. No rbenv, nvm, pyenv, or gvm to keep in sync because Nix is all of them at once.
The host stays clean. Our flakes deliberately keep language caches inside the repo (.gem, .go, .gocache, target/nix) so that nothing is written to $HOME. Deleting the project directory removes every trace. A new laptop is Nix plus a git clone.
Host tooling cannot leak in. This one is subtle. Even with Nix on the PATH, Cargo and Ruby’s mkmf will happily read CFLAGS or LDFLAGS that Homebrew or Xcode left in the environment and link against host libraries. Our Rust flake unsets those variables on the way in and then verifies that every build tool resolves to /nix/store:
devShellBuildToolCommands = [
"cargo" "rustc" "cc" "clang" "ld" "ar" "ranlib"
"libtool" "pkg-config" "cmake" "make" "node" "git" "nix"
];
for build_tool in ${pkgs.lib.concatStringsSep " " devShellBuildToolCommands}; do
build_tool_path="$(command -v "$build_tool" || true)"
case "$build_tool_path" in
/nix/store/*) ;;
*)
printf 'build tool %s resolved outside /nix/store: %s\n' \
"$build_tool" "''${build_tool_path:-missing}" >&2
exit 1
;;
esac
done
If cc ever turns out to be Apple’s clang instead of the one the flake asked for, the shell refuses to start rather than producing a binary that only works on one laptop.
Where do I start?
The fastest path is the Determinate Nix Installer, which “enables Nix with flakes enabled by default” and keeps an installation receipt so that /nix/nix-installer uninstall removes it cleanly:
curl -fsSL https://install.determinate.systems/nix | sh -s -- install
If you prefer the upstream installer, add the following to ~/.config/nix/nix.conf afterward, since flakes are still formally experimental there:
experimental-features = nix-command flakes
Open a new terminal and confirm it works without installing anything:
nix run nixpkgs#hello
Now let’s set up a project environment. Create a flake.nix in the repo root:
{
description = "Development environment for my-app";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
};
outputs = { nixpkgs, ... }:
let
systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forAllSystems = f: nixpkgs.lib.genAttrs systems f;
in
{
devShells = forAllSystems (system:
let
pkgs = import nixpkgs { inherit system; };
in
{
default = pkgs.mkShell {
packages = [
pkgs.nodejs_24
pkgs.postgresql_18
pkgs.git
];
PGHOST = "localhost";
shellHook = ''
echo "node $(node --version), psql $(psql --version | cut -d' ' -f3)"
'';
};
}
);
};
}
Then:
git add flake.nix
nix develop
That’s it. The first run writes flake.lock, downloads Node and Postgres from the binary cache, and drops you into a bash shell with both on the PATH. Commit the lock file, and you’re off to the races.
Now the fun part 😀
A dev shell that installs tools is table stakes. The patterns below are what made flakes click for us, pulled from three real projects: a Rails application, a Go CLI, and a Rust service.
1) Skip flake-utils and write forAllSystems yourself
Most tutorials reach for the flake-utils input to iterate over systems. It is a fine library, but it is an extra input to lock and update for what amounts to one line of code:
forAllSystems = f: nixpkgs.lib.genAttrs systems f;
lib.genAttrs will “generate an attribute set by mapping a function over a list of attribute names,” so forAllSystems (system: ...) produces { x86_64-linux = ...; aarch64-darwin = ...; }, which is exactly the shape flake outputs expect.
2) Make project commands real programs with writeShellApplication
The single most useful pattern we have adopted is to stop writing Makefile targets and bin/ scripts and instead define every project command as a writeShellApplication. Each command declares the tools it needs in runtimeInputs, and Nix wraps it with a PATH containing only those tools:
# Define our maintenance commands
# - This is convenient because it simplifies the command definitions
# and we can output the list as a helper command, e.g. devops-menu.
maintenanceCommands = pkgs: [
{
name = "devops-build";
description = "Build bin/devops (static, version from git)";
runtimeInputs = [ pkgs.go pkgs.git ];
text = ''
version="$(git describe --tags --always --dirty 2>/dev/null || echo dev)"
go build -trimpath -ldflags "-s -w -X main.version=''${version}" -o bin/devops .
'';
}
{
name = "devops-lint";
description = "Run golangci-lint";
runtimeInputs = [ pkgs.go pkgs.git pkgs.golangci-lint ];
text = ''
golangci-lint run "$@" ./...
'';
}
# ...
];
# Render the commands
maintenanceScripts = pkgs:
map
(c: pkgs.writeShellApplication {
inherit (c) name runtimeInputs;
text = goWorkspaceEnv + c.text;
})
(maintenanceCommands pkgs);
3) Pin exact versions with overrideAttrs, and use SRI hashes
Sometimes nixpkgs does not have the precise patch release a project needs. Our Rails app is locked to Ruby 3.3.11, so we use overrideAttrs to swap the source of the ruby_3_3 package:
ruby = pkgs.ruby_3_3.overrideAttrs (old: rec {
version = "3.3.11";
src = pkgs.fetchurl {
url = "https://cache.ruby-lang.org/pub/ruby/3.3/ruby-${version}.tar.gz";
hash = "sha256-WfD6+xpZoF3DdlEXrz+mjhU+tIJUcIVJ8yHB6eB416A=";
};
});
4) Pull in things that are not in nixpkgs
Not everything is packaged, and that is fine. Our Rust service uses Alloy and TLA+ for architecture verification, both of which ship as jar files on GitHub. fetchurl plus a thin writeShellApplication wrapper turns a jar into a first-class command with a pinned Java runtime:
mkAlloyCli = pkgs:
let
alloyJar = pkgs.fetchurl {
url = "https://github.com/AlloyTools/org.alloytools.alloy/releases/download/v6.2.0/org.alloytools.alloy.dist.jar";
hash = "sha256-a4wctbyTvt/HxhQ1xOGrbmiKJC3HAqOUYo2amAHtt40=";
};
in
pkgs.writeShellApplication {
name = "alloy-cli";
runtimeInputs = [ pkgs.jdk21_headless ];
text = ''
exec java -Djava.awt.headless=true -jar "${alloyJar}" "$@"
'';
};
5) Use overlays for toolchains nixpkgs does not track closely
Rust moves faster than a stable nixpkgs branch. rust-overlay, a “pure and reproducible nix overlay of binary distributed rust toolchains”, gives us rust-bin.stable.latest with the components we need, and the follows line keeps it on our nixpkgs instead of its own:
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
# ...
mkPkgs = system: import nixpkgs {
inherit system;
overlays = [ rust-overlay.overlays.default ];
};
mkRustToolchain = pkgs: pkgs.rust-bin.stable.latest.default.override {
extensions = [ "clippy" "rustfmt" "rust-src" "rust-analyzer" ];
};
The Nixpkgs manual has a full chapter on overlays if you want to write your own.
6) Share environment setup between the shell and the commands
If a shellHook exports GOPATH into the repo but nix run .#devops-test does not, the command will behave differently depending on whether you entered the shell first. Our Go flake defines the workspace environment once as a string and concatenates it into both:
goWorkspaceEnv = ''
root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cd "$root"
export GOPATH="$root/.go"
export GOMODCACHE="$root/.go/pkg/mod"
export GOCACHE="$root/.gocache"
export GOTOOLCHAIN=local
export CGO_ENABLED=0
'';
maintenanceCommands = pkgs: [
{
name = "devops-test";
description = "Run the test suite (accepts go test flags)";
runtimeInputs = [ pkgs.go pkgs.git ];
text = ''
go test "$@" ./...
'';
}
# ...
];
maintenanceScripts = pkgs:
map
(c: pkgs.writeShellApplication {
inherit (c) name runtimeInputs;
text = goWorkspaceEnv + c.text;
})
(maintenanceCommands pkgs);
7) Set static environment variables as attributes, not in the hook
Anything that does not depend on runtime state belongs directly on mkShell, where it is visible at a glance and does not need bash quoting:
default = pkgs.mkShell {
packages = [ ruby pkgs.postgresql_18 pkgs.nodejs_24 pkgs.libpq pkgs.libyaml ];
PGHOST = "localhost";
PGUSER = "postgres";
RAILS_ENV = "development";
PORT = "8000";
shellHook = ''
# runtime-dependent setup only
'';
};
IDE integration, direnv, caching, and garbage collection
direnv is the glue. nix develop gives you a bash shell that isn’t your shell, and your editor never sees it. direnv fixes both. It “augments existing shells with a new feature that can load and unload environment variables depending on the current directory,” which means the flake’s environment lands in whatever shell you are already using when you cd into the project. Install direnv and nix-direnv, hook direnv into your shell as described in the nix.dev direnv recipe, then drop a one-line .envrc into the repo:
use flake
Run direnv allow once. From then on, entering the directory exports the flake’s PATH and variables, and leaving it removes them.
nix-direnv is the cache. Plain direnv would re-evaluate the flake on every cd, which takes a few seconds. nix-direnv caches the evaluated shell in .direnv/ and only re-evaluates when flake.nix or flake.lock changes. Critically, its README notes that it also “prevents garbage collection of build dependencies by symlinking the resulting shell derivation in the user’s gcroots,” so the tools it references survive a nix-collect-garbage.
Editors follow direnv. The VS Code direnv extension loads the .envrc into the editor process, so the integrated terminal, rust-analyzer, gopls, and the Ruby LSP all find the flake’s binaries. JetBrains has an equivalent Direnv Integration plugin. This is also why our Rust flake copies the standard library sources out of the read-only store into a writable .rust-src/ inside the repo: IDEs want to index and annotate those files, and /nix/store will not let them.
Garbage collection. Nix never deletes anything on its own, so every toolchain you have ever used stays in /nix/store until you ask. Two commands handle it:
# delete old profile generations, then remove everything unreachable from a GC root
nix-collect-garbage -d
# deduplicate identical files with hard links
nix store optimise
The first is always safe because the collector only removes paths that no GC root points to. Your profiles, result symlinks, and nix-direnv’s .direnv/ link are all roots, so active projects are untouched. The second deduplicates the store and routinely frees gigabytes.
Future proof
A flake.nix plus its flake.lock is a fully codified development environment. There is no README step that a human has to interpret, no “make sure you have the right Xcode version”, and no dependence on what happens to be installed. Everything a project needs to be built, tested, and run is declared in one machine-readable file and pinned to the byte.
That property mattered when the environment’s consumer was a new hire or a CI runner. It matters far more now that the consumer is increasingly an AI coding agent. An agent working in a sandbox doesn’t have your dotfiles, your Homebrew packages, and can’t ask you which Node version the project uses. Hand it a flake, and the problem disappears:
- The environment is discoverable.
nix flake showlists every shell, package, and app. - The commands are safe to call. Because each
writeShellApplicationcarries its ownruntimeInputs, an agent can runnix run .#devops-checkfrom a bare container and get the same result a developer gets locally. - The results are reproducible. When an agent reports that tests pass, the lock file guarantees it runs the same compiler and linter you will. When it opens a pull request that adds a dependency, the diff to
flake.nixandflake.lockis reviewable like any other code change. - The host stays clean. Everything the agent installs or caches lands in the repo or the Nix store, never in
$HOMEor/usr/local. Our Rust flake goes further and refuses to start if any build tool resolves outside/nix/store, so neither a human nor an agent can accidentally produce an artifact that depends on the host.
The same file that lets a teammate onboard in one command lets an autonomous agent onboard in one command, and it will still work in five years when the laptop, the OS, and the agent have all been replaced.
Potential downsides
Nix is not free. The Nix language is small but unfamiliar, and error messages from deep in nixpkgs can be opaque until you have seen a few. macOS in particular has sharp edges around Xcode SDKs and linking, which is exactly why our flakes go to such lengths to unset host compiler flags and pin libiconv. Flakes remain formally experimental upstream, and nix.dev still describes them as “an experimental extension format with outstanding issues,” so the interface could in principle change. In practice, the community has standardized on them, and the Determinate installer ships them enabled by default.
That’s a wrap!
Nix flakes take an idea from 2006: software should be built from pure functions of its inputs, and turn it into a practical answer to a very old problem: how do we make sure everyone and everything that touches a project is working in the same environment? A flake.nix declares the toolchain; flake.lock pins it, writeShellApplication turns project tasks into self-contained programs, and direnv makes it invisible in daily use. The same environment runs on a developer laptop, a CI runner, and an AI agent’s sandbox without modification. After using Nix dev environments for the past year and working across over a dozen different build environments, it’s hard to imagine working any other way.
Loved the article? Hated it? Didn’t even read it?
We’d love to hear from you.