Nix Builds on GitHub Actions
A Nix build on GitHub Actions is slow when the store starts empty. Add your own substituter, size the runner for eval, and boot warm stores from snapshots.
Last verified:
A Nix build on GitHub Actions is fast or slow for one reason: how much of the closure the runner has to build instead of download. Give the job a substituter that holds your own derivations, run it on warp-ubuntu-latest-x64-8x, and when the store grows past the point where fetching it is worth the wait, boot the job from a snapshot runner that already carries /nix/store on disk.
The sections below cover the store cache strategies and what each one removes, a working workflow with the installer, the nix.conf settings and a nix flake check step, the sizing arithmetic for evaluation and parallel builds, and the prepared-machine option for repositories whose store is too large to restore on every run.
Overview
Nix addresses every build output by a hash of its inputs and stores it under /nix/store. A derivation whose hash is already present is never rebuilt. A GitHub Actions runner is an ephemeral VM, so every job starts with a store containing nothing beyond what the installer puts there, and every path your flake needs has to arrive from somewhere.
A substituter is where it arrives from. It is a binary cache that Nix queries before building: for each store path, Nix requests a .narinfo file, and on a hit it downloads the archive and unpacks it rather than running the derivation (Nix configuration reference). The public cache at cache.nixos.org holds prebuilt paths for nixpkgs. It holds nothing your repository defines, which is why a flake with a handful of local packages still compiles from source on every runner until you add a cache of your own.
That leaves four strategies, and the difference between them is which part of the work disappears.
| Strategy | What the job stops doing | What it costs | Where it stops helping |
|---|---|---|---|
cache.nixos.org only | Building nixpkgs paths from source | Nothing | Every derivation your repository defines still builds |
| Your own binary cache as a second substituter | Rebuilding your own derivations after the first build anywhere | Object storage plus egress from your own bucket | A cold closure is still one narinfo request and one archive download per path |
Cache archive of ~/.cache/nix | Refetching flake inputs and re-evaluating unchanged attributes | $0.20 per GB-month storage, $0.0001 per cache operation | It carries no store paths, only evaluation and fetcher state |
Snapshot runner with a warm /nix/store | Downloading the closure at all | $0.04 per snapshot restore, $0.025 per snapshot-hour | Ubuntu Cloud runners only, and a snapshot is deleted after 15 days |
Cache and snapshot rates are from the pricing page and the caching documentation. Most repositories want the second and third rows together, and reach for the fourth when the closure gets big.
Nix work belongs on the Linux lines, where the runner catalog lists 2 to 32 vCPU sizes, each with a 150GB SSD, on Ubuntu images that carry the same tooling as GitHub-hosted runners. Snapshot runners are one part of a product surface that also includes remote Docker builders, CI observability, an MCP server, and the Action Debugger.
Configuration
The workflow
name: nix
on:
push:
branches: [main]
pull_request:
jobs:
check:
runs-on: warp-ubuntu-latest-x64-8x
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- name: Install Nix
run: |
sh <(curl -L https://nixos.org/nix/install) --daemon --yes
echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH"
- name: Configure substituters
run: |
sudo mkdir -p /etc/nix
sudo tee /etc/nix/nix.conf > /dev/null <<'EOF'
experimental-features = nix-command flakes
accept-flake-config = false
substituters = https://cache.nixos.org https://acme-nix-cache.s3.us-east-1.amazonaws.com
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= acme-nix-cache-1:REPLACE_WITH_YOUR_PUBLIC_KEY
max-jobs = 4
cores = 2
http-connections = 50
narinfo-cache-negative-ttl = 0
EOF
sudo systemctl restart nix-daemon
- name: Restore evaluation and fetcher cache
uses: WarpBuilds/cache@v1
with:
path: ~/.cache/nix
key: nix-eval-${{ runner.os }}-${{ hashFiles('flake.lock') }}
restore-keys: nix-eval-${{ runner.os }}-
- name: Flake check
run: nix flake check --print-build-logs --keep-going
- name: Root the outputs so garbage collection keeps them
if: github.ref == 'refs/heads/main'
run: |
mkdir -p /home/runner/nix-roots
nix build .#packages.x86_64-linux.default \
--out-link /home/runner/nix-roots/package
nix build .#devShells.x86_64-linux.default \
--out-link /home/runner/nix-roots/devshell
- name: Push new store paths to the binary cache
if: github.ref == 'refs/heads/main'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.NIX_CACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.NIX_CACHE_SECRET_ACCESS_KEY }}
NIX_CACHE_SIGNING_KEY: ${{ secrets.NIX_CACHE_SIGNING_KEY }}
run: |
printf '%s' "$NIX_CACHE_SIGNING_KEY" > "$RUNNER_TEMP/cache.sec"
chmod 600 "$RUNNER_TEMP/cache.sec"
nix copy \
--to "s3://acme-nix-cache?region=us-east-1&secret-key=$RUNNER_TEMP/cache.sec" \
/home/runner/nix-roots/package /home/runner/nix-roots/devshellFive details carry this workflow.
The daemon reads /etc/nix/nix.conf, so it needs a restart. substituters, max-jobs, and cores are daemon-side settings in a multi-user install. Writing the file after the installer has already started the daemon changes nothing until systemctl restart nix-daemon picks it up.
A substituter is inert without its public key. Nix ignores paths from a cache whose signing key is absent from trusted-public-keys, and the job then rebuilds everything while the cache sits there answering requests. Generate the key pair once with nix key generate-secret, keep the secret half in Actions secrets, and put the public half in nix.conf.
accept-flake-config = false keeps a flake from adding its own substituters. A nixConfig block in an input flake can otherwise push extra caches and keys into the job, which is a supply-chain path you do not want open on a runner that holds your cache credentials.
Pull requests read the cache and main writes it. The push step is gated on github.ref, so an unreviewed branch cannot place a store path under a hash that a later trusted build would download.
--out-link makes the outputs garbage collection roots. nix build --no-link leaves the result unrooted, and the next nix-collect-garbage deletes exactly the paths you wanted to keep warm. Pointing the link at a stable path outside the workspace keeps the root alive after the next checkout wipes the working tree.
The prepared machine
When the closure is large enough that the download dominates, stop moving the store over the network and keep it on the machine. Snapshot runners capture the runner disk mid-workflow and boot later jobs from that image, with the label form documented on the snapshot runners page:
jobs:
check:
runs-on: >-
${{ github.ref == 'refs/heads/main'
&& 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true'
|| 'warp-ubuntu-latest-x64-8x;snapshot.key=nix-store-main' }}
steps:
# install, configure, and check steps as above
- name: Collect garbage before the snapshot
if: github.ref == 'refs/heads/main'
run: |
nix-collect-garbage --delete-older-than 7d
rm -rf "$HOME/.ssh" "$HOME/.aws"
df -h /
- name: Save snapshot
if: github.ref == 'refs/heads/main'
uses: WarpBuilds/snapshot-save@v1
with:
alias: nix-store-main
fail-on-error: true
wait-timeout-minutes: 45snapshot.enabled=true always boots from the base image, which is what the main-branch job wants so the saved store is built from a known starting point. snapshot.key=nix-store-main boots from the existing snapshot when one exists and falls back to the base image when it does not, so a stale alias costs a cold run rather than a failure. Snapshots are supported on WarpBuild Cloud Ubuntu runners; the labels are silently ignored on BYOC, Windows, and macOS runners.
Keep the store out of /tmp, which is cleared on reboot, and a snapshot boot is a reboot. The default store path is /nix/store, so this matters mainly for anything you redirect by hand.
Sizing
Linux x64 rates from the pricing page:
| Runner label | vCPU | Memory | Storage | Per minute | Fits |
|---|---|---|---|---|---|
| warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | lock file checks, formatting, evaluation-only jobs |
| warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | small flakes with a warm substituter |
| warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | nix flake check on a repository with several packages |
| warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | wide build graphs, overlays that rebuild parts of nixpkgs |
| warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 | full closure rebuilds after a toolchain or nixpkgs bump |
For the default size, the list-price arithmetic runs like this: warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13 in the GitHub Actions billing reference.
Set max-jobs and cores together. max-jobs is how many derivations build at once and cores is what each build sees as NIX_BUILD_CORES, so their product is the parallelism you are asking the machine for. Leaving both at their defaults either serializes the graph or oversubscribes it.
| Runner label | vCPU | max-jobs | cores | Peak build threads |
|---|---|---|---|---|
| warp-ubuntu-latest-x64-4x | 4 | 2 | 2 | 4 |
| warp-ubuntu-latest-x64-8x | 8 | 4 | 2 | 8 |
| warp-ubuntu-latest-x64-16x | 16 | 4 | 4 | 16 |
| warp-ubuntu-latest-x64-32x | 32 | 8 | 4 | 32 |
Favor more cores when a few large derivations dominate, such as a compiler or a kernel, and more max-jobs when the graph is many small independent packages.
Worked cost model
Take a repository running 400 pull request jobs a month. Assume nix flake check takes 12 minutes on 8 vCPU with a warm substituter and 4 minutes when the store is already on the machine. Substitute your own measurements, because the gap depends entirely on closure size.
| Line item | Rate | Volume | Monthly cost |
|---|---|---|---|
| GitHub-hosted 8-core larger runner, 12 minutes per job | $0.022 per minute | 4,800 minutes | $105.60 |
| warp-ubuntu-latest-x64-8x, 12 minutes per job | $0.016 per minute | 4,800 minutes | $76.80 |
| warp-ubuntu-latest-x64-8x from a snapshot, 4 minutes per job | $0.016 per minute | 1,600 minutes | $25.60 |
| Snapshot restore | $0.04 per job | 400 jobs | $16.00 |
| Snapshot storage, one alias | $0.025 per snapshot-hour | 720 hours | $18.00 |
The snapshot path totals $59.60 against $76.80 for the same runner without snapshots and $105.60 for the same minutes on GitHub-hosted larger runners. The main-branch job that refreshes the alias pays the cold path and is not in the 400.
Bottlenecks
Evaluation runs in one thread. Before a single derivation builds, Nix evaluates the flake, and that phase uses one core and a heap that reaches several gigabytes on a flake that imports all of nixpkgs. Neither max-jobs nor cores touches it. Two things help: keep ~/.cache/nix between runs so the evaluation cache survives, and avoid import-from-derivation, which suspends evaluation to run a build and turns the widest part of your graph into a queue. NIX_SHOW_STATS=1 nix eval .#packages.x86_64-linux.default prints the CPU time and allocation counts that tell you which of the two you are looking at.
Cold substituter fetches are round trips, not bandwidth. A closure of a few thousand paths is a few thousand narinfo requests before any archive moves. Raising http-connections above the default of 25 shortens that phase, and narinfo-cache-negative-ttl = 0 stops Nix from remembering a miss for the default hour, which matters when one job pushes a path that a later job in the same workflow needs. The push side has the same shape: nix copy uploads each new path individually, so a main-branch job after a nixpkgs bump spends real minutes writing to the cache.
The store outgrows the disk. Each Linux size carries a 150GB SSD, and a repository that builds several variants of a large closure fills it faster than expected once snapshots keep the store alive between runs. Run nix-collect-garbage --delete-older-than 7d before the snapshot save, keep the paths you want warm rooted through --out-link, and print df -h / in the same step so the log says how close the last run came.
Telling these apart is a measurement problem. WarpBuild's CI observability streams system metrics from the runner and correlates them with GitHub Actions job logs, so an evaluation stall reads as one busy core, a cold substituter reads as network activity with idle cores, and a real rebuild reads as sustained load across every core. For a derivation that fails only in GitHub Actions, the Action Debugger pauses the workflow and opens an SSH session on the live runner, which is where nix log, nix why-depends, and nix path-info --closure-size answer in seconds what a rerun answers in minutes.
Proof
Public repositories running warp- labels are the citable evidence. The bitcoin/bitcoin GitHub Actions workflow routes its Linux jobs across warp- labels sized per job, from warp-ubuntu-latest-x64-2x for lint up to warp-ubuntu-latest-x64-16x for the fuzz and MSan matrices, with compiler caches restored and saved between runs (checked on 2026-08-13). That is a native compile matrix rather than a Nix build, and the pattern this page applies to /nix/store is the same one: size the runner to the shape of the work, then keep the expensive state on the machine.
Related reading: the snapshot runner reference covers the label forms and lifecycle in full, the incremental builds guide generalizes the warm-state pattern beyond Nix, the remote build cache definition explains the key-derivation ideas that a substituter shares with other build tools, and the Linux x64 runner page lists every label, image, and rate.
FAQ
What does a substituter change about a Nix job on GitHub Actions?
A substituter is a binary cache that Nix consults before it builds a store path. When the path hash already exists there, Nix downloads the archive instead of running the derivation, so job time moves from compile time to download time. cache.nixos.org covers nixpkgs paths only. Your own derivations are substitutable only after a job pushes them to a cache you control.
Which runner size should nix flake check use?
Start on warp-ubuntu-latest-x64-8x at $0.016 per minute with max-jobs = 4 and cores = 2. Evaluation runs in one thread and can hold a multi-gigabyte heap, so the 32 GB of memory matters as much as the core count. Move to 16 or 32 vCPU only when the build graph is wide enough to keep that many derivations running at once.
When is a snapshot runner better than a binary cache for /nix/store?
When the closure is large enough that fetching it dominates the job. A substituter still pays a narinfo request and an archive download per store path. A snapshot boots a WarpBuild Ubuntu runner from a saved disk where /nix/store is already populated, so the fetch does not happen. Snapshots are deleted after 15 days and are supported on WarpBuild Cloud Ubuntu runners only.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.