Persistent Caches for GitHub Actions Runs

A GitHub Actions runner is destroyed after every job. Persist state with a cache action for files, or with a snapshot runner for whole machine state.

Last verified:

A GitHub Actions runner is destroyed when its job finishes, so every run starts from a blank machine and pays to rebuild whatever the previous run left behind. Two mechanisms carry state forward: a cache action, which uploads named paths at the end of a run and downloads them at the start of the next one, and a WarpBuild snapshot runner, which captures the entire runner VM mid-workflow and boots later jobs from that image. This guide covers which mechanism fits which kind of state, the exact runs-on syntax for both, and the arithmetic that decides between them.

Diagnosis

Start by naming the state your workflow keeps rebuilding. Three separate costs hide under the label "cold start", and they respond to different fixes.

The restore and save round trip runs on every job

A cache action is a network transfer wrapped in a workflow step. On a hit it downloads a compressed archive and decompresses it into the paths you named; on a save it compresses those paths and uploads them again. Both halves are charged as runner minutes, because the runner is billed for the wall clock time it spends waiting on that transfer.

The round trip is nearly constant work per run, and it grows with the size of the archive rather than with the size of the change. A pull request that touches one file still pays the full restore for a 20 GB dependency and build tree. Look at the step timings on a recent run: if the restore step and the post-run save step together account for more than a quarter of the job, the transfer is the cost, and shrinking the entry or skipping the save on branches will move the number more than any runner resize.

Some entries are too large to restore quickly

Cache archives are compressed with zstd and streamed onto the runner. That is efficient for a few hundred megabytes of package tarballs and slow for tens of gigabytes of Gradle caches, container layers, and compiled artifacts. Large entries also fail in specific ways. Docker layers larger than 5 GB on a small runner produce a "Failed to commit cache" error, which the caching documentation resolves by moving the job to a larger runner size.

Size interacts with retention. A cache entry expires 7 days after its last use, and on GitHub's own cache storage a repository-wide ceiling evicts entries in least recently used order once the total passes the cap. The guide to the GitHub Actions cache size limit covers that ceiling and how to remove it. The point here is narrower: an entry big enough to be slow to restore is also big enough to crowd out its neighbors.

Incremental build state dies with the runner

The costs above are transfer costs. This one is structural. Incremental compilers depend on state that a cache action was never designed to move.

  • A Gradle or Bazel daemon that has a warm JVM, a populated in-memory file hash cache, and an open project model. The daemon dies with the machine.
  • Timestamps and inode identity across the working directory. A fresh git clone writes new timestamps, so an incremental compiler that compares mtimes treats every source file as changed even when the bytes are identical.
  • The Docker daemon's local image store, its overlay layers, and any containers a test harness left warm.
  • Package manager state that lives across several directories at once, where naming every path in a path: list is guesswork.

None of that survives a restore, which is why a workflow can hold a 95 percent cache hit rate and still spend eight minutes before the first useful compilation. The state is machine state, and a cache action moves files.

Measure the split before you choose a fix. Add up the seconds spent in the restore step, the seconds spent in the post-run save step, and the seconds between the end of the restore and the first step that does new work. The third number is reconcile time, and it is the number a cache action cannot reduce.

Fix

Pick the mechanism that matches the state, and be willing to run both.

Use a cache action when the state is a set of files you can name by path. Dependency directories such as ~/.npm, ~/.m2, ~/.gradle/caches, ~/.cargo, and vendor/bundle are exactly this shape. They are content addressed by a lockfile hash, they restore in seconds at typical sizes, and they are shared cleanly across branches through restore-keys. Swapping actions/cache@v4 for WarpBuilds/cache@v1 is a one-line change, and the WarpBuild setup actions for Node.js, Python, Go, Java, .NET, Ruby, Zig, Rust, Gradle, and mise wire the same storage in with no key management at all.

Use a snapshot runner when the state is the machine. A snapshot runner captures the runner VM at a chosen point in the workflow, and later jobs boot from that image with the filesystem, the installed toolchains, the pulled container images, and the prior build outputs already in place. Nothing is transferred at job start, so the cost stops scaling with the size of the state.

