The Problem Docker Doesn't Fully Solve
Docker images are excellent, but they have a subtle reproducibility gap. When you write RUN apt-get install -y curl in a Dockerfile, you are asking for "whatever version of curl is current in the apt repository today." Build the same Dockerfile six months later and you may get a different version of curl, or a different version of one of curl's transitive dependencies. The image is not bit-for-bit reproducible over time.
Nix solves this at a deeper level. It is a package manager (and optionally a full operating system, NixOS) built on a single insight borrowed from functional programming: a package is a pure function of its inputs. Given the same inputs — source code, compiler version, build flags, and all transitive dependencies — you always get the same output. Always.
How Nix Stores Packages
In a traditional Linux system, installing Python puts it at /usr/bin/python. If you install a different version, it overwrites the first. Conflict.
Nix never installs anything to /usr/bin. Instead, every package lives in the Nix store at a path that includes a cryptographic hash of its entire dependency tree:
/nix/store/b6gvzjyb2pg0kjf...-python-3.11.0/bin/python
/nix/store/a1c3d9xk7m2nqr8...-python-3.9.18/bin/python
The hash b6gvzjyb2pg0kjf... is computed from the Python 3.11.0 source, the specific GCC version used to compile it, the version of glibc it links against, and every other input. If any input changes by a single byte, the hash changes, and Nix treats it as a completely different package. This means:
- 50 versions of Python can coexist on the same machine with zero conflicts.
- If two builds produce the same hash, they are guaranteed identical. No "works on my machine" — the hash is the proof.
- Rollbacks are trivial. Switching to a previous environment means pointing your
$PATHat a different set of/nix/storepaths.
Nix Flakes: Reproducible Development Shells
For development environments, the key Nix feature is nix develop (using Nix Flakes). Instead of spinning up a full Docker container, Nix modifies your current shell's $PATH to include only the specific tools you declared — and nothing else.
A flake.nix file describes the environment:
{
description = "Go + Terraform development environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-23.11";
};
outputs = { self, nixpkgs }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
in {
devShells.${system}.default = pkgs.mkShell {
buildInputs = [
pkgs.go_1_21
pkgs.terraform
pkgs.kubectl
pkgs.awscli2
];
shellHook = ''
echo "Dev environment ready."
echo "Go: $(go version)"
echo "Terraform: $(terraform version | head -1)"
'';
};
};
}
Running nix develop in a directory containing this file:
- Nix resolves the exact versions of
go,terraform,kubectl, andawscli2pinned by thenixpkgscommit referenced inflake.lock. - It downloads only the specific binaries needed (not a full OS image).
- It spawns a new shell with
$PATHmodified to include those binaries. - When you type
exit, your shell returns to normal. Nothing was installed globally.
The flake.lock file — automatically generated and committed to version control — pins the exact nixpkgs commit used. Anyone who runs nix develop against the same flake.lock gets byte-for-byte identical tools, regardless of when they run it or what OS they are on.
The Nix Learning Curve
Nix uses its own purely functional language (also called Nix) to describe packages and environments. The syntax is unfamiliar, the error messages are cryptic, and the concepts (derivations, closures, overlays) take significant time to internalize. This is not a tool you pick up in an afternoon. Most teams adopt Nix gradually — starting with nix-shell for simple use cases before moving to full Flakes.
A common interview question in platform engineering roles is: "What's the difference between Docker and Nix for managing development environments?" The key distinction is the layer at which they operate. Docker provides OS-level isolation via Linux namespaces and cgroups — you get a full filesystem and process tree. Nix operates at the package graph level — it modifies $PATH without any OS virtualization. Docker is heavier but more portable (works on any Docker host). Nix is lighter and more mathematically reproducible, but requires Nix to be installed on the host. The sophisticated answer notes that many teams use both: Nix inside a Dev Container to get the portability of Docker with the reproducibility of Nix.