Caching Nix Builds for Faster Team Onboarding
A new engineer runs nix develop on their first morning and watches building '/nix/store/…-custom-protoc-plugin-1.4.0.drv' scroll for fifty minutes, followed by an hour compiling a patched OpenSSL. Every other developer did the same on their first day, and CI does it on every cold runner. The public cache at cache.nixos.org only contains packages built from unmodified nixpkgs; anything custom, overridden or from a non-default input is built locally. This page builds those packages once in CI and serves them to everyone from a team binary cache, as part of reproducible dev shells with Nix, Devbox and direnv.
The payoff is usually dramatic: first-run time on a fresh laptop drops from about an hour to a few minutes, which is a direct improvement to time-to-first-PR.
Diagnostic
List which store paths in the dev shell's closure are missing from the configured caches — those are what a new machine builds from source:
#!/usr/bin/env bash
set -euo pipefail
sys=$(nix eval --impure --raw --expr builtins.currentSystem)
drv=$(nix eval --raw ".#devShells.$sys.default.drvPath")
nix-store --query --requisites "$drv" | grep '\.drv$' > /tmp/drvs.txt
nix build --dry-run ".#devShells.$sys.default" 2>&1 | sed -n '/will be built/,/will be fetched/p' | head -12
nix config show | grep -E '^(substituters|trusted-public-keys)'
Expected bad output:
these 7 derivations will be built:
/nix/store/2x9…-openssl-3.0.14-patched.drv
/nix/store/8kq…-custom-protoc-plugin-1.4.0.drv
/nix/store/c1m…-terraform-provider-internal-0.9.2.drv
/nix/store/jz0…-nix-shell-env.drv
these 312 paths will be fetched (1.21 GiB download, 5.02 GiB unpacked):
substituters = https://cache.nixos.org/
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=
312 paths come from the public cache, but seven — including a patched OpenSSL, which drags in a long rebuild — must be built locally, and only the public cache is configured.
Root cause
Nix decides per store path whether to download or build. Before building, it asks each configured substituter (binary cache) whether it has that exact path, identified by the hash of all its inputs. cache.nixos.org is populated by Hydra, the nixpkgs build farm, so it has every package built from an unmodified nixpkgs revision — but anything with a changed input has a different hash and is not there. Overlays, overrideAttrs, custom derivations, pinned private sources and packages from a non-standard nixpkgs revision all fall into that category. Worse, overriding a low-level dependency such as OpenSSL changes the hash of everything that depends on it, turning a single patch into dozens of local rebuilds. Without a team cache, each machine repeats all of that work independently.
The cache also has a trust dimension. Nix only accepts downloaded paths signed by a key listed in trusted-public-keys, so adding a cache means distributing its public key, and on multi-user installs only trusted users can add substituters. That is why a cache someone "set up" still does not help if the key never reached developers' nix.conf.
Resolution
Create a cache. Cachix is the quickest hosted option (
cachix create shop-dev); self-hosted options include an S3 bucket withnix copy --to s3://…or Attic. The steps below use Cachix.Push the dev-shell closure from CI on every change to
flake.lockorflake.nix, so the cache is warm before developers pull:
name: nix-cache
on:
push:
branches: [main]
paths: [flake.nix, flake.lock]
jobs:
cache:
strategy:
matrix:
os: [ubuntu-24.04, macos-14]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@v14
- uses: cachix/cachix-action@v15
with:
name: shop-dev
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
- run: nix build --no-link .#devShells.$(nix eval --impure --raw --expr builtins.currentSystem).default
The cachix-action watches the store and pushes every path built during the job. The matrix covers Linux and Apple Silicon, since cached paths are per system.
- Configure developers' machines to use the cache. Put the settings in the flake so Nix offers them on first use:
{
nixConfig = {
extra-substituters = [ "https://shop-dev.cachix.org" ];
extra-trusted-public-keys = [ "shop-dev.cachix.org-1:Xb4mLq2kH9fT3sVwYc8dRn1pJ6uA0eZ5gK7iO2yQ4tM=" ];
};
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
outputs = { self, nixpkgs }: { };
}
On multi-user installs, nixConfig settings are applied only for trusted users. Add developers to trusted-users in /etc/nix/nix.conf during bootstrap, or write the substituter and key into /etc/nix/nix.custom.conf directly:
#!/usr/bin/env bash
set -euo pipefail
conf=/etc/nix/nix.custom.conf
sudo touch "$conf"
grep -q shop-dev.cachix.org "$conf" || sudo tee -a "$conf" >/dev/null <<'EOF'
extra-substituters = https://shop-dev.cachix.org
extra-trusted-public-keys = shop-dev.cachix.org-1:Xb4mLq2kH9fT3sVwYc8dRn1pJ6uA0eZ5gK7iO2yQ4tM=
EOF
sudo launchctl kickstart -k system/org.nixos.nix-daemon 2>/dev/null || sudo systemctl restart nix-daemon
- Reduce what needs caching. Before caching a patched OpenSSL, ask whether the patch is needed in the dev shell at all. Overriding low-level libraries multiplies rebuilds; scoping the override to the one package that needs it keeps everything else on the public cache.
Expected output
$ nix build --dry-run .#devShells.aarch64-darwin.default 2>&1 | head -3
these 319 paths will be fetched (1.34 GiB download, 5.41 GiB unpacked):
/nix/store/2x9…-openssl-3.0.14-patched
/nix/store/8kq…-custom-protoc-plugin-1.4.0
$ time nix develop --command true
real 4m12s
No derivations are listed under "will be built"; everything is fetched, and a fresh laptop is ready in about four minutes instead of an hour.
Prevention
Fail CI when the dev shell would build locally. Run
nix build --dry-runon a clean runner with only the configured caches and fail if the "will be built" list is non-empty after the cache job has run. That catches a cache job that stopped pushing.Rotate the cache auth token like any other CI secret; only CI needs write access. Developers need only the public key.
Track first-run time as an onboarding metric. A sudden jump means something stopped being cached.
Platform caveats
macOS: the Nix daemon must be restarted after editing
nix.confornix.custom.conf; uselaunchctl kickstart -k system/org.nixos.nix-daemon.
Apple Silicon (ARM64): cached paths are per system. A cache populated only by Linux runners gives macOS developers nothing; include a
macos-14(arm64) runner in the cache job.
WSL2: behaves like Linux. If systemd is not enabled in the distribution, restart the daemon manually or use a single-user install, where
nix.confin the home directory applies immediately.
Rollback
Remove the substituter lines; Nix falls back to the public cache and local builds:
#!/usr/bin/env bash
set -euo pipefail
sudo sed -i.bak '/shop-dev.cachix.org/d' /etc/nix/nix.custom.conf
sudo systemctl restart nix-daemon 2>/dev/null || sudo launchctl kickstart -k system/org.nixos.nix-daemon
Frequently Asked Questions
Why does Nix ignore the nixConfig substituters in my flake?
On multi-user installs, only users listed in trusted-users may add substituters, and Nix prompts or ignores the setting otherwise. Add the cache directly to the system nix.conf during bootstrap, or add developers to trusted-users.
Can a team cache serve stale or malicious binaries?
Paths are identified by the hash of their inputs and signed. Nix only accepts a path whose signature matches a trusted public key, and the path name encodes its inputs, so a cache cannot substitute a different build for the same inputs without the signing key.
How much does a team cache store?
Only paths not in the public cache: typically a few hundred megabytes to a few gigabytes per system. Hosted caches offer retention policies; self-hosted S3 caches can use lifecycle rules.
Is magic-nix-cache in CI a replacement for a team cache?
It caches store paths between runs of the same repository's CI, which speeds up CI but does not help developer laptops. A team cache serves both.