Sticky disks keep your GitHub Actions caches warm between jobs
Tenki sticky disks keep dependency and compiler caches on a disk that follows the cache key, so jobs start warm without restoring a tarball.
Guzman Pintos
7 min read
Every GitHub Actions job starts on a clean machine. That's what you want from CI, and it's also why jobs spend so long downloading modules and recompiling code the previous job already compiled.
actions/cache helps by packing a directory into a tarball at the end of a job and unpacking it at the start of the next one. For a small dependency folder that works well. A Go build cache or a Playwright install can run to gigabytes. Every job downloads and unpacks it, saving a new entry means packing and uploading it again, and a lockfile change sends you back to an older entry.
Sticky disks, now in beta on Tenki Runners, skip the tarball. The cache lives on a disk, Tenki attaches that disk at the path you ask for when the job starts, and your tools find the last run's files already in place.
What a sticky disk is
A sticky disk is a disk that Tenki keeps between jobs, identified by a key you choose. A job mounts it at a path and uses it like any other directory. When a job that is allowed to publish succeeds, what it wrote becomes the starting point for the next job that mounts the same key.
Keys are scoped to your workspace, the repository and the region the job ran in. Two repositories that both use the key npm-cache get two separate disks.
Adding one to a workflow
Mount the disk before the step that fills it:
jobs:
test:
runs-on: tenki-standard-medium-4c-8g
steps:
- uses: actions/checkout@v6
- id: sticky
uses: LuxorLabs/stickydisk@v1
with:
key: npm-node22-v1
path: ~/.npm
- run: npm ci
- run: echo "sticky disk source is ${{ steps.sticky.outputs.source }}"
The action's source output tells you what the job got:
source | Meaning |
|---|---|
hit | A published generation was attached, and this job can publish an updated one. |
miss | The key has nothing published yet, so the job starts with an empty disk. |
readonly | A published generation was attached for reading. The job can write, but its writes are discarded. |
fallback | No disk was attached, so the job uses the runner's own disk and runs cold. |
The action also writes a Sticky disk block to the job summary with the mount mode, the generation, and the reason for any fallback.
The path must be empty (or not exist yet) and sit inside the job's workspace or under ~/.cache, ~/.npm, ~/.cargo, ~/.gradle or ~/go/pkg/mod. A job can mount up to 10 sticky disks.
Keys work differently from actions/cache
With actions/cache, the key usually contains a lockfile hash, and restore-keys provides a fallback when the hash changes. A sticky disk key is matched exactly and there are no restore keys, because the disk carries its contents forward from run to run. Adding one dependency changes a few files on the disk. The rest of the cache is untouched.
So pick a stable name per cache, like npm-node22-v1 or go-build-race-v1. Bump the suffix when a toolchain upgrade or a cache format change makes the old contents useless. Putting a commit SHA in the key stops any reuse between commits.
Who can write to the cache
A job never edits the shared cache directly. It gets its own writable view of the current generation, and what it writes becomes a new generation only after all of these are true:
- The workspace policy allows the job to publish.
- The action's post step recorded the request and flushed the disk.
- GitHub reports the job as successful.
- The detached disk passes Tenki's filesystem check.
- No other job published to the key after this one started.
A failed or cancelled job publishes nothing, and the previous generation stays in place.
Branch Protection is on for new workspaces. With it on, only push, schedule and workflow_dispatch jobs on the default branch publish. Pull requests, including those from forks, mount the current generation read-only. Pull requests still start warm, and code from a pull request can't change what your default branch builds with.
The flip side is that every job in the repository can read the cache. Don't leave credentials on a sticky disk, such as an .npmrc with a token in it.
Results from Tenki's own CI
Our backend is a large Go module, and we moved the caches of its main workflows onto sticky disks. Main Deploy runs on the default branch and publishes the caches. Go PR runs on every pull request and mounts them read-only.
To measure the difference, we compared successful runs of each job before and after the switch:
| Job | Before | After | Faster by |
|---|---|---|---|
| Main Deploy, full workflow | 7m 57s | 5m 46s | 27% |
| Main Deploy, Go Test | 4m 11s | 2m 00s | 52% |
| Main Deploy, sandbox-nodeagent build | 1m 32s | 1m 04s | 30% |
| Main Deploy, Linux CLI build | 1m 10s | 1m 00s | 15% |
| Go PR, Unit Tests | 5m 55s | 4m 44s | 20% |
| Go PR, Sandbox engine tests | 3m 13s | 2m 46s | 14% |
Every run with sticky disks mounted a warm cache. How much it saved depended on how much the code changed since the cache was published. Go only recompiles and reruns packages whose inputs changed, so a small change to a leaf package finishes fast, while a change to a package that everything imports rebuilds most of the tree.
The best case is a rerun of the same commit. When we tested the setup on a benchmark branch, a rerun with a warm disk took 1m 17s against 5m 12s for the first, cold run, because Go reused cached results for 226 of 241 packages. Don't expect that on a typical pull request.
The setup follows the pattern we use. Put the module cache and the build cache on separate keys, and point Go at them through environment variables:
steps:
- uses: actions/checkout@v6
- uses: LuxorLabs/stickydisk@v1
with:
key: go-mod-v1
path: ~/.cache/sticky/go-mod
- uses: LuxorLabs/stickydisk@v1
with:
key: go-build-test-v1
path: ~/.cache/sticky/go-build
- run: |
echo "GOMODCACHE=$HOME/.cache/sticky/go-mod" >> "$GITHUB_ENV"
echo "GOCACHE=$HOME/.cache/sticky/go-build" >> "$GITHUB_ENV"
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: false
- run: go test ./...
A few lessons from running this across our workflows:
- Turn off
setup-go's own cache. If you leave it on, it tars the same directories into the Actions cache on every run. - Give each set of compiler flags its own build key. A
-racebuild and a plain build produce different outputs, and if both publish to one key from the same generation, only one of them is kept. - Choose one job to publish each key. When two jobs publish the same key from the same generation, the first to finish wins and the other's changes are thrown away. Set
commit: neveron the jobs that should only read. - Keep a fallback if a cold run is too slow for you. Some of our E2E workflows restore from
actions/cache, but only when the sticky disk comes up empty.
Sticky disks or the Actions cache
You can use both in the same workflow, and Tenki Runners already come with a hosted Actions cache and dependency mirrors that need no workflow changes. A sticky disk pays off for a big cache that changes a little on every run, like a compiler cache or a package manager's store. Stay on actions/cache when you want entries keyed by a lockfile hash, or when the same workflow also runs on GitHub-hosted runners. The cache strategy guide goes deeper on the Actions cache.
The full reference, including path rules, publishing, concurrency and troubleshooting, is in the sticky disks docs.


