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.

StrategyWhat the job stops doingWhat it costsWhere it stops helping
cache.nixos.org onlyBuilding nixpkgs paths from sourceNothingEvery derivation your repository defines still builds
Your own binary cache as a second substituterRebuilding your own derivations after the first build anywhereObject storage plus egress from your own bucketA cold closure is still one narinfo request and one archive download per path
Cache archive of ~/.cache/nixRefetching flake inputs and re-evaluating unchanged attributes$0.20 per GB-month storage, $0.0001 per cache operationIt carries no store paths, only evaluation and fetcher state
Snapshot runner with a warm /nix/storeDownloading the closure at all$0.04 per snapshot restore, $0.025 per snapshot-hourUbuntu 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/devshell

Five 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: 45

snapshot.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 labelvCPUMemoryStoragePer minuteFits
warp-ubuntu-latest-x64-2x28 GB150GB SSD$0.004lock file checks, formatting, evaluation-only jobs
warp-ubuntu-latest-x64-4x416 GB150GB SSD$0.008small flakes with a warm substituter
warp-ubuntu-latest-x64-8x832 GB150GB SSD$0.016nix flake check on a repository with several packages
warp-ubuntu-latest-x64-16x1664 GB150GB SSD$0.032wide build graphs, overlays that rebuild parts of nixpkgs
warp-ubuntu-latest-x64-32x32128 GB150GB SSD$0.064full 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 labelvCPUmax-jobscoresPeak build threads
warp-ubuntu-latest-x64-4x4224
warp-ubuntu-latest-x64-8x8428
warp-ubuntu-latest-x64-16x164416
warp-ubuntu-latest-x64-32x328432

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 itemRateVolumeMonthly cost
GitHub-hosted 8-core larger runner, 12 minutes per job$0.022 per minute4,800 minutes$105.60
warp-ubuntu-latest-x64-8x, 12 minutes per job$0.016 per minute4,800 minutes$76.80
warp-ubuntu-latest-x64-8x from a snapshot, 4 minutes per job$0.016 per minute1,600 minutes$25.60
Snapshot restore$0.04 per job400 jobs$16.00
Snapshot storage, one alias$0.025 per snapshot-hour720 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.