Sticky disks: native-speed GitHub Actions caches on EBS
RunsOn v3.2 adds persistent EBS-backed caches that stay in their native format, so CI no longer spends its time packing and unpacking archives.
GitHub Actions cache archives are a useful default. They are also a poor fit for the largest parts of a build: Docker layers, Git mirrors, compiled dependencies, and package-manager stores.
Those files already have a format. Compressing them, uploading them, downloading them, and extracting them again is work that does not build your software.
RunsOn v3.2 adds sticky disks: a dedicated EBS volume that follows a cache lineage instead of a runner instance. A job restores the latest clean snapshot, uses the files directly, then snapshots the disk for the next compatible job.
No tarball. No cache-server protocol. No extraction step.
runs-on/action@v2 provides the sticky_cache inputs used below.
A cache that behaves like a disk#
Add a sticky= label to a Flex job:
jobs: test: runs-on: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/sticky=build-cache:30gb steps: - uses: actions/checkout@v7 - id: cache uses: runs-on/action@v2 with: sticky_cache: | go pnpm custom,path=.turbo,path=vendor/cache - run: go test ./...runs-on/action@v2 mounts the requested cache directories on the sticky disk. The paths stay where the tools expect them. Go still finds its build cache; pnpm still finds its store; Turbo still finds .turbo.
The action reports whether every requested cache was restored. If a cache grows dangerously full, it leaves a job summary with the affected paths. If the volume becomes critically full, it clears the cache before the job runs rather than turning a cache miss into a no space left on device failure.
Use a named lineage when unrelated workloads should not share a disk:
runs-on: runs-on=${{ github.run_id }}/runner=8cpu-linux-x64/sticky=docker:60gb:gp3:750mbs:6000iopsThe name comes before the size. One job gets one disk, and each name creates an independent lineage.
Cache hits without crossing a boundary#
Sticky disks are not a shared mutable workspace. RunsOn scopes a snapshot by repository, lineage name, Git ref, operating system, and architecture.
A branch restores its own newest snapshot first. If it has none, it can start from the repository default branch. Pull requests can use that fallback but do not write back to the default-branch lineage. Linux and Windows, and x64 and arm64, never reuse each other’s filesystems.
Every job gets its own volume from that base snapshot. Concurrent jobs do not lock one another; the last clean completion becomes the next snapshot, just like normal GitHub Actions cache behavior.
Git and BuildKit are the two big wins#
Large checkouts and Docker builds usually spend more time moving cache data than compiling code.
For Git, run the action before actions/checkout. It keeps bare mirrors on the sticky disk and serves GitHub fetches through a local smart-HTTP proxy:
steps: - uses: runs-on/action@v2 with: sticky_cache: git - uses: actions/checkout@v7For BuildKit, the action creates the state volume used by Docker’s official Buildx action. Keep the setup order and disable Buildx cleanup so RunsOn can stop the builder before the disk is snapshotted:
steps: - uses: actions/checkout@v7 - id: runs-on uses: runs-on/action@v2 with: sticky_cache: buildkit - uses: docker/setup-buildx-action@v4 with: name: ${{ steps.runs-on.outputs.buildkit-builder }} driver: docker-container buildkitd-config-inline: ${{ steps.runs-on.outputs.buildkit-inline-config }} cleanup: false - uses: docker/build-push-action@v7 with: builder: ${{ steps.runs-on.outputs.buildkit-builder }} context: . load: trueThat is deliberately more prescriptive than a generic cache configuration. A persistent BuildKit state is only useful when the builder actually uses it.
Control snapshot initialization#
When restoring an existing snapshot, RunsOn requests a 200 MiB/s provisioned
initialization rate by default. That makes full-volume performance predictable.
Use lazy-init to leave initialization to AWS and avoid the
provisioned-initialization charge; first access to untouched blocks can then
wait for AWS to fetch them. To choose a rate between 100 and 300 MiB/s, append
an mibps-init suffix:
runs-on: runs-on=${{ github.run_id }}/runner=8cpu-linux-x64/sticky=60gb:200mibps-initRunsOn uses the requested rate only while restoring an existing snapshot. If regional quota or capacity is unavailable, it falls back to lazy initialization and warns in the job log instead of failing the job.
A safer successor to snapshot workflows#
Sticky disks work on Flex and Fleet, on Linux and Windows. They replace the older runs-on/snapshot@v1 workflow pattern, where a job step had to make EBS calls itself.
That replacement also makes a stronger isolation model possible. Once every legacy snapshot workflow has moved to sticky=, enable sticky-disk isolation to remove EBS volume and snapshot permissions from runner roles. The control plane keeps doing the EBS work; an untrusted job no longer can.
Read the sticky disks guide before migrating an existing cache. It covers supported cache modes, Windows behavior, retention, cleanup, and the rollout order with Magic Cache isolation.