Nix Reproducible Dev Environments with Flakes
Nix reproducible dev environments have emerged as the gold standard for teams that are tired of “works on my machine” problems. Unlike Docker-based dev containers that abstract the OS, Nix provides bit-for-bit reproducible toolchains that run natively on your machine. With Nix Flakes — the modern interface for Nix — you get lockfiles, composability, and a dramatically improved developer experience.
This guide walks you through setting up Nix Flakes for real-world projects, from a simple Node.js app to a polyglot microservices repository. You will learn how to define dev shells, pin dependencies, integrate with CI pipelines, and avoid common pitfalls that trip up Nix newcomers.
Why Nix Over Docker Dev Containers
Docker dev containers run your tools inside a Linux container, which means you lose native macOS/Windows integration — file watching is slow, GUI tools do not work directly, and bind mount performance is poor. Nix takes a fundamentally different approach by installing packages into an immutable store (/nix/store) and creating isolated shell environments that reference those packages.
The result is native performance, instant shell startup, and complete reproducibility across machines. Moreover, every dependency is tracked by a cryptographic hash, so two developers with the same flake.lock are guaranteed to have identical tools — down to the exact compiler version and shared library paths.
Installing Nix and Enabling Flakes
Start by installing Nix using the Determinate Systems installer, which enables Flakes by default and works on macOS, Linux, and WSL2:
# Install Nix with Flakes enabled (recommended installer)
curl --proto '=https' --tlsv1.2 -sSf \
-L https://install.determinate.systems/nix | sh -s -- install
# Verify installation
nix --version
# nix (Nix) 2.21.0
# Check Flakes support
nix flake --helpIf you already have Nix installed without Flakes, add experimental features to your configuration:
# ~/.config/nix/nix.conf
experimental-features = nix-command flakesYour First flake.nix
A Flake is defined by a flake.nix file at the root of your project. It declares inputs (dependencies like nixpkgs) and outputs (dev shells, packages, etc.). Here is a practical example for a Node.js + Python project:
{
description = "Full-stack web application dev environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
# Node.js toolchain
nodejs_20
nodePackages.pnpm
nodePackages.typescript
# Python toolchain
python312
python312Packages.pip
python312Packages.virtualenv
# Database tools
postgresql_16
redis
# Dev utilities
jq
httpie
just # command runner
];
shellHook = ''
echo "Dev environment loaded!"
echo "Node: $(node --version)"
echo "Python: $(python --version)"
echo "PostgreSQL: $(psql --version)"
'';
# Environment variables
DATABASE_URL = "postgresql://localhost:5432/myapp_dev";
REDIS_URL = "redis://localhost:6379";
};
});
}Run nix develop in the project directory and you will have all tools available instantly. The first run downloads everything; subsequent runs are instant because packages are cached in /nix/store.
# Enter the dev shell
nix develop
# Or run a single command in the shell
nix develop --command just test
# Generate the lockfile (committed to git)
nix flake lockNix Flakes for Polyglot Monorepos
For monorepos with multiple services, define multiple dev shells in a single flake. This approach lets each team member enter only the shell they need, without downloading unnecessary toolchains.
{
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let pkgs = nixpkgs.legacyPackages.${system}; in
{
devShells = {
default = pkgs.mkShell {
buildInputs = with pkgs; [ just git ];
};
backend = pkgs.mkShell {
buildInputs = with pkgs; [
jdk21
gradle
postgresql_16
];
};
frontend = pkgs.mkShell {
buildInputs = with pkgs; [
nodejs_20
nodePackages.pnpm
playwright-driver.browsers
];
};
infra = pkgs.mkShell {
buildInputs = with pkgs; [
terraform
kubectl
helm
awscli2
];
};
};
});
}# Enter specific shells
nix develop .#backend
nix develop .#frontend
nix develop .#infraIntegrating with direnv for Automatic Shell Activation
Typing nix develop every time you enter a directory is tedious. The nix-direnv integration automatically activates your dev shell when you cd into the project, and deactivates it when you leave. This is the recommended approach for daily development.
# Install direnv (add to your global Nix profile or system config)
nix profile install nixpkgs#direnv nixpkgs#nix-direnv
# Add to your shell rc file (~/.bashrc or ~/.zshrc)
eval "$(direnv hook bash)" # or zsh
# In your project root, create .envrc
echo "use flake" > .envrc
direnv allowNow every time you cd into the project directory, your tools are available. The shell loads in milliseconds because nix-direnv caches the environment profile.
CI/CD Integration with Nix Flakes
The true power of Nix reproducible dev environments shines in CI/CD. Your CI pipeline uses the exact same toolchain as local development — no more drift between local and CI environments.
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v26
with:
extra_nix_config: |
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
- uses: cachix/cachix-action@v14
with:
name: my-project
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: Run tests
run: nix develop --command just test
- name: Build
run: nix buildCachix provides a binary cache so your CI does not rebuild packages from source every run. Consequently, CI times drop from 15+ minutes to under 2 minutes after the first cached run.
When NOT to Use Nix
Nix has a steep learning curve, and the functional language can be intimidating for teams unfamiliar with it. If your team already has a working Docker Compose setup and nobody wants to learn Nix, forcing adoption will cause more friction than it solves. Additionally, Nix on native Windows (without WSL2) has limited support.
For simple projects with a single language and runtime (e.g., a basic Node.js app), a .tool-versions file with asdf or mise may be sufficient. Nix shines most in polyglot environments, complex build chains, and organizations that value byte-for-byte reproducibility across dozens of developers.
Key Takeaways
- Nix reproducible dev environments guarantee identical toolchains across all developer machines and CI/CD pipelines
- Nix Flakes add lockfiles and composability, making Nix practical for production teams
- Use nix-direnv for automatic shell activation — it eliminates the friction of manual
nix developcommands - Multiple dev shells in a single flake support polyglot monorepos without wasting disk space
- Cachix binary caches make CI/CD fast by avoiding redundant package builds