Reproducible Dev Shells With Nix, Devbox and direnv
Version managers pin language runtimes, but most of a project's toolchain is everything else: jq, protoc, terraform, kubectl, libpq headers for a Postgres driver, a specific openssl, awscli, shellcheck. Each is installed by hand from Homebrew, apt, a vendor script or a colleague's advice, at whatever version was current that day. Six months later two developers running the same make target get different results because one has protoc 3.21 and the other 25.1. This topic, part of developer onboarding architecture and friction mapping, covers reproducible development shells: a declared, locked set of tools that every developer and CI job gets identically, activated automatically when entering the project directory.
The technology underneath is Nix, a package manager that builds every package in isolation into a content-addressed store path such as /nix/store/6x2…-protobuf-25.1, and records the exact inputs in a lockfile. Two machines with the same lockfile get bit-identical tools. The friction with Nix has always been its language and learning curve; Devbox and similar wrappers remove most of that by generating the Nix configuration from a short JSON file, while direnv makes the environment load and unload as you cd in and out of the repository.
This approach sits between two others the site covers. Toolchain version managers such as mise and asdf pin language runtimes with little ceremony but do not handle system libraries. Dev containers pin everything but move development into a container with its file-sharing and editor-integration costs. Nix shells pin everything while keeping tools native on the host — fast file access, native editor integration, no VM — at the price of installing Nix itself.
Prerequisites
- Nix 2.18+ with flakes enabled, installed with the Determinate Systems installer (
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install), which enables flakes by default and supports clean uninstallation. The official multi-user installer also works; addexperimental-features = nix-command flakesto/etc/nix/nix.conf. - About 5–10 GB of free disk for the Nix store on a typical project.
- direnv 2.32+ and its shell hook (
eval "$(direnv hook zsh)"in~/.zshrc, or the bash equivalent). - Optionally Devbox 0.13+ (
curl -fsSL https://get.jetify.com/devbox | bash) for teams that do not want to write Nix expressions. - Admin rights on macOS for the one-time creation of the
/nixvolume.
Verify the installation before touching the project:
#!/usr/bin/env bash
set -euo pipefail
nix --version
nix config show | grep -E '^experimental-features' || echo "flakes not enabled"
direnv version
command -v devbox >/dev/null && devbox version || echo "devbox not installed (optional)"
df -h /nix | tail -1
Declaring the toolchain in a flake
A flake is a directory with a flake.nix that declares inputs (usually a pinned revision of the nixpkgs package collection) and outputs (here, a development shell). The companion flake.lock records the exact revision, so the toolchain only changes when someone runs nix flake update and commits the result:
{
description = "shop development shell";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
inputs.flake-utils.url = "github:numtide/flake-utils";
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let pkgs = import nixpkgs { inherit system; };
in {
devShells.default = pkgs.mkShell {
packages = with pkgs; [
nodejs_20 python312 go_1_22
jq terraform kubectl protobuf shellcheck
postgresql_16 openssl pkg-config
];
shellHook = ''
export PGHOST=localhost
echo "shop shell: node $(node --version), terraform $(terraform version -json | jq -r .terraform_version)"
'';
};
});
}
- Save as
flake.nixat the repository root and runnix developonce to createflake.lock. - Commit both files. The lockfile is what makes the shell reproducible.
- Run the drift diagnostic below on two machines; the output must match exactly.
#!/usr/bin/env bash
set -euo pipefail
nix develop --command bash -c 'for t in node python3 go jq terraform kubectl protoc; do printf "%-10s %s\n" "$t" "$(command -v $t)"; done'
Choosing the nixpkgs input is the main decision in the file. A stable release branch such as nixos-24.05 receives security fixes but not major version bumps, so a nix flake update months later changes patch versions without surprising anyone. The nixpkgs-unstable branch has newer tools but moves quickly; if the team needs one bleeding-edge tool, add a second input pinned to unstable and take only that package from it, rather than moving the whole shell. Updates should be a deliberate, reviewed change — a scheduled monthly pull request that runs nix flake update and lets CI prove nothing broke works well, and keeps the lockfile from going stale for years.
Every path begins with /nix/store/ followed by a hash. Identical hashes on two machines mean bit-identical tools. The flake dev shell guide covers pinning a specific package version that nixpkgs does not provide, and per-system differences between macOS and Linux.
Devbox for teams that do not want to learn Nix
The flake above is short, but it is still a new language, and teams frequently stall on the first error message. Devbox provides the same guarantees with a JSON file and familiar commands. It generates the Nix configuration, resolves package versions through its search index (so [email protected] means exactly that version), and writes its own lockfile:
{
"$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.13.0/.schema/devbox.schema.json",
"packages": [
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]"
],
"shell": {
"init_hook": ["echo 'shop devbox shell ready'"],
"scripts": {
"test": "npm test",
"bootstrap": "npm ci && pip install -r requirements.txt"
}
}
}
Developers run devbox shell to enter the environment and devbox run test to run scripts inside it. Adding a tool is devbox add [email protected], which updates both files. The Devbox onboarding guide covers migrating from a Brewfile and exposing the environment to IDEs.
Devbox also changes how the environment is explained to newcomers. Instead of a README section listing a dozen install commands for three operating systems, the README says "install Devbox, run devbox shell" and the configuration file itself documents the toolchain. Scripts defined in devbox.json become the discoverable list of project tasks — devbox run --list prints them — which overlaps with what a Makefile or a task runner provides. Use Devbox scripts for short environment-bound commands and keep the task runner for anything with dependencies between steps, so there is one obvious place for each kind of task.
The key design decision is which file is the source of truth. Pick one — flake.nix or devbox.json — per repository. Maintaining both leads to exactly the drift this topic exists to eliminate.
Loading the environment automatically with direnv
A reproducible shell is only useful if developers are actually in it. Remembering to run nix develop or devbox shell in every new terminal fails often enough that people end up running system tools by accident, which reintroduces drift. direnv solves this: when you cd into a directory with an allowed .envrc, it loads the environment into the current shell; when you leave, it unloads it.
#!/usr/bin/env bash
set -euo pipefail
cat > .envrc <<'EOF'
if has nix_direnv_version; then
use flake
else
echo "install nix-direnv for cached loading: https://github.com/nix-community/nix-direnv"
use flake
fi
dotenv_if_exists .env.local
EOF
direnv allow
nix-direnv caches the evaluated environment, so entering the directory takes milliseconds after the first load instead of re-evaluating the flake each time. For Devbox, devbox generate direnv writes an equivalent .envrc. The direnv guide covers editor integration — VS Code and JetBrains both have direnv extensions — and the security model: .envrc runs code, so direnv requires an explicit direnv allow after every change.
direnv is also the right place for project environment variables that are not secrets: a PGHOST, a default AWS_REGION for local tooling, a COMPOSE_PROJECT_NAME. dotenv_if_exists .env.local layers personal overrides on top without committing them. Because direnv unloads everything on leaving the directory, those variables never leak into other projects — a common source of confusion when developers export them globally in ~/.zshrc and later wonder why an unrelated project connects to the wrong database.
The drift diagnostic for this section is simple and belongs in the onboarding health check:
#!/usr/bin/env bash
set -euo pipefail
case "$(command -v terraform)" in
/nix/store/*) echo "terraform from project shell: ok" ;;
*) echo "terraform is $(command -v terraform || echo missing): not the project shell; run 'direnv allow'"; exit 1 ;;
esac
Binary caches and first-run time
The first nix develop on a new laptop downloads every package in the closure — often 1–3 GB for a polyglot project. When packages come from the public cache at cache.nixos.org, that is a few minutes on a good connection. When the flake pins something that is not in the public cache — a custom package, an overlay, a patched library — Nix builds it from source, and first-run time can climb to an hour. A team binary cache (Cachix, an S3 bucket, or Attic) holds those builds so each is done once, in CI, and downloaded everywhere else.
#!/usr/bin/env bash
set -euo pipefail
nix develop --command true
nix path-info --recursive --closure-size --human-readable "$(nix eval --raw .#devShells.$(nix eval --raw --impure --expr builtins.currentSystem).default.outPath)" | tail -1
nix store ping --store https://cache.nixos.org
The binary cache guide shows the CI job that populates a cache and the nix.conf settings that consume it. Garbage collection interacts with caching in a way worth knowing. nix-collect-garbage removes store paths no longer referenced by any profile or GC root, and a project shell entered with plain nix develop is not a GC root — so a cleanup can delete the environment and force a re-download on the next entry. nix-direnv registers the shell as a root automatically, which is one more reason to load the environment through direnv rather than by hand. On shared build machines, schedule garbage collection with a retention window rather than running it ad hoc, and the cache hit rate stays high while the disk stays bounded.
Measure first-run time on a clean machine as part of time-to-first-PR metrics; a slow first run is onboarding friction just as much as a missing README step.
Using the same shell in CI
A reproducible shell pays off twice when CI uses it too: the version of terraform that formats code on a laptop is exactly the one that validates it in CI. Nix makes this straightforward because the shell is a single command away:
name: checks
on: [pull_request]
jobs:
lint:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@v14
- uses: DeterminateSystems/magic-nix-cache-action@v8
- run: nix develop --command make lint test
The magic-nix-cache action stores Nix store paths in the GitHub Actions cache, so repeated runs do not download the closure again; the first run on a new lockfile takes a few minutes and subsequent runs start in seconds. Other CI systems have equivalents, or can read from the same team binary cache developers use.
Every tool invoked by make lint test comes from the same flake.lock developers use. Upgrading a tool becomes a reviewed pull request that changes flake.lock, and CI proves the upgrade before anyone's laptop sees it. This is the same principle as the site's CI/CD pipeline parity checks, applied to the toolchain rather than the runtime.
Platform caveats
macOS: the Nix installer creates an APFS volume mounted at
/nix. macOS major upgrades have historically broken the mount or the shell integration; the Determinate installer repairs itself with/nix/nix-installer repair. Some packages are Linux-only; guard them withlib.optionals pkgs.stdenv.isLinux [ ... ]in the flake.
Apple Silicon (ARM64): nixpkgs builds
aarch64-darwinpackages natively and the public cache covers the vast majority. A few packages are only cached forx86_64-darwin; those build from source the first time, which is where a team cache helps most.
WSL2: Nix works inside WSL2 distributions as on Linux. Keep the repository in the WSL filesystem; direnv's hook in a Windows terminal does not see directories under
/mnt/cchanging reliably.
Linux (SELinux distributions): the multi-user installer needs SELinux in permissive mode or a policy module on Fedora and RHEL; check the installer's output rather than assuming success.
Rollback and recovery
Adopting Nix is non-destructive: it lives in /nix, and nothing outside the project directory changes unless a developer opts in. To back out for one project, delete flake.nix, flake.lock and .envrc; developers fall back to whatever tools they had before. To uninstall Nix entirely:
#!/usr/bin/env bash
set -euo pipefail
if [ -x /nix/nix-installer ]; then
sudo /nix/nix-installer uninstall
else
echo "installed with the official installer: follow https://nixos.org/manual/nix/stable/installation/uninstall"
fi
If a lockfile update breaks the shell, revert flake.lock to the previous commit; the old store paths are usually still present locally, so recovery is instant.
Frequently Asked Questions
Is Nix worth it if we already pin runtimes with mise?
It is worth it when the pain is in system libraries and CLIs that mise does not cover, or when CI and laptops disagree on tool versions. If only language runtimes vary, mise is simpler; the comparison of Nix, mise and dev containers lays out the trade-offs.
Do developers need to learn the Nix language?
Not with Devbox, which uses a JSON file and familiar commands. With raw flakes, one or two people should own flake.nix; everyone else only runs nix develop or relies on direnv.
How big does the Nix store get?
A typical polyglot project closure is 1–3 GB. Old generations accumulate as lockfiles change; nix-collect-garbage --delete-older-than 30d reclaims them.
Can the dev shell and the production image share packages?
Yes. The same flake can build an OCI image with dockerTools.buildLayeredImage, so production and development use identical packages. Many teams start with the shell only and adopt Nix-built images later, if at all.
Related
- Write a project flake that pins every tool
- Adopt Devbox without learning Nix
- Pin runtimes with mise when that is enough
- Enforce the same tool versions in CI
Every guide in this topic
- Auto-Loading Project Environments With direnvLoad the project's tools and variables when you cd in and unload them when you leave: .envrc with use flake, nix-direnv caching, IDE plugins and blocked-file errors.
- Caching Nix Builds for Faster Team OnboardingStop new laptops compiling Nix packages from source for an hour: push dev-shell closures to a team binary cache from CI and configure substituters and trusted keys.
- Creating a Project Dev Shell With Nix FlakesWrite a flake.nix dev shell that pins every tool for macOS and Linux, fix 'does not provide attribute devShells' and dirty-tree errors, and pin exact versions.
- Nix vs mise vs Dev Containers for Toolchain PinningChoose how a team pins its toolchain: mise for runtimes, Nix or Devbox for every tool and library, or dev containers for a full OS image, compared honestly.
- Onboarding With Devbox Without Learning NixReplace a README of brew and apt installs with devbox.json: pinned packages, devbox run scripts, IDE integration and migrating an existing Brewfile step by step.