Skip to main content
One line turns a runner into its own cache server:
actions/cache, actions/setup-* (cache: npm and friends), Swatinem/rust-cache, astral-sh/setup-uv and anything else built on @actions/cache then work unchanged, reading and writing an S3 bucket in your own account, in the runner’s own region. No size limit beyond your bucket, and no 10 GB per-repository ceiling.
airship.yaml
Your workflow does not change at all:
Available on Linux runners on AWS. On Google Cloud and Azure, cache: true is not an error — the runner launches without the proxy and Events records a cache.unavailable warning — so a shared definition stays portable.

Docker build cache

docker/build-push-action needs the url_v2 form, pointed at the local proxy:

How it works

1

The bucket is prepared, and proven to be yours

At launch, Airship makes sure airship-cache-<region>-<hash> exists in your account: private, public access blocked, entries expired after seven days, abandoned multipart uploads after one. Every call names the expected owner account, so a bucket of that name in another account is refused rather than used.
2

The machine gets a proxy and a one-off token

The boot script downloads the cache proxy from the control plane, starts it, and trusts its certificate authority on that machine only.
3

The proxy trades its token for narrow credentials

An STS federation token derived from your access key, allowed to read and write only the cache bucket, valid for an hour and refreshed before it expires. Your access key never reaches the machine.
4

Cache calls are answered locally

The runner’s https_proxy points at the proxy. It terminates TLS for exactly one host, answers the cache service’s calls itself — reserve, upload, finalize, lookup — and forwards everything else on that host to GitHub untouched. Every other host is tunnelled without inspection.
5

Objects move straight to and from S3

Uploads are staged on the root volume and written as a multipart upload with eight parallel 64 MiB parts, sent from disk. Downloads are served straight from S3: one GET for actions/cache, ranged GETs for clients like buildx.
Entries are stored at <version>/<key>, and restore-keys match by prefix within the same version, newest first — the same semantics GitHub’s own cache has, so a migrated workflow behaves identically. If anything in that chain fails, the job still runs, without acceleration, and Events records why.

The IAM permissions

In addition to EC2 access, the connected IAM user needs:
A federation token can never exceed the issuing user’s own permissions, and the proxy’s policy narrows it to one bucket — so the object statements above are the ceiling on what anything on a runner can do with the cache, whatever happens on the machine.

Getting the speed

Raise the disk first

gp3 defaults to 125 MiB/s whatever its size, which caps restores well below what the network does. Throughput: 1000 with Iops: 6000 is the starting point.

Add an S3 gateway endpoint

It keeps cache traffic off the internet and off your NAT bill, and is usually the best single network change.

Pick a network-heavy instance class

Once the disk is not the bottleneck, the instance’s network allocation is the next lever.

Keep cache steps on the host

Steps inside a container: job inherit https_proxy but not the host’s trust store, so run cache steps outside the container.

Scope, stated plainly

Every repository in the organization shares one bucket, and there is no per-branch isolation. A pull request’s job can write an entry a main-branch job later restores, and one repository can restore another’s keys.Keep public repositories with fork pull requests off cache runners, or make their keys unguessable. Airship enforces the bucket, not the key space.
The rest of the cache’s security properties — the local, one-day certificate authority, the narrow interception, the proven bucket ownership — are in Security.

When it does not work

On the machine itself, /var/log/airship-cacheproxy.log has the whole story.