Sticky disks (EBS)
Keep build caches on a persistent EBS disk and restore them at native filesystem speed on Flex and Fleet.
Sticky disks keep a cache on a dedicated EBS volume instead of packing it into an archive after every job. RunsOn restores the latest compatible snapshot before a job, mounts the volume on the runner, and snapshots it again after a clean unmount. Your cache stays in its native format, so there is no tar upload, download, or extraction step.
They are a good fit for large dependency stores, BuildKit layers, Git mirrors, and other caches that are expensive to serialize.
Availability#
Sticky disks work on Linux and Windows runners, with both Flex and Fleet.
Linux mounts the ext4 volume at /mnt/runs-on/stickydisk; Windows mounts the
NTFS volume at C:\runs-on\stickydisk.
Quick start#
Request a sticky disk on the runner, then let runs-on/action@v2 map cache
directories onto it:
jobs: build: runs-on: runs-on=${{ github.run_id }}/runner=2cpu-linux-x64/sticky=build-cache:30gb steps: - uses: actions/checkout@v7 - id: sticky uses: runs-on/action@v2 with: sticky_cache: | go pnpm custom,path=.turbo,path=vendor/cache - if: steps.sticky.outputs.cache-hit != 'true' run: echo "At least one cache starts cold"Every non-empty sticky_cache line is one cache record. The action waits
for the disk, binds the requested directories to it, and reports whether every
requested path was restored. It makes no AWS calls and needs no credentials.
Choose a disk#
The label syntax is:
sticky=[<name>:]<size>[:<type>][:<throughput>][:<iops>][:<initialization-rate>]Examples:
sticky=20gbcreates the default lineage with default performance.sticky=docker:30gb:gp3:750mbs:6000iopsuses a separatedockerlineage with faster GP3 settings.sticky=go-cache:60gb:100mibps-initrequests a 100 MiB/s snapshot initialization rate when restoring an existing snapshot.
The optional name must come first. Use it to keep unrelated caches in the same
repository apart, for example go-cache and docker. Sticky disks
default to GP3, 3,000 IOPS, and 400 MiB/s throughput.
RunsOn requests a 200 MiB/s provisioned initialization rate by default when it
restores an existing snapshot. Use 100mibps-init through 300mibps-init to
choose another rate, or lazy-init to avoid the provisioned-initialization
charge. A new, blank disk needs no initialization. If regional quota or
capacity is unavailable, RunsOn retries with lazy initialization and writes a
warning. The job still runs, but first access to restored blocks can be slower.
Sticky storage is separate from the runner’s root volume= setting. A job
gets one sticky disk, mounted at:
- Linux:
/mnt/runs-on/stickydisk - Windows:
C:\runs-on\stickydisk
Cache modes#
runs-on/action@v2 knows the common cache locations below. You can also
use custom,path=... for any directory.
| Mode | Cache location |
|---|---|
go | ~/.cache/go-build and ~/go/pkg/mod |
node / npm | ~/.npm |
yarn | ~/.cache/yarn |
pnpm | ~/.pnpm-store |
ruby / bundler | ~/.bundle and vendor/bundle |
rust / cargo | ~/.cargo/registry and ~/.cargo/git |
python / pip | ~/.cache/pip |
uv | ~/.cache/uv |
poetry | ~/.cache/pypoetry |
gradle | ~/.gradle/caches and ~/.gradle/wrapper |
maven | ~/.m2/repository |
playwright | ~/.cache/ms-playwright |
apt | /var/cache/apt/archives (Linux only) |
buildkit / buildx | Docker BuildKit state (Linux only) |
git / checkout | GitHub.com repository mirrors (Linux only) |
custom | One or more directories supplied through path= |
For custom paths, repeat path= on the same line or add another
custom line:
- uses: runs-on/action@v2 with: sticky_cache: | custom,path=vendor/cache,path=~/.cache/my-tool custom,path=.turboRelative paths resolve from GITHUB_WORKSPACE, so configure
workspace-relative custom paths after checkout. Home-relative and absolute
paths work on both platforms. On Windows, the action uses NTFS junctions;
on Linux, it uses bind mounts.
Below 20% free space or 10% free inodes, the action warns and reports the
affected cache modes. If either falls below 5% at the next job start, it resets
all caches on the disk and lets the job run cold rather than fail with
no space left on device. BuildKit uses its own garbage collection.
Faster Git checkouts#
The git mode must start before actions/checkout. It keeps bare
mirrors on the sticky disk and serves GitHub fetches through a local smart-HTTP
proxy. Later jobs fetch only new Git objects while checkout reads the pack from
the runner’s disk.
steps: - uses: runs-on/action@v2 with: sticky_cache: git token: ${{ secrets.CROSS_REPO_READ_TOKEN }} - uses: actions/checkout@v7 with: repository: acme/private-monorepo token: ${{ secrets.CROSS_REPO_READ_TOKEN }}The default github.token is enough for the workflow repository. Pass a
token with access when fetching another private repository. Pushes, Git LFS,
unsupported requests, and mirror failures continue to use GitHub normally.
The Git cache is Linux-only. It does not accelerate SSH remotes, container
jobs, GitHub Enterprise Server, or any checkout configured before the action.
If the same job also caches a workspace path, call the action once for
git before checkout and once for the workspace cache afterward.
Persistent Docker and BuildKit layers#
For a docker-container Buildx builder, run the action after checkout and
before docker/setup-buildx-action. Use the emitted builder name and
BuildKit configuration, and set cleanup: false so the RunsOn post step can
validate and stop the builder before snapshotting the disk:
jobs: image: runs-on: runs-on=${{ github.run_id }}/runner=2cpu-linux-x64/sticky=docker:30gb steps: - uses: actions/checkout@v7 - id: runs-on uses: runs-on/action@v2 with: sticky_cache: buildkit - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 with: name: ${{ steps.runs-on.outputs.buildkit-builder }} version: v0.34.1 driver: docker-container driver-opts: | image=moby/buildkit:v0.31.1 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: trueThe same buildkit-inline-config output configures Docker Hub’s
pull-through mirror when the runner has ecr-pull-through enabled. See
Docker Hub pull-through
for that setup.
Use the mounted disk directly#
The companion action is the safest way to map cache paths and wait for the disk. For a tool with its own storage layout, the runner also exports:
| Variable | Purpose |
|---|---|
RUNS_ON_STICKYDISK_DIR | Mounted disk directory. |
RUNS_ON_STICKYDISK_READY_FILE | Created once the disk is mounted. |
RUNS_ON_STICKYDISK_UNAVAILABLE_FILE | Created when the requested disk cannot be used. |
RUNS_ON_STICKYDISK_NAME | Selected lineage name. |
Use the ready and unavailable signals in a custom setup step. Fleet disks are
attached after GitHub assigns a job, so they can become ready after the job
starts; runs-on/action@v2 handles that wait for sticky_cache
automatically.
Scope, lifecycle, and cost#
Snapshots are isolated by repository, lineage name, Git ref, operating system, and architecture. A branch restores its own latest clean snapshot first, then falls back to the repository default branch. Pull requests can use that fallback without writing back to the default branch.
RunsOn snapshots a disk only after a clean unmount. A successful, failed, or cancelled job can advance the cache when its post step records that marker. A Spot interruption, crash, or failed unmount leaves the last clean generation in place. Concurrent jobs follow normal cache semantics: each starts from its selected base snapshot, and the last clean completion becomes the newest one.
Housekeeping keeps the newest snapshot per lineage and removes lineages that have been idle for more than 10 days.
You pay for the live EBS volume while it exists and for retained snapshot data. Snapshot billing is based on stored changed blocks rather than the full provisioned size, but provisioned IOPS and throughput also affect EBS cost.
Pricing example#
The following uses US East (N. Virginia) EBS prices ↗
and excludes EC2, the root volume, taxes, and free-tier credits. It assumes a
30 GiB gp3 sticky disk, alive for 10 minutes, restoring a snapshot with 10
GiB of full snapshot data. RunsOn provisions its default 3,000 IOPS and 400
MiB/s of throughput. It uses AWS’s 30-day-month proration.
| Label | gp3 storage: 30 GiB | Provisioned throughput: 400 MiB/s | Provisioned volume initialization: 10 GiB | Total per restored disk |
|---|---|---|---|---|
| Official US East rate | $0.08/GiB-month | 125 MiB/s included, then $0.04/MiB/s-month | $0.0024/GiB at 200 MiB/s | — |
sticky=build-cache:30gb:400mibps:lazy-init | $0.0006 | $0.0025 | $0 | $0.0031 |
sticky=build-cache:30gb:400mibps:200mibps-init (explicit defaults) | $0.0006 | $0.0025 | $0.0240 | $0.0271 |
At these official rates, 30 GiB of gp3 storage costs $2.40 per month.
Provisioning 400 MiB/s adds $11 per month: gp3 includes 125 MiB/s, then
charges for the remaining 275 MiB/s at $0.04/MiB/s-month. The 10-minute shares
are $0.0006 for storage and $0.0025 for provisioned throughput. The default
3,000 IOPS are included, so they add no charge.
Provisioned volume initialization costs 10 GiB × $0.0024/GiB = $0.0240 and
is billed once per restored volume. AWS bases it on snapshot data, not the
disk’s provisioned size.
lazy-init leaves initialization to AWS. It only avoids the provisioned
initialization charge and can make first access to restored blocks slower. A
fresh disk has no snapshot to restore, so it pays no initialization fee either
way. If retained snapshot data totals 10 GiB, it adds about $0.50 per month.
EBS stores snapshots incrementally, so actual retention cost depends on changed
blocks and Region.
Fleet configuration#
Fleet workflows still select only a named fleet. Put the sticky disk specification on the Terraform-owned runner definition:
runners = { linux-cache = { cpu = 4 ram = 8 family = ["c8i"] image = "ubuntu26-full-x64" sticky = "build-cache:30gb" }}Fleet validates the sticky value during planning. The workflow then targets
the fleet as usual:
runs-on: runs-on/fleet=linux-cache/env=productionUpgrading safely#
The prerelease snap= label became sticky=. Replace it before
upgrading, and express sticky_cache as one record per line instead of a
comma-separated list.
The new sticky-disk control plane works with the default isolation setting.
Later, after every legacy
runs-on/snapshot@v1 ↗ workflow has moved
to sticky=, you can enable sticky-disk isolation:
- CloudFormation:
EnableStickyDiskIsolation: "true" - Terraform:
enable_stickydisk_isolation = true
Isolation removes legacy EBS volume and snapshot permissions from runner roles. New sticky disks keep working because the control plane performs those EBS operations. The legacy snapshot action stops working once isolation is enabled.
For older snapshot-action workflows, migrate to the examples on this page before turning the flag on.