Nix Flakes: Functional Overview
Nix Flakes are often described with abstract terminology that obscures their practical value. A more concrete framing follows.
A Nix Flake is a flake.nix file in your repo that declares, with zero ambiguity:
- Exactly which versions of every tool your project needs
- Exactly how to build your project
- Exactly what the development environment looks like
And "exactly" means hash-pinned-to-a-specific-commit-of-nixpkgs exactly. Not "Node 20.x". Not "whatever Homebrew gives you." The specific Node.js build, compiled with specific flags, linked against specific libraries.
Here is the flake.nix from a representative production service, a Go API with a PostgreSQL dependency and some code generation tools:
{
description = "Order service API";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
flake-utils.url = "github:numtide/flake-utils";
gomod2nix = {
url = "github:nix-community/gomod2nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, flake-utils, gomod2nix }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ gomod2nix.overlays.default ];
};
in {
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
go_1_23
gopls
gotools
go-mockery
sqlc
postgresql_16
pgformatter
protobuf
protoc-gen-go
protoc-gen-go-grpc
buf
just
docker-compose
];
shellHook = ''
export GOPATH="$PWD/.go"
export PATH="$GOPATH/bin:$PATH"
export PGDATA="$PWD/.pgdata"
export PGHOST="$PWD/.pgdata"
echo "Order service dev shell ready"
echo "Go $(go version | cut -d' ' -f3)"
echo "PostgreSQL $(pg_config --version)"
'';
};
packages.default = pkgs.buildGoApplication {
pname = "order-service";
version = "0.1.0";
src = ./.;
modules = ./gomod2nix.toml;
};
});
}
When someone runs nix develop, they get a shell with every single tool at the exact version specified. Go 1.23. PostgreSQL 16. protoc, buf, sqlc, all of it. On Linux, on macOS, on Intel, on ARM. The same versions. Every time.
The Anatomy of flake.nix (The Parts That Matter)
The following breaks down the structure, since the Nix docs make this harder than necessary:
inputs
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
flake-utils.url = "github:numtide/flake-utils";
};
These are your dependencies. nixpkgs is the package repository (over 100,000 packages). The URL pins to a specific branch. When you run nix flake update, it updates flake.lock with the exact commit hash, like a package-lock.json but for your entire system toolchain.
outputs
This is where you define what your flake provides. The most common outputs for a development project:
devShells.default, the development environment (whatnix developuses)packages.default, the built artifact (whatnix builduses)checks, test suites (whatnix flake checkruns)
devShells (The Money Feature)
devShells.default = pkgs.mkShell {
buildInputs = [ ... ]; # Tools available in the shell
shellHook = ''...''; # Runs when entering the shell
env = { ... }; # Environment variables
};
mkShell creates an environment, not a container. There's no isolation overhead, no volume mounts, no network bridge. Your editor, your terminal, your git config, they all work normally. You just have different tools on your PATH.
Why This Beats Docker for Local Dev
Precision matters here because this is where people get defensive. Docker is excellent for deployment. Nix isn't replacing your production containers. What Nix replaces is the Dockerfile.dev and docker-compose.dev.yml that you maintain separately from your production setup.
Speed: nix develop on a warm cache takes 0.3 seconds. Starting a Docker Compose dev environment with 4 services: 12-20 seconds. Over a day of work, that adds up.
Resource usage: A Nix devShell uses zero additional RAM beyond the tools themselves. Docker Desktop on macOS reserves a VM with 4-8GB of RAM by default.
File system performance: This is the critical factor on macOS. Docker's file system sharing (even with VirtioFS) adds measurable latency to every file operation. For a go test ./... on a typical service: 4.1s native, 7.8s in Docker. For npm install on a mid-size project: 12s native, 34s in Docker.
Debugging: When something in your Nix shell doesn't work, you can inspect it. which go shows you the exact Nix store path. nix why-depends traces the dependency chain. In Docker, you're SSHing into a container and guessing.
A Real Comparison
Migration checklist, what gets replaced:
| Docker-based | Nix-based |
|---|---|
Dockerfile.dev (47 lines) |
flake.nix (43 lines) |
docker-compose.dev.yml (89 lines) |
, (services run natively) |
.env.docker |
, (shellHook sets env vars) |
scripts/setup-dev.sh (112 lines) |
, |
scripts/fix-permissions.sh |
, |
docs/SETUP.md (47 steps) |
docs/SETUP.md (3 steps) |
The three steps: 1) Install Nix, 2) Run nix develop, 3) Run just dev.
Cross-Compilation: Nix's Secret Weapon
This is a genuinely surprising feature. Nix's cross-compilation story is absurdly good:
packages = {
default = pkgs.buildGoApplication {
pname = "order-service";
version = "0.1.0";
src = ./.;
modules = ./gomod2nix.toml;
};
# Cross-compile for ARM64 Linux (for Graviton deployment)
arm64-linux = pkgs.pkgsCross.aarch64-multiplatform.buildGoApplication {
pname = "order-service";
version = "0.1.0";
src = ./.;
modules = ./gomod2nix.toml;
};
# Build a Docker image without Docker
dockerImage = pkgs.dockerTools.buildLayeredImage {
name = "order-service";
tag = "latest";
contents = [ self.packages.${system}.default ];
config = {
Cmd = [ "/bin/order-service" ];
ExposedPorts = { "8080/tcp" = {}; };
};
};
};
That dockerImage output is beautiful. It builds an OCI-compliant container image without needing Docker installed. The resulting image is typically 10-30MB because it only includes exactly what the application needs, no base image bloat, no leftover build tools.
ARM64 Linux images can be built on macOS developer machines for testing. No QEMU, no emulation. Nix just cross-compiles.
Migration: An Honest Account
This process is not always smooth. Here is a representative timeline of migrating a team of 6:
Week 1: Writing the flake.nix and getting it working on one machine. This takes about 3 days due to fighting the Nix language. The error messages are... not great. error: attribute 'go_1_23' missing when there was actually a typo in the overlay. Helpful.
Week 2: Two other senior devs install Nix and try the flake. Immediate issue: a developer's corporate VPN blocks the Nix binary cache (cache.nixos.org). Setting up an internal cache on S3 using Attic takes a full day.
Week 3: Full team rollout. Three issues typically arise:
- One dev on an older Intel Mac had a different system architecture string. Fixed by ensuring
flake-utils.lib.eachDefaultSystemwas used (it was). - Pre-commit hooks that assumed globally-installed tools need to run inside
nix develop. - VS Code's Go extension couldn't find
goplsuntil thedirenvintegration is added.
Week 4: Everyone is on Nix. The Docker dev setup gets deleted. No complaints.
direnv Integration: The Missing Piece
Raw nix develop requires manually entering the shell every time a terminal opens. direnv with nix-direnv automates this:
# .envrc
use flake
That's the entire file. When you cd into the project directory, direnv automatically loads the Nix devShell. Your editor picks up the tools. Your terminal has the right PATH. It's invisible once configured.
Install it once:
# In your global Nix config or home-manager
nix profile install nixpkgs#direnv nixpkgs#nix-direnv
Add to your shell rc:
eval "$(direnv hook bash)" # or zsh
Multiple devShells for Different Workflows
One flake.nix can provide multiple shells:
devShells = {
default = pkgs.mkShell {
buildInputs = with pkgs; [ go_1_23 gopls postgresql_16 ];
};
ci = pkgs.mkShell {
buildInputs = with pkgs; [ go_1_23 postgresql_16 golangci-lint ];
};
docs = pkgs.mkShell {
buildInputs = with pkgs; [ mdbook graphviz plantuml ];
};
};
nix develop enters the default shell. nix develop .#ci enters the CI shell. nix develop .#docs enters the documentation shell. Each is hermetic and reproducible.
The Binary Cache: Why Second Builds Are Fast
First-time nix develop can be slow, minutes if packages need building from source. But Nix has a binary cache (cache.nixos.org) that provides pre-built binaries for most packages. After your first run, subsequent nix develop calls are nearly instant because everything is cached in the local Nix store.
For teams, setting up a shared cache is strongly recommended. Attic on an S3 bucket works well:
# In your nix.conf or flake's nixConfig
extra-substituters = https://your-team-cache.s3.amazonaws.com
extra-trusted-public-keys = your-team-cache:AAAA...==
CI builds populate the cache. Developer machines consume from it. A full nix develop with warm cache takes under a second.
Flake Checks: Your CI's Best Friend
checks.${system} = {
build = self.packages.${system}.default;
lint = pkgs.runCommand "lint" {
buildInputs = [ pkgs.golangci-lint ];
} ''
cd ${self}
golangci-lint run ./...
touch $out
'';
test = pkgs.runCommand "test" {
buildInputs = [ pkgs.go_1_23 pkgs.postgresql_16 ];
} ''
cd ${self}
go test -race -count=1 ./...
touch $out
'';
};
nix flake check runs all checks. In CI, this guarantees the exact same tool versions as local development. No more "but it works on my machine", it literally cannot differ.
Honest Assessment
Nix Flakes have real downsides:
- The learning curve is steep. The Nix language is weird. The documentation is scattered. Error messages are often unhelpful.
- The ecosystem is smaller. You won't find a Stack Overflow answer for every problem.
- macOS support is second-class. It works, but you'll hit edge cases that Linux users never see.
- Nix store disk usage grows. Run
nix store gcperiodically or you'll lose 50GB before you notice.
The bottom line: the time spent learning Nix pays back within the first month. Every new hire since then is productive on day one. CI becomes deterministic. Builds are reproducible across platforms. And 250 lines of shell scripts that no one understood and everyone was afraid to touch get deleted.
That's worth a weird programming language.