GitHub Actions on Large Repositories

Large repositories run slow on GitHub Actions because every job rebuilds a cold working set. Measure the setup phases, then keep the tree warm.

Last verified:

GitHub Actions is slow on a large repository because every job starts on a clean VM and rebuilds the entire working set before the build begins: fetch the history, write tens of thousands of files to disk, unpack a multi-gigabyte cache archive, and reinstall dependencies. The fix is to measure those setup phases separately from the build, then carry the warm working tree between runs with snapshot runners and size the runner label for disk throughput and memory rather than for cores alone.

Diagnosis

Four phases carry the cost, and they respond to different changes. Split them into separate workflow steps so the GitHub Actions log prints a duration for each one, then read which phase dominates.

Clone and fetch. The transfer from GitHub scales with history depth, submodule count, and LFS object volume. A repository with a decade of history moves gigabytes of packfiles per job, and a matrix of twenty jobs multiplies that by twenty against the same GitHub endpoint. This page treats the fetch as one line item; the fetch itself, including depth, submodules, LFS, and mirror strategies, is covered in speeding up git checkout in GitHub Actions.

Working tree materialization. After the objects arrive, git writes the files. This phase is dominated by file count rather than by repository size in bytes, and it runs largely single threaded. A tree of 300,000 small files costs more here than a tree of 400 large ones at the same total volume, and no amount of extra vCPU changes that.

Cache archive size. A cache entry for a large repository is a tar of a dependency store, a build output directory, or both. Restoring it means downloading the archive and decompressing it onto the same disk the checkout is using. Above a few gigabytes the restore stops being a fast path and starts competing with the checkout for disk bandwidth. Watch for the case where the archive is written on every pull request instead of on the default branch, since that doubles the traffic and evicts the entry everything else restores from.

Disk pressure. Every WarpBuild Linux label carries 150GB SSD, from warp-ubuntu-latest-x64-2x through warp-ubuntu-latest-x64-32x. Moving up a size adds vCPU and RAM while the disk stays at 150GB. Runner storage is ephemeral on every platform and is deleted when the runner terminates, so the budget resets each job.

Write the disk budget out once for your heaviest job. The example below is the repository used through the rest of this page. Substitute your own numbers.

Item on diskExample size
.git directory after fetch5 GB
Working tree14 GB
Dependency store8 GB
Build output directory40 GB
Container images pulled during the job18 GB
Job total85 GB

Against 150GB that leaves room, until the base image and preinstalled toolchains take their share and a second build configuration lands in the same output directory. Jobs that fail late with a write error, or that slow down as the disk fills, are usually reading this table rather than a CPU graph.

Measure the phases before changing anything

Here is the shape the measurement usually produces on warp-ubuntu-latest-x64-8x at 8 vCPU and 32 GB. Cold means a clean runner with no cache restored. Warm means the job booted from a snapshot that already holds the tree. The build column is held constant on purpose, because build time belongs to a different lever.

PhaseCold minutesWarm minutes
Clone and fetch from GitHub3.80.5
Working tree materialization1.70.2
Dependency install3.00.3
Cache archive download and unpack, 6 GB2.50.0
Setup subtotal11.01.0
Build and test14.014.0
Job total25.015.0

One run tells you very little on a repository this size, since a cold registry, a busy mirror, or an unlucky cache miss moves any single number. Read the aggregate instead. The WarpBuild reports documentation describes the Jobs section, which aggregates every unique repository, workflow, and job name and reports Duration P75 and P90, Queue Time P75 and P90, and, with CI observability enabled, CPU P75 and P90 and Memory P75 and P90. Filter by runner label, export the CSV, and compare the same job before and after the change. A job whose CPU P90 sits low while Duration P90 stays high is waiting on disk or network, which points at the phases above rather than at the machine size.

Fix

Three levers apply here, and they are independent of each other.

Carry the working set between runs. Snapshot runners capture the runner VM disk mid-workflow and boot a later job from that image, so the .git directory, the working tree, the dependency store, and pulled container images are already present. That collapses the 11.0 minute setup column above to about 1.0 minute, because the fetch has only new commits to transfer and the install step reconciles an existing tree. Snapshot runners are one part of the product surface, alongside remote Docker builders, CI observability, an MCP server, and the Action Debugger.

Three boundaries decide whether this lever is available to a given job. Snapshots run on WarpBuild Cloud Ubuntu runners only, and the labels are silently ignored on macOS, Windows, and BYOC runners. Every snapshot is deleted after 15 days. A snapshot boot takes 45 to 60 seconds, which is slower than a default runner boot, so the pattern pays on jobs with real setup work and loses on short ones.

