<!-- Canonical: https://lucioduran.com/blog/nix-flakes-reproducible-dev-environments
Language: en
Content-SHA256: ca0f3b6e6f775da400f543bb24924b4ab12ad7b68df1c16c5b8cec244c46ed2e
-->

DevOps

# Nix Flakes as a Docker Replacement for Local Development Environments

2025-11-198 min read

Lucio Durán
Engineering Manager & AI Solutions Architect

Also available in: [Español](<https://lucioduran.com/es/blog/nix-flakes-reproducible-dev-environments>)

## Nix Flakes: Functional Overview

[Nix Flakes](<https://nixos.wiki/wiki/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:

1. **Exactly** which versions of every tool your project needs
2. **Exactly** how to build your project
3. **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:

```nix
{
 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

```nix
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 (what ` nix develop ` uses)
- ` packages.default `, the built artifact (what ` nix build ` uses)
- ` checks `, test suites (what ` nix flake check ` runs)

### devShells (The Money Feature)

```nix
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:

```nix
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:

1. One dev on an older Intel Mac had a different system architecture string. Fixed by ensuring ` flake-utils.lib.eachDefaultSystem ` was used (it was).
2. Pre-commit hooks that assumed globally-installed tools need to run inside ` nix develop `.
3. VS Code's Go extension couldn't find ` gopls ` until the ` direnv ` integration 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:

```bash
# .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:

```bash
# In your global Nix config or home-manager
nix profile install nixpkgs#direnv nixpkgs#nix-direnv
```

Add to your shell rc:

```bash
eval "$(direnv hook bash)" # or zsh
```

## Multiple devShells for Different Workflows

One ` flake.nix ` can provide multiple shells:

```nix
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:

```nix
# 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

```nix
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 gc ` periodically 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.

## Frequently asked questions

### Do I need to learn the Nix language to use Flakes?

Unfortunately, yes, at least the basics. Nix the language is functional and lazily evaluated, which trips people up. But for devShells, you mostly need to understand attribute sets, let bindings, and mkShell. The learning curve is steep but short.

### Can Nix Flakes fully replace Docker for development?

For dependency management and reproducible environments, absolutely. For cases where you need to replicate a specific Linux kernel version or test network configurations, Docker still has its place. In practice, most teams can drop Docker for local dev.

### How does Nix Flakes affect CI/CD pipeline speed?

First builds are slower because Nix downloads and builds more precisely. But subsequent builds are dramatically faster due to the binary cache. CI times can drop from 8 minutes to 2 minutes after the cache warms up.

## Tips

1. **Tip:** Always pin your ` nixpkgs ` input to a specific commit or release branch like ` nixpkgs/nixos-25.11 `, never use ` nixpkgs/nixpkgs-unstable ` for team projects.
2. **Tip:** Use ` nix develop --impure ` during exploration, but always remove ` --impure ` before committing. Impure shells hide missing dependencies that will bite your CI.
3. **Tip:** ` nix build .#dockerImage ` creates OCI images without a Dockerfile or Docker daemon, perfect for CI environments that only have Nix.
4. **Tip:** When a Nix build fails cryptically, ` nix log ` on the failed derivation path gives you the actual build output that ` nix build ` swallows.

- nix
- flakes
- devops
- docker
- reproducible-builds
- developer-experience
- cross-compilation

[Back to Blog](<https://lucioduran.com/blog>)
