self-host →

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=20gb creates the default lineage with default performance.
  • sticky=docker:30gb:gp3:750mbs:6000iops uses a separate docker lineage with faster GP3 settings.
  • sticky=go-cache:60gb:100mibps-init requests 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.

ModeCache 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 / buildxDocker BuildKit state (Linux only)
git / checkoutGitHub.com repository mirrors (Linux only)
customOne 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=.turbo

Relative 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: true

The 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:

VariablePurpose
RUNS_ON_STICKYDISK_DIRMounted disk directory.
RUNS_ON_STICKYDISK_READY_FILECreated once the disk is mounted.
RUNS_ON_STICKYDISK_UNAVAILABLE_FILECreated when the requested disk cannot be used.
RUNS_ON_STICKYDISK_NAMESelected 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.

Labelgp3 storage: 30 GiBProvisioned throughput: 400 MiB/sProvisioned volume initialization: 10 GiBTotal per restored disk
Official US East rate$0.08/GiB-month125 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=production

Upgrading 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.