Size the label for the phase that dominates.. On Linux, disk stays at 150GB across every size, so a move up buys vCPU, RAM, and the throughput ceiling of a larger instance class. Two of those matter for large repositories. Storage and network throughput ceilings on cloud instances scale with instance size, so a 16x or 32x label can shorten an archive unpack or a large fetch on a job that never saturates its cores. And RAM decides whether a 14 GB working tree stays in page cache across the steps that read it repeatedly, which is the difference between 32 GB on the 8x label and 64 GB on the 16x label.

LabelvCPURAMStorageRate per minute
warp-ubuntu-latest-x64-2x28 GB150GB SSD$0.004
warp-ubuntu-latest-x64-4x416 GB150GB SSD$0.008
warp-ubuntu-latest-x64-8x832 GB150GB SSD$0.016
warp-ubuntu-latest-x64-16x1664 GB150GB SSD$0.032
warp-ubuntu-latest-x64-32x32128 GB150GB SSD$0.064

Rates come from the WarpBuild pricing page, verified on 2026-08-13. ARM64 carries the same sizes and the same 150GB SSD at $0.003 to $0.048 per minute. Windows labels carry 256GB SSD and the 12 vCPU macOS label carries 270GB SSD, which is the option when the working set itself will not fit on a Linux runner, though snapshots are unavailable on both.

Lower the rate at the same size. Runner images carry the same tooling as GitHub-hosted runners, so a job moves by editing one runs-on value. Same-size list prices, GitHub rates read from the GitHub Actions per-minute rate reference and checked on 2026-08-13:

vCPUGitHub-hosted labelGitHub rateWarpBuild labelWarpBuild rateDifference
2ubuntu-latest on private repositories$0.006warp-ubuntu-latest-x64-2x$0.00433 percent lower list price
44-core Linux larger runner$0.012warp-ubuntu-latest-x64-4x$0.00833 percent lower list price
88-core Linux larger runner$0.022warp-ubuntu-latest-x64-8x$0.01627 percent lower list price
1616-core Linux larger runner$0.042warp-ubuntu-latest-x64-16x$0.03224 percent lower list price
3232-core Linux larger runner$0.082warp-ubuntu-latest-x64-32x$0.06422 percent lower list price

Two more facts shape a large-repository rollout. Run as many jobs as your workflows need, since generally available Linux and Windows runners do not have plan-level concurrency caps, which is what lets a wide test matrix start at once instead of queueing behind a plan limit. And if the runners have to sit inside your own cloud account, BYOC runs on AWS, GCP, and Azure, with Terraform support for BYOC on AWS; snapshot labels are ignored there, so a BYOC job leans on checkout tuning and cache archives.

Configuration

The pattern separates the writer from the readers. Pushes to the default branch boot clean with snapshot.enabled=true and publish a fresh image. Every other run boots from that alias with snapshot.key=<alias> and finds the tree already on disk.

name: build
on:
  push:
    branches: [main]
  pull_request:

jobs:
  build:
    runs-on: >-
      ${{ github.ref == 'refs/heads/main'
        && 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true'
        || 'warp-ubuntu-latest-x64-8x;snapshot.key=platform-worktree' }}
    steps:
      - name: Report boot source
        run: echo "booted from ${WARPBUILD_SNAPSHOT_KEY:-base image}"

      - uses: actions/checkout@v5
        with:
          fetch-depth: 0

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Build
        run: pnpm build

      - name: Test
        run: pnpm test

      - name: Remove credentials before the snapshot
        if: github.ref == 'refs/heads/main'
        run: rm -rf $HOME/.ssh $HOME/.aws $HOME/.npmrc $HOME/.docker/config.json

      - name: Save snapshot
        if: github.ref == 'refs/heads/main'
        uses: WarpBuilds/snapshot-save@v1
        with:
          alias: "platform-worktree"
          fail-on-error: false
          wait-timeout-minutes: 60

Six details decide whether this behaves the way the table above predicts.

  1. The label carries the setting. Append snapshot.enabled=true or snapshot.key=<alias> to the runner label after a semicolon. snapshot.enabled=true always boots from the base image and is the label for the job that produces a clean image. snapshot.key=<alias> boots from the existing snapshot for that alias, falling back to the base image when none exists.
  2. What resets on every boot. The machine reboots, so no process from the earlier job is running, no port is listening, and no container is up. /tmp is cleaned on reboot, so keep anything you want to survive under $HOME or inside the workspace. GitHub hands the job a fresh runner registration with its own GITHUB_* environment, secrets, and token, so the snapshot supplies the disk while GitHub supplies the job identity.
  3. Checkout still runs. actions/checkout reuses an existing checkout when the directory already holds the right repository, so on a warm boot the fetch transfers only the commits added since the snapshot was written. Keep the step in the workflow.
  4. Skip git clean -ffdx in this pattern. The command removes gitignored paths, which is exactly the dependency store and build output the snapshot exists to carry. Remove credential files by path instead, as the workflow above does, and read the security notes in the snapshot runners documentation before rolling this out on a public repository or across an organization.
  5. Raise the save timeout for a large disk. wait-timeout-minutes defaults to 30, which is generous for a dependency tree and tight for tens of gigabytes of build output. fail-on-error: false keeps a failed capture from turning the pipeline red.
  6. Refresh the alias often. Snapshots are deleted after 15 days, and a stale alias drifts from the branch it was taken on. A job pointing at an expired or missing alias still runs; it boots from the base image and pays the full setup on that run.