The boundary runs in both directions, and both directions have a cost.

QuestionCache actionSnapshot runner
What it movesNamed paths, as a compressed archiveThe whole runner VM image
Cost at job startGrows with archive sizeA boot of 45 to 60 seconds, flat
Per job fee$0.0001 per cache operation$0.04 per snapshot restore
Standing fee$0.20 per GB-month of storage$0.025 per snapshot-hour
Good fitLockfile-keyed dependency treesWarm daemons, container stores, incremental build trees
Poor fitTens of GB of mixed machine stateA 500 MB npm cache
Platform supportLinux x64 and ARM64 runnersWarpBuild Cloud Ubuntu runners only

Read the poor-fit row carefully. A snapshot runner boots in 45 to 60 seconds, which is slower than a base image boot, and it charges $0.04 for the restore. On a small dependency cache the snapshot loses on both counts. Reach for it when the setup work it removes is worth more than that fixed boot and fee, and keep the cache action everywhere else.

State the unsupported surfaces plainly before you plan a rollout. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. BYOC runners, Windows runners, and macOS runners are not supported, and a snapshot label on any of those is silently ignored, so the job runs normally with no snapshot behavior and no error. The /tmp directory does not persist, because it is cleaned on reboot. Snapshots are deleted after 15 days.

The conditional runs-on pattern

The pattern the snapshot runner documentation recommends puts snapshot creation on the default branch and snapshot consumption on feature branches. Pushes to main boot from the base image with snapshot.enabled=true and write a fresh snapshot at the end of the run; every other branch boots from that snapshot with snapshot.key=<alias>.

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

jobs:
  build:
    runs-on: >-
      ${{ github.ref == 'refs/heads/main'
        && 'warp-ubuntu-latest-x64-16x;snapshot.enabled=true'
        || 'warp-ubuntu-latest-x64-16x;snapshot.key=monorepo-main' }}
    steps:
      - name: Checkout code
        uses: actions/checkout@v5

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

      - name: Build
        run: pnpm build

      - name: Test
        run: pnpm test

      - name: Cleanup credentials
        if: github.ref == 'refs/heads/main'
        run: |
          rm -rf $HOME/.ssh $HOME/.aws
          git clean -ffdx

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

Two properties make this shape the default recommendation. Snapshots are only ever written from a branch you control, so a pull request cannot poison the image other pull requests boot from. And the snapshot is rebuilt from the base image on every push to main, which bounds how far the image can drift from a clean install.

The runner label carries the snapshot directive after a semicolon, so the whole thing stays a single runs-on string. The snapshot API supports listing and deleting aliases from automation as well as from the console.

Configuration

Two labels control the feature, and the difference between them is the boot source.

  • snapshot.enabled=true turns the feature on and always boots from the base image. Pair it with the WarpBuilds/snapshot-save@v1 action to capture the machine at a chosen point.
  • snapshot.key=<alias> turns the feature on and boots from an existing snapshot for that alias when one exists. With no snapshot yet, the runner boots from the base image, so the first run of a new alias is correct without special handling.

A job booted from a snapshot carries the WARPBUILD_SNAPSHOT_KEY environment variable set to the alias. Branch on it when a step should behave differently on a warm machine:

      - name: Prime toolchain
        if: env.WARPBUILD_SNAPSHOT_KEY == ''
        run: ./scripts/install-toolchain.sh

The snapshot-save action takes three inputs. alias is required and is the name later jobs boot from. fail-on-error defaults to true and turns a failed snapshot creation into a failed job. wait-timeout-minutes defaults to 30 and bounds how long the action waits for creation to finish; large images on busy repositories are the reason to raise it to 60.

Clean the machine before you capture it

A snapshot preserves everything on disk, including anything a step wrote into a home directory. Put a cleanup step ahead of snapshot-save on every workflow that captures an image:

rm -rf $HOME/.ssh $HOME/.aws
git clean -ffdx

