Skip to content

Blog

How do I keep a build directory (Gradle, Go, ccache) between GitHub Actions runs?

·

To keep a build directory between GitHub Actions runs, mount the same disk again rather than re-archiving it. actions/cache saves and restores a whole archive under a key, which suits dependency downloads. A persistent disk leaves a Gradle, Go, ccache or node_modules tree warm for the next job, provided untrusted builds work only on a copy.

Below: why archives strain on large build trees, what a persistent disk is, how pull requests are kept from poisoning it, its limits and cost, and when actions/cache is still better. GitHub facts were read from GitHub’s own documentation and changelog on 10 November 2026; runners.io facts come from our published documentation.

Why does actions/cache miss, or restore slowly, for large incremental build directories?

The cache is a set of immutable archives found by exact key, so a changed key is a miss, and a bigger tree means more to move on every run.GitHub’s documentation says you “cannot change the contents of an existing cache”: to change it you create one under a new key, and the job saves it only if the job succeeds.

Two failures follow for build directories. With a fixed key, the first run’s contents are kept and nothing a later build adds is saved, so an incremental tree stops improving. With a key built from a lockfile or source hash, every change misses on the exact key. A restore-keys prefix then restores the most recently created matching entry, the build finishes, and the whole directory is archived and uploaded again under the new key.

That is where the slowness comes from, in our reading. Each run downloads and unpacks the whole archive before the build starts, and a run that misses its exact key packs and uploads the whole directory again, however little changed. The archive is compressed with gzip or zstd, depending on the runner’s operating system.

Three more GitHub rules make it worse. A pull request run saves under its merge ref, which only re-runs of that pull request can restore, so a new pull request benefits only from what its base branch or the default branch saved. The repository limit is 10 GB by default, the least recently accessed entries go first when it is full, and anything not accessed in over seven days is removed. Since 20 November 2025 the limit can be raised, with storage above 10 GB charged and a Pro, Team or Enterprise account required. The full rules are in the GitHub Actions cache limit post.

An open request on the cache action’s repository, filed in 2020, asks for storage other than GitHub’s rather than uploading and downloading gigabytes, and later comments in its thread report large Rust and Unity builds evicted by the size limit.

What is a persistent (sticky) disk, and how does it differ from a cache archive?

A sticky disk is a second block device attached to a job and kept between jobs for the same repository, so the directory you point at it is already there on the next run instead of being archived and unpacked. The job’s root filesystem is still new every time; only this disk persists. When a job has one, it is mounted before your steps run and its path is in GHWARP_STICKY_DISK. You lay it out as you like.

Aspectactions/cacheSticky disk on runners.io
What is keptAn archive of the directory, saved under a keyA disk, mounted again at the same path
Changing itImmutable: new contents need a new key and a full new archiveA kept build's changes are what the next job starts from
Parallel jobsEach restores the same entryEach gets its own private copy of the last saved state
Who can writeAny run with write access to that scope; low-trust triggers are read-only by defaultOnly a successful default-branch build, decided by us
ExpiresAfter 7 days without an accessAfter 7 days without a job attaching it
Size10 GB per repository by default; more is paid100 GB per organisation
Where it worksAny runnerOur Linux x64 runners; not Windows, not ARM64
CostIncluded up to the default size, then GitHub's storage rateBilled on the blocks actually used; rate on the pricing page

Parallel jobs are the difference that matters most for a matrix. When several jobs of one repository run at once, each is given its own private copy of the disk as it was last saved, on whichever machine it lands. Jobs never share a live disk, so a ten-job matrix gives all ten the warm tree.

An owner, admin or billing member turns sticky disks on under Settings, Runners, Add-ons; your organisation needs a card on file, and it takes effect within a couple of minutes. Then point each tool’s cache directory at the disk. This workflow does that for Go and ccache, and falls back to a temporary directory when no disk was mounted. We ran it, and the Gradle step after it, as written on 10 November 2026 on runnersio-8vcpu-ubuntu-2404 in a repository of an organisation with sticky disks on, each twice with a pause between, as builds of the default branch. The first Go run compiled 62 packages, the standard library among them, and left 341 files on the disk. The second started with all 341 and compiled none. A third run with different source found the disk warm but not the first run’s marker file, and compiled only the two packages of the module. Gradle’s compileJava ran in the first build and was FROM-CACHE in the second, with the project setting org.gradle.caching=true, which the recipe does not set for you. One thing is not on the image: ccache. The Ubuntu 24.04 image had no ccache command, so the CCACHE_DIR line holds nothing until your job installs it, and we did not measure ccache hits. The using sticky disks page has the documented form, with the Go caches set in a step’s env.

name: Build on: push: branches: [main] # the default branch pull_request: jobs: build: runs-on: runnersio-8vcpu-ubuntu-2404 steps: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: go-version-file: go.mod cache: false - name: Point the build caches at the disk run: | DISK="${GHWARP_STICKY_DISK:-$RUNNER_TEMP}" mkdir -p "$DISK/go-build" "$DISK/go-mod" "$DISK/ccache" { echo "GOCACHE=$DISK/go-build" echo "GOMODCACHE=$DISK/go-mod" echo "CCACHE_DIR=$DISK/ccache" # only if the build uses ccache } >> "$GITHUB_ENV" - run: go build ./...

For Gradle, link only the caches directory rather than moving the whole user home, because the user home also holds gradle.properties, which can carry credentials, and credentials must stay off the disk:

- uses: actions/setup-java@v6 with: distribution: temurin java-version: 21 - name: Keep Gradle's caches on the disk run: | DISK="${GHWARP_STICKY_DISK:-$RUNNER_TEMP}" mkdir -p "$DISK/gradle-caches" "$HOME/.gradle" ln -sfn "$DISK/gradle-caches" "$HOME/.gradle/caches" - run: ./gradlew build

The first build of the default branch formats the disk, so treat that run as cold; until a default-branch build has kept something, every build starts empty. A disk that cannot be prepared does not fail the job. The using sticky disks page has the rest, including up to five more named disks per job.

Which builds should write to it, and how are pull requests kept from poisoning it?

Only a successful build of the default branch should write to it, and on runners.io that is decided on our side, not by your workflow file. A successful default-branch build started by push, schedule, workflow_dispatch, repository_dispatch, delete or page_build keeps what it leaves, as does a registry_package run for a package published from the default branch. If that build fails, is cancelled or times out, what it left is thrown away.

Every other build of a branch of the repository works on a private copy that is thrown away when the job ends. Feature branches, pull requests from branches of the repository, issue_comment, workflow_run and merge queues all start warm and change nothing. A build from a fork, a build that runs a tag, and a build whose workflow run we could not read get no disk.

The reason is the one GitHub gives for its own cache: cache contents are “not signed or verified”, and anyone who can open a pull request against a repository can read the caches in its base branch, so GitHub says to restrict writes to workflows triggered by trusted actors. Its 26 June 2026 change made low-trust triggers such as pull_request_target read-only by default. A build working on a private copy can still read what is on the disk, so never put a credential on one.

Before a build’s changes are kept, the part of the disk it changed is checked for the formats of GitHub, AWS, Slack, Stripe, npm, PyPI and crates.io tokens. If one is found, nothing that build left is kept and the job is not failed. It is a safety net, not a licence: it cannot see inside a compressed or encrypted file and does not look for private keys. A repository can also be protected, so that only a trusted default-branch build gets the real disk and every other build gets an empty one. The price is that pull requests start cold every time. If a credential does reach a disk, reset it from Settings, Runners, Add-ons and rotate the credential. The protection page has the exact rules.

How long does a sticky disk last, how big can it get, and what does it cost?

A repository’s disk is deleted after seven days without a job attaching it, your organisation can keep up to 100 GB, and you pay per GB-month for what is actually stored. Each attach resets the clock, so a repository that builds weekly keeps its disk; one that stops loses it. Once deleted it is not billed.

The meter reads the blocks actually used, not the size the disk reports. The pricing page has the rate. Storage is measured hourly, so the settings card can be up to an hour behind. At the cap your builds still run with their disks, but nothing more is kept until you reset a repository’s disk or ask us to raise the cap. Turning sticky disks off stops new builds getting one, and what is already stored is billed until it is deleted.

Nothing on a sticky disk is durable, by design: everything on it must be reconstructible by the build, so point it at cache directories, never at a whole home directory.

When is actions/cache still the right tool?

actions/cache is still the right tool for small, keyed inputs that rarely change, such as a package store keyed by a lockfile hash, and for any job where a sticky disk is not available. A key is a clean-room guarantee: change the lockfile and the next build starts from exactly what the key names. A disk carries whatever the last kept build left, so a stale file that breaks a build is cleared by a reset, not a new key. That is our opinion.

There is no sticky disk on Windows jobs, on ARM64 jobs, on a build from a fork or on a build that runs a tag. On those, and for the dependency download step everywhere, keep using the archive. On our runners it works without a workflow change and is included rather than metered, with published limits of 25 GiB per repository and 250 GiB per organisation. The Actions cache reference has the details. This step uses the disk when there is one and the archive when there is not. We ran it both ways on 10 November 2026. On a default-branch build with a disk, the variable was set, present was true and the cache step was skipped. On a build of a tag, which gets no disk, the variable was unset and the step ran: the first run found nothing and saved about 7 MB, and the second restored it:

- id: disk run: | if [ -n "${GHWARP_STICKY_DISK:-}" ]; then echo "present=true" >> "$GITHUB_OUTPUT" fi - if: steps.disk.outputs.present != 'true' uses: actions/cache@v6 with: path: ~/.cache/go-build key: go-${{ runner.os }}-${{ hashFiles('go.sum') }} restore-keys: | go-${{ runner.os }}-

For a large build tree on our Linux x64 runners, start with the sticky disk guide, and read the protection rules before you decide what goes on it.

Run your CI and your infrastructure on one bill

Connect GitHub

Card required. You pay only for the minutes your jobs run.