The build side of a warm tree, meaning compiler state and incremental output, is covered in incremental builds on GitHub Actions. Fanning a large repository across several sized labels is covered in monorepo pipelines on GitHub Actions, and the full snapshot reference including break-even tables per size is on snapshot runners for GitHub Actions.

Cost or Time Model

Assumptions, all stated so you can substitute your own:

  • One repository at the size in the disk budget above: 5 GB of history and a 14 GB working tree.
  • 1,200 jobs per month on warp-ubuntu-latest-x64-8x.
  • Cold job 25.0 minutes, warm job 15.0 minutes, from the phase table.
  • One snapshot alias kept alive for the whole month.
  • Build and test time is identical in both columns, so the model measures setup only.

Minutes: 1,200 x 25.0 = 30,000 cold minutes per month against 1,200 x 15.0 = 18,000 warm minutes.

LineCold runnersWarm from one snapshot alias
Billed minutes per job25.015.0
Runner cost per job at $0.016$0.400$0.240
Snapshot restore per job$0.000$0.040
Cost per job$0.400$0.280
1,200 jobs$480.00$336.00
Snapshot storage, one alias, 30 days$0.00$18.00
Monthly total$480.00$354.00

Snapshot restore bills $0.04 per job that boots from a snapshot and snapshot storage bills $0.025 per snapshot-hour, which is $0.60 per day and $18.00 per 30 day month for one alias. Spread across 1,200 jobs that storage line is $0.015 per job. Both rates are on the pricing page, verified on 2026-08-13, and both appear as their own columns on the CI Billing report next to runner cost.

The break-even test is one division. A restore costs $0.04 regardless of size, so on the 8x label at $0.016 per minute it has to remove 2.5 minutes of work to pay for itself. This job removes 10.0 minutes, which clears the bar by a wide margin. On warp-ubuntu-latest-x64-2x at $0.004 per minute the same restore has to remove 10.0 minutes, which is why small runners with light setup should stay stateless.

For an external reference point, the same 25.0 minute cold job on the 8-core GitHub-hosted Linux larger runner at 8 vCPU and 32 GB lists at $0.022 per minute: 30,000 minutes is $660.00 per month. GitHub list price from the GitHub Actions per-minute rate reference, checked on 2026-08-13. GitHub-hosted runners have no snapshot equivalent, so that column has no restore or storage line and no warm variant.

The wall clock number is the one engineers feel. Ten minutes returned on 1,200 jobs is 12,000 minutes per month, or 200 hours of waiting on pull requests that the team gets back. Confirm it with Duration P75 and P90 for that job in the Jobs report rather than with the fastest run you happen to see, and check Queue Time P75 and P90 in the same view so a change in wait time does not get read as a change in build time.

Where the model breaks: a repository whose pull requests routinely invalidate the dependency store puts the install phase back into the warm column, an alias refreshed only weekly drifts far enough that the fetch phase grows again, and a job under 2.5 minutes of removable setup on the 8x label loses to the restore fee. Rerun the arithmetic per workflow with your own numbers from the CI Billing report before adding a snapshot label across a repository.

FAQ

Why are GitHub Actions jobs slow on a large repository?

Because every job starts on a clean VM and pays for the whole working set before the build starts: the fetch from GitHub, writing tens of thousands of files to disk, unpacking a multi-gigabyte cache archive, and reinstalling dependencies. Measure those four phases as separate steps first, since the one that dominates decides which fix is worth the effort.

Do snapshot runners work for a large repository on macOS or Windows?

No. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. A snapshot label on a macOS, Windows, or BYOC runner is silently ignored and the job runs normally at full setup cost, so those jobs rely on a narrow checkout and a cache archive instead.

Should a large-repository job move to a 16x or 32x runner?

Move up when the job is waiting on storage and network throughput or thrashing page cache, since throughput ceilings on cloud instances scale with instance size and warp-ubuntu-latest-x64-16x carries 64 GB of RAM against 32 GB on the 8x label. A job that sits at low peak CPU while it unpacks an archive is buying throughput rather than cores.

How much disk does a WarpBuild Linux runner have?

Every Linux label carries 150GB SSD, from warp-ubuntu-latest-x64-2x through warp-ubuntu-latest-x64-32x, so moving up a size adds vCPU and RAM while the disk stays the same. Windows labels carry 256GB SSD and the 12 vCPU macOS label carries 270GB SSD.

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.