git clean -ffdx removes untracked files and directories, including files ignored by .gitignore; the doubled -f forces removal even where clean.requireForce is set. Two exposure paths make this mandatory rather than tidy. On public repositories, anyone who can open a pull request can reference the alias and boot a runner from your image. On private repositories, WarpBuild provisions runners at the organization level and GitHub may hand a runner built for a snapshot job to a different job in the organization, so treat the snapshot as readable by anyone in the org.

Incremental snapshots on a feature branch

When each run should build on the previous one, drop the conditional and use one alias for both boot and save. Every run boots from the latest snapshot and writes a new one at the end:

jobs:
  build:
    runs-on: warp-ubuntu-latest-x64-8x;snapshot.key=integration-suite
    steps:
      - uses: actions/checkout@v5

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

      - name: Build
        run: pnpm build

      - name: Cleanup credentials
        run: |
          rm -rf $HOME/.ssh $HOME/.aws
          git clean -ffdx

      - name: Save snapshot
        uses: WarpBuilds/snapshot-save@v1
        with:
          alias: "integration-suite"
          fail-on-error: true
          wait-timeout-minutes: 60

This shape keeps incremental build directories warm across runs, which is the case a cache action handles worst. It also compounds drift, because nothing ever resets the image to a clean base. Rebuild the alias from a scheduled job on the default branch with snapshot.enabled=true if the working tree accumulates junk over a week.

Running both mechanisms together

Snapshots and cache actions coexist. A common split puts machine state in the snapshot and lockfile-keyed dependencies in the cache, so a lockfile bump invalidates one small entry instead of the whole image:

      - name: Restore pnpm store
        uses: WarpBuilds/cache@v1
        with:
          path: ~/.local/share/pnpm/store
          key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
          restore-keys: |
            ${{ runner.os }}-pnpm-

Platform placement decides what is available. The cache is enabled by default on Linux runners and is not supported on Windows-based runners, and snapshots are limited to Cloud Ubuntu runners, so a Windows or macOS job in the same workflow keeps its existing setup steps unchanged.

Cost or Time Model

Prices first, then a worked model. GitHub's list prices for Linux GitHub-hosted runners, from the GitHub Actions billing reference, checked on 2026-08-13:

GitHub-hosted runnerShapePrice per minute
ubuntu-latest on a private repository2 vCPU, 8 GB$0.006
4-core Linux larger runner4 vCPU, 16 GB$0.012
8-core Linux larger runner8 vCPU, 32 GB$0.022
16-core Linux larger runner16 vCPU, 64 GB$0.042
32-core Linux larger runner32 vCPU, 128 GB$0.082

WarpBuild Linux rates for the same shapes, from the pricing page:

Runner labelvCPURAMPrice per minute
warp-ubuntu-latest-x64-2x28 GB$0.004
warp-ubuntu-latest-x64-4x416 GB$0.008
warp-ubuntu-latest-x64-8x832 GB$0.016
warp-ubuntu-latest-x64-16x1664 GB$0.032
warp-ubuntu-latest-x64-32x32128 GB$0.064
warp-ubuntu-latest-arm64-4x416 GB$0.006
warp-ubuntu-latest-arm64-8x832 GB$0.012
warp-ubuntu-latest-arm64-16x1664 GB$0.024
warp-ubuntu-latest-arm64-32x32128 GB$0.048

State-persistence fees on hosted runners:

MetricRate
Snapshot restore$0.04 per job
Snapshot storage$0.025 per snapshot-hour
Cache storage$0.20 per GB-month
Cache write or restore$0.0001 per operation

Break-even before any modeling

One snapshot restore costs $0.04. Divide that by the per-minute rate to get the setup time a snapshot has to remove before it pays for itself:

Runner labelPrice per minuteRunner minutes equal to one snapshot restore
warp-ubuntu-latest-x64-2x$0.00410.0
warp-ubuntu-latest-x64-4x$0.0085.0
warp-ubuntu-latest-x64-8x$0.0162.5
warp-ubuntu-latest-x64-16x$0.0321.25
warp-ubuntu-latest-x64-32x$0.0640.63
warp-ubuntu-latest-arm64-8x$0.0123.33
warp-ubuntu-latest-arm64-16x$0.0241.67

The rule falls out of the table. On a small runner with a small dependency cache, a snapshot has to save ten minutes per run to break even on the restore fee alone, which a 500 MB npm cache never will. On a large runner with a large working set, the threshold is under 90 seconds, and the boot itself takes 45 to 60 seconds of that.

Worked time model

Assumptions, stated so you can substitute your own numbers from your step timings:

  • A monorepo on warp-ubuntu-latest-x64-16x at $0.032 per minute.
  • 30 workflow runs per weekday, 22 weekdays per month, so 660 runs.
  • A 20 GB working set: dependency trees, Gradle caches, pulled container images, and prior build outputs.
  • On the cache path: restoring 20 GB takes 5.0 minutes, reconcile after restore takes 3.0 minutes, and a save takes 5.0 minutes on the one run in five where a key changes, which is 1.0 minute amortized. Base image boot is 0.25 minutes.
  • On the snapshot path: boot from snapshot is 1.0 minute, the docs upper bound of 60 seconds. Reconcile is 1.0 minute, because prior build outputs and warm images are already on disk. Snapshot creation costs 5.0 minutes on each of 22 pushes to main, which is 0.17 minutes amortized over 660 runs.
StageCache action pathSnapshot path
Runner boot0.25 min1.00 min
Restore state5.00 min0.00 min
Reconcile after restore3.00 min1.00 min
Save state, amortized1.00 min0.17 min
Total per run9.25 min2.17 min

The difference is 7.08 minutes per run. Across 660 runs that is 4,673 minutes per month, or about 78 hours of runner time, and every one of those minutes sits at the front of a job where a developer is waiting on a check.

Worked money model

Same assumptions, priced out:

LineMonthly
Runner minutes removed, 4,673 at $0.032$149.53
Cache storage no longer held, 20 GB at $0.20 per GB-month$4.00
Cache operations no longer run, 3,960 at $0.0001$0.40
Snapshot restores added, 660 at $0.04$26.40
Snapshot storage added, 720 snapshot-hours at $0.025$18.00
Net$109.53

Two sensitivities are worth checking against your own numbers. Runner size moves the top line and nothing else, so the same workload on warp-ubuntu-latest-x64-4x at $0.008 per minute removes $37.38 of runner cost against the same $44.40 of snapshot fees, and the trade turns negative. Run count moves both, but the snapshot storage line is fixed at $18.00 per alias per month whether the repository runs 60 jobs or 6,000, so busy repositories amortize it away.

The same workload on GitHub-hosted runners carries the full 9.25-minute setup on every run with no snapshot mechanism available, priced at $0.042 per minute for the 16 vCPU, 64 GB shape (GitHub billing reference, checked on 2026-08-13) against $0.032 per minute for warp-ubuntu-latest-x64-16x with the same 16 vCPU and 64 GB, which is 24 percent lower list price.

FAQ

How do I persist state between GitHub Actions runs?

Two mechanisms exist. A cache action uploads named paths at the end of a run and downloads them at the start of the next one, which suits dependency directories. A snapshot runner captures the whole runner VM mid-workflow and boots later jobs from that image, which suits state that lives outside a cacheable path, such as warmed daemons, container images, and incremental build outputs.

When should I use a snapshot runner instead of a cache action?

Use a snapshot runner when the restore and reconcile work at the start of a run exceeds the runner minutes that one snapshot restore costs. A snapshot restore is $0.04 per job, which equals 10 minutes on warp-ubuntu-latest-x64-2x and 1.25 minutes on warp-ubuntu-latest-x64-16x. Below that threshold, a cache action is the cheaper mechanism.

Which runners support snapshot runners?

Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners, on x64 and ARM64. BYOC runners, Windows runners, and macOS runners are not supported. Snapshot labels applied to an unsupported runner type are silently ignored and the job runs normally without snapshot behavior.

How long does a WarpBuild snapshot last, and what does it not capture?

Snapshots are temporary and are deleted after 15 days. The /tmp directory does not persist, because it is cleaned on reboot, so anything a workflow needs across runs must live outside /tmp. Snapshot storage is billed at $0.025 per snapshot-hour.

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.