Nx Affected Builds on GitHub Actions
Run nx affected on GitHub Actions with correct base and head SHAs, restore .nx/cache with WarpBuilds/cache, and size warp- runners to the affected graph.
Last verified:
To run nx affected correctly on GitHub Actions, give Nx a base SHA that points at the last commit you actually verified, check out enough git history for that diff to resolve, and restore the .nx/cache directory so cached task results survive the ephemeral runner. On WarpBuild runners the cache step is the WarpBuilds/cache action, a drop-in replacement for actions/cache@v4, and snapshot runners can hold node_modules and .nx/cache warm across runs.
Those three decisions determine whether affected mode saves anything. This page covers the workflow YAML for base and head SHA selection, how the Nx computation cache behaves on a machine that is destroyed after every job, when a snapshot runner beats a cache archive, and how to pick --parallel and a runner size for the 8 and 16 vCPU tiers.
Overview
Nx builds a project graph from your workspace and computes which projects a commit range touches, directly or through a dependency edge. nx affected -t build then runs the build target for that subset instead of the whole workspace. Two inputs decide the subset: the base SHA and the head SHA.
On top of selection sits the computation cache. Nx hashes every input to a task, which includes the project's source files, the hashes of its dependencies, the target configuration in nx.json and project.json, the installed package versions, and any runtime or environment inputs you declared. If a task with that hash has run before and its result is in .nx/cache, Nx replays the recorded terminal output and restores the declared outputs instead of executing the task.
Selection and caching solve different halves of the problem. Selection decides which tasks are candidates. The cache decides which of those candidates actually execute. A workflow that gets selection right and starts with an empty cache still runs every affected task from scratch.
That empty cache is the default state on GitHub Actions. Every job starts on a fresh virtual machine with nothing from the previous run, so .nx/cache does not exist, every task hash misses, and Nx runs the full affected set. Carrying the cache forward is a workflow decision, and there are two mechanisms for it: restore a cache archive at the start of the job, or boot the job on a runner image that already contains the warm directory.
Nx workspaces run on the Linux sizes, and moving a job onto them is a one-line change to runs-on. Pick one architecture for the whole pipeline and stay on it: cache entries are scoped by a version derived from the operating system and compression tool, so an entry written on a Linux ARM64 runner does not restore on a Linux x64 runner, and native modules under node_modules are architecture specific anyway.
One boundary before the configuration. This page covers nx affected and the Nx computation cache on runner-local storage. Hosted remote cache services for Nx are a separate topic, and Turborepo's task pipeline has its own configuration on the Turborepo builds on GitHub Actions page. Dependency store caching for npm, pnpm, and yarn is covered on the Node.js dependency caching page.
Configuration
This workflow runs lint, test, and build for the affected projects on an 8 vCPU WarpBuild runner. It restores the Nx computation cache on every run and writes a new entry only from main.
name: nx-affected
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
actions: read
jobs:
affected:
runs-on: warp-ubuntu-latest-x64-8x
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: nrwl/nx-set-shas@v4
with:
main-branch-name: main
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: WarpBuilds/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Restore Nx computation cache
uses: WarpBuilds/cache/restore@v1
with:
path: .nx/cache
key: ${{ runner.os }}-nx-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-nx-
- run: pnpm install --frozen-lockfile
- name: Run affected targets
run: |
npx nx affected -t lint test build \
--base=$NX_BASE \
--head=$NX_HEAD \
--parallel=6
- name: Save Nx computation cache
if: github.ref == 'refs/heads/main'
uses: WarpBuilds/cache/save@v1
with:
path: .nx/cache
key: ${{ runner.os }}-nx-${{ github.run_id }}Four lines carry most of the behavior.
fetch-depth: 0 gives the runner the full git history. The default shallow checkout fetches one commit, and a diff against any base SHA outside that single commit cannot resolve.
nrwl/nx-set-shas@v4 exports NX_BASE and NX_HEAD into the job environment. On a pull_request event it sets the base to the merge base with the target branch and the head to the pull request head. On a push to main it queries the GitHub API for the most recent successful run of the same workflow on that branch and uses that SHA as the base, which is why the job needs the actions: read permission. Nx reads both variables on its own, so the explicit --base and --head flags in the run step are documentation for the next reader rather than a requirement.
The restore step names .nx/cache, the workspace-local cache directory used by current Nx versions. Older workspaces put it under node_modules/.cache/nx, and a workspace that overrides cacheDirectory in nx.json needs the overridden path here. Restoring the wrong directory produces a workflow that looks correct and never gets a cache hit.
The save step runs only on main. Every push writes a new entry keyed by run ID, and pull request jobs restore the most recent one through the restore-keys prefix. Restricting writes to main keeps the number of stored entries proportional to merges rather than to every push on every branch, which matters for the storage bill discussed under Sizing.
Keeping node_modules and the Nx cache warm with a snapshot runner
A cache archive has to be uploaded and downloaded. When the install step plus the cache download is the largest block of time in the job, a snapshot runner removes both by booting the job on a machine image captured after a previous run. Snapshot runners are available on WarpBuild Cloud Ubuntu runners, and the labels attach to runs-on:
jobs:
affected:
runs-on: >-
${{ github.ref == 'refs/heads/main'
&& 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true'
|| 'warp-ubuntu-latest-x64-8x;snapshot.key=nx-monorepo-warm' }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: nrwl/nx-set-shas@v4
- run: pnpm install --frozen-lockfile
- run: npx nx affected -t lint test build --base=$NX_BASE --head=$NX_HEAD --parallel=6
- name: Cleanup credentials
if: github.ref == 'refs/heads/main'
run: |
rm -rf $HOME/.ssh $HOME/.aws
git clean -ffdx -e node_modules -e .nx
- name: Save snapshot
if: github.ref == 'refs/heads/main'
uses: WarpBuilds/snapshot-save@v1
with:
alias: nx-monorepo-warm
fail-on-error: true
wait-timeout-minutes: 60snapshot.enabled=true boots from the base image and lets the snapshot-save action capture the machine at the end. snapshot.key=nx-monorepo-warm boots from the snapshot stored under that alias when one exists, so pull request jobs start with node_modules already installed and .nx/cache already populated. Jobs booted from a snapshot get WARPBUILD_SNAPSHOT_KEY in the environment, which is a cheap way to assert in a step that the warm path is actually being taken.
The -e node_modules -e .nx exclusions on git clean are the part people get wrong. Both directories are gitignored, so a plain git clean -ffdx deletes exactly the state the snapshot exists to preserve. Keep the credential removal, drop the two directories from the sweep.
Three constraints from the snapshot runner documentation shape how you use this. Snapshots are deleted 15 days after creation, so an alias that stops being refreshed silently falls back to the base image. The /tmp directory is cleared on reboot and does not survive. Booting from a snapshot takes 45 to 60 seconds, which is slower than a default runner boot, so the technique pays only when the warm state saves more than that.
Sizing
WarpBuild Linux x64 runners scale from 2 to 32 vCPU with 4GB of memory per core and 150GB of SSD storage on every size. The tiers that matter for an Nx workspace:
| Runner label | vCPU | Memory | Storage | Price per minute |
|---|---|---|---|---|
| warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 |
| warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 |
| warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | $0.032 |
| warp-ubuntu-latest-x64-32x | 32 | 128GB | 150GB SSD | $0.064 |
The ARM64 line carries the same sizes at a lower rate, which suits a workspace whose native dependencies all publish ARM64 binaries:
| Runner label | vCPU | Memory | Price per minute |
|---|---|---|---|
| warp-ubuntu-latest-arm64-8x | 8 | 32GB | $0.012 |
| warp-ubuntu-latest-arm64-16x | 16 | 64GB | $0.024 |
Nx runs at most --parallel tasks at once, and the default is 3. On an 8 vCPU runner that leaves five cores idle for most of the run. Two numbers set the right value.
Cores. On warp-ubuntu-latest-x64-8x, start at --parallel=6. The Nx process itself, the runner agent, and the log streaming all need CPU, and pinning all 8 cores to build tasks makes the job less predictable rather than faster. On warp-ubuntu-latest-x64-16x, start at --parallel=12 for the same reason.
Memory. A single Node build task holds 1GB to 3GB depending on the bundler and the size of the project. Twelve concurrent tasks at 3GB is 36GB, which fits inside the 64GB on 16x and would not fit inside the 32GB on 8x. When a run dies with a heap error at high parallelism, the fix is a larger tier or a lower --parallel value, in that order of preference if wall time matters.
The ceiling that actually binds is neither of those. nx affected on a typical pull request selects a handful of projects, and their tasks respect the dependency graph, so a chain of five projects that each depend on the previous one executes serially no matter how many cores are available. Before moving from 8x to 16x, print the selection and its shape:
npx nx affected -t build --base=$NX_BASE --head=$NX_HEAD --graph=affected.html
npx nx show projects --affected --base=$NX_BASE --head=$NX_HEADIf the widest layer of that graph is four tasks, 16 cores buy nothing. Move the money into fanning out across runners instead, covered under Bottlenecks.
Worked cost model
GitHub publishes per-minute list prices for its hosted runners at github.com/pricing and on the GitHub Actions minute multipliers reference. Checked on 2026-08-13, the standard ubuntu-latest runner for private repositories (2 vCPU) is $0.006 per minute, and the larger Linux runners are $0.012 for 4 vCPU, $0.022 for 8 vCPU, and $0.042 for 16 vCPU.
| Machine | vCPU | Per-minute rate | Source |
|---|---|---|---|
| GitHub-hosted Linux larger runner | 4 | $0.012 | GitHub list price, checked 2026-08-13 |
| GitHub-hosted Linux larger runner | 8 | $0.022 | GitHub list price, checked 2026-08-13 |
| GitHub-hosted Linux larger runner | 16 | $0.042 | GitHub list price, checked 2026-08-13 |
| warp-ubuntu-latest-x64-8x | 8 | $0.016 | WarpBuild pricing |
| warp-ubuntu-latest-x64-16x | 16 | $0.032 | WarpBuild pricing |
Take a monorepo team pushing 1,500 workflow runs a month. Running the whole workspace takes 14 minutes on an 8 vCPU machine. Running only the affected projects with a warm computation cache takes 6 minutes.
| Scenario | Rate | Monthly minutes | Monthly cost |
|---|---|---|---|
| Whole workspace, GitHub-hosted 8 vCPU | $0.022/min | 21,000 | $462.00 |
| Whole workspace, warp-ubuntu-latest-x64-8x | $0.016/min | 21,000 | $336.00 |
| Affected only, warp-ubuntu-latest-x64-8x | $0.016/min | 9,000 | $144.00 |
Stated as list-price arithmetic: 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.
Now price the warm state, because both mechanisms are metered.
Cache storage is $0.20 per GB-month and each cache write or restore operation is $0.0001. Entries expire 7 days after last use. With a 2GB .nx/cache archive saved on 20 merges per weekday, roughly 140 entries stay resident at any moment, which is about 280GB and $56.00 a month. Two levers bring that down: save less often on main by bucketing the key on the hour, or prune the cache directory before the save step so the archive is smaller.
Snapshots are metered differently. Snapshot restore is $0.04 per job and snapshot storage is $0.025 per snapshot-hour. One continuously refreshed snapshot alive for 30 days is 720 snapshot-hours, or $18.00. Across 1,500 jobs, that is $60.00 of restores plus $18.00 of storage, so $78.00 a month, and $0.052 per job.
That per-job number is the decision rule. At $0.016 per minute on warp-ubuntu-latest-x64-8x, $0.052 buys 3.25 minutes of runner time. A snapshot that removes more than about 3.3 minutes of install and cache-download work per job costs less than the time it saves. Below that threshold, the cache archive is the cheaper mechanism.
Every rate for every size and platform is on the WarpBuild pricing page.
Bottlenecks
The base SHA is wrong. This is the failure that makes affected mode useless, and it fails in both directions.
Too wide: a base pointing at the current tip of main rather than the merge base marks every project touched by commits that landed since you branched. A pull request changing one library then runs forty projects, and the run costs more than skipping affected mode entirely. A shallow checkout produces the same shape from a different cause, because git cannot walk back to the base commit and Nx has no diff to narrow the graph with.
Too narrow: a base of HEAD~1 on a branch where a previous run failed. The commits from that failed run sit between HEAD~1 and the last verified state, so their projects are never selected and never tested. Broken code reaches main with a green check. Using the last successful workflow run as the base on main is what nx-set-shas exists to provide, and it is the reason the actions: read permission is in the workflow above.
Fork pull requests deserve their own check. The base repository history and the workflow-run lookup both behave differently for a fork, so verify the selection on a fork pull request before trusting it. When a selection looks wrong, npx nx show projects --affected prints exactly what Nx picked, and comparing that list against git diff --name-only $NX_BASE $NX_HEAD shows whether the problem is the diff or the project graph.
The computation cache never hits, or hits when it should miss. A restored .nx/cache produces a hit only when a task hash matches exactly. Anything that changes the hash on every run, such as a timestamp written into a source file or an undeclared environment variable that leaks into the build, guarantees a permanent miss while the cache costs storage. The inverse bug is worse: a task whose real inputs include something Nx does not hash returns a stale replay.
The related trap is undeclared outputs. On a cache hit Nx restores the paths listed in the target's outputs and replays the terminal log. Nothing else comes back. A build that also writes coverage/ or a generated dist/types directory without declaring it produces a job that passes cold and fails warm, because the downstream step finds an empty directory. Declare every path a later step reads.
Cache scope matters too. Per the WarpBuild caching documentation, entries are scoped to the key, the cache version, and the branch, so a cache written on a feature branch is not visible to a sibling branch, and an entry expires 7 days after its last use. Seeding from main and restoring by prefix is what makes the archive useful across the repository. One platform limit to plan around: WarpBuild Cache is not supported on Windows runners, so keep Nx cache steps on Linux jobs.
Install time swamps the affected run. nx affected narrows the tasks. It does nothing to the install step, which still resolves the entire workspace dependency tree on every job. In a workspace with several hundred packages, install can exceed the time spent on the affected tasks. Restoring the package manager store helps, and the snapshot runner configuration above removes the step almost entirely by keeping node_modules on the machine image.
One Nx behavior compounds this. Nx disables its daemon when the CI environment variable is set, so every nx invocation recomputes the project graph from scratch. Running nx affected -t lint test build as a single command computes the graph once. Running three separate nx affected commands computes it three times.
The affected graph is too narrow to use a big runner. When the widest layer of the affected graph is small, a larger single runner sits idle. Fan the work out across machines instead, selecting projects in one job and building them in a matrix:
jobs:
select:
runs-on: warp-ubuntu-latest-x64-2x
outputs:
projects: ${{ steps.affected.outputs.projects }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: nrwl/nx-set-shas@v4
- uses: WarpBuilds/setup-node@v6
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- id: affected
run: |
echo "projects=$(npx nx show projects --affected --json)" >> "$GITHUB_OUTPUT"
build:
needs: select
if: needs.select.outputs.projects != '[]'
runs-on: warp-ubuntu-latest-x64-8x
strategy:
fail-fast: false
matrix:
project: ${{ fromJson(needs.select.outputs.projects) }}
steps:
- uses: actions/checkout@v4
- uses: WarpBuilds/setup-node@v6
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: npx nx run ${{ matrix.project }}:buildTwo limits apply. GitHub caps a matrix at 256 jobs per workflow run, so a change touching a shared library in a very large workspace needs a chunking step that groups projects into a fixed number of shards. And each matrix job repeats checkout and install, so total runner minutes rise even as wall time falls, which is the strongest argument for keeping install cheap.
On the capacity side, run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, so a wide matrix on a busy afternoon does not sit behind a queue that a plan limit created.
When a job is slow and the cause is unclear, WarpBuild's CI observability shows OpenTelemetry-based system metrics from the runner agent correlated with GitHub Actions job logs, which separates a CPU-saturated task graph from an install stuck on the network. The Action Debugger pauses a workflow and opens an SSH session on the runner, so you can inspect .nx/cache on the machine and run npx nx show projects --affected by hand against the same SHAs the job used. Broader workflow-level tactics are collected in the guide on speeding up GitHub Actions.
Proof
Test this against your own workspace instead of taking the sizing numbers on faith. Run the same pull request twice: once on your current runner with the workflow above, once on warp-ubuntu-latest-x64-8x, and compare the per-step timings in the GitHub Actions log.
Three measurements make that test honest. Record the selection size from npx nx show projects --affected so both runs cover the same tasks. Record the cache hit rate from the Nx terminal output, since a replayed task and an executed task are not comparable. Record install time separately from task time, because that is the number a snapshot runner changes.
For teams that need the runners inside their own cloud account, BYOC runs on AWS, GCP, and Azure, and Terraform support exists for BYOC on AWS.
FAQ
How do I set the base and head SHAs for nx affected on GitHub Actions?
Check out with fetch-depth: 0, then run nrwl/nx-set-shas, which exports NX_BASE and NX_HEAD. On pull requests NX_BASE is the merge base with the target branch. On pushes to main it is the SHA of the last successful run of the same workflow, which needs the actions: read permission.
Why does nx affected run every project on my pull requests?
Almost always the base SHA. A shallow checkout leaves git without the commits needed to compute the diff, and a base that points at the tip of main instead of the merge base marks every project touched by unrelated commits as affected. Print the selection with npx nx show projects --affected before trusting the run.
Should I cache .nx/cache or use a snapshot runner?
Cache .nx/cache when the Nx computation cache is the only warm state you need. Use a snapshot runner when install time dominates, since the snapshot holds node_modules and .nx/cache together. A snapshot costs $0.04 per restore plus $0.025 per snapshot-hour, so it pays for itself once it saves about 3.3 minutes on a warp-ubuntu-latest-x64-8x runner.
What --parallel value should I use on an 8 or 16 vCPU runner?
Start at 6 on warp-ubuntu-latest-x64-8x and 12 on warp-ubuntu-latest-x64-16x, leaving headroom for the Nx process itself and the runner agent. The real ceiling is the width of the affected task graph, so measure how many independent tasks a typical pull request selects before buying more cores.
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.