A guide to GitHub Actions caching

There are multiple ways to cache in GitHub Actions: the actions/cache action, the caching built into setup actions, Docker layer cache exports, remote caches native to your build tools, persistent disks on self-hosted runners, and co-located cache backends from third-party runner providers. Which methods you need depends on what state your build recreates every run, and the biggest wins come from state the GitHub Actions cache alone doesn't cover.
For repeatable work, caching is often the highest-leverage speedup in GitHub Actions. In our published benchmarks, faster hardware alone is worth 2 to 3x on cold builds, while a Next.js monorepo with Turborepo runs up to 145x faster with cache hits, and the Linux kernel build drops from 27m 23s on a standard GitHub runner to 24.1 seconds warm with ccache on Avrea, a 68.2x difference. Most workflows collect a fraction of that, because they use the GitHub Actions cache for one dependency directory and nothing else.
On GitHub-hosted ephemeral runners, anything you don't restore, fetch from a mirror, or get from the runner image starts cold: Docker layers, compiler output, task results, package downloads, Git LFS objects.
The larger multipliers are reachable only when the workload has stable, content-addressable work to reuse, and each kind of work has its own method. For each method, this guide shows what it caches, the YAML to configure it, its limits, and how it combines with the others.
The actions/cache action
actions/cache gives you explicit control over the GitHub Actions cache: you name paths and a key, it tars the paths into a compressed archive, and ships the archive to a cache service: GitHub's own, backed by GitHub-owned cloud storage, or your runner provider's co-located one. Restores run through the cache service API, one call for a signed URL and a second for the download. In our benchmark, a 1 GB restore averaged 73 MB/s and 14.1 seconds end to end, with a 4 to 6x spread between the fastest and slowest run; on Avrea's co-located cache the same restore takes 2.89 seconds.
The key must match exactly for a hit. restore-keys are prefix fallbacks: on a miss, the newest cache starting with pip-Linux- restores instead, so after a lockfile change the install starts from the previous package store and downloads only what changed, rather than starting from nothing. When save and restore need to happen at different points in a job, actions/cache/restore and actions/cache/save split the two halves.
Hash the lockfile, not the source tree; a source-tree hash changes on nearly every commit, so nearly every run misses. Keep runner.os in the key, because an archive built on one OS restores broken paths and permissions on another, and give matrix jobs their matrix axis in the key for the same reason.
A run can restore caches from its own branch or the default branch, and a pull request can read its base branch; sibling branches never share.
On Avrea runners the same actions/cache steps (v4 and later) work unchanged; requests route to a cache proxy on the runner's local network instead, up to 6x faster across cache sizes with a much tighter spread between runs.
Caching built into the setup actions
Most language setup actions wrap actions/cache for dependency caching, usually off until you opt in. setup-node takes cache: npm, yarn, or pnpm, and setup-python takes pip, pipenv, or poetry. setup-java covers Maven, Gradle, and sbt, setup-dotnet caches NuGet against a lock file, ruby/setup-ruby has bundler-cache: true, and setup-go is the exception: caching on by default, modules and build outputs both.
The built-ins hash your dependency manifests or lockfiles and cache the package manager's global store, and the maintainers keep those paths current as runner images change. For standard dependency caching they're usually better than hand-rolled keys. Outside setup-go, they don't cache install output like node_modules or your build results; the install still runs, it just skips the network fetch.
The same options work as-is on Avrea, and misses cost less there too: when a lockfile change invalidates the whole bundled archive, the re-install pulls every package from a pull-through mirror pre-configured in the runner image instead of the public registry, so even a full rebuild of the cache runs at local-network speed.
Docker layer caching, four ways
BuildKit can export a build's layer cache and import it during a later build. The choice that matters is which cache backend stores those layers.
type=ghastores layers in the GitHub Actions cache service. Configuration is minimal, but it inherits the transfer speeds above, onemode=maxmulti-stage build can crowd everything else out of the repo's quota, and the cache API throttles busy builds; Docker's docs recommend passing a GitHub token (ghtoken) so lookups use the regular API instead.scopedefaults tobuildkit, so if you build several images, give each its own scope or they overwrite one another's cache.type=registrypushes the cache as a separate image to a container registry.mode=maxkeeps intermediate layers; you pay in registry storage and pull time.type=inlineembeds cache metadata in the image itself. Onlymode=min, so multi-stage builds mostly miss.type=localwrites to a directory, usually wrapped inactions/cache, which stacks both methods' costs.
RUN --mount=type=cache mounts (apt lists, cargo registries, Go module downloads) live on the builder's local disk and vanish with an ephemeral runner unless something persists them. Teams also try docker save into actions/cache; the image tarball usually gets big enough that restoring and loading it costs about what the pull did.
With Avrea the same flags point at the co-located service instead: type=gha,url_v2=https://cache.avrea.com/. Unchanged layers restore rather than rebuilding, and the layer cache no longer competes with your dependency archives for space. The exact configuration is in Avrea's Docker cache docs.
Remote caches your build tools bring
Compilers and task runners have had caching longer than CI has: ccache and sccache memoize compilations, Gradle, Turborepo, and Nx reuse task outputs, Bazel and Nix hash whole artifact graphs. Each supports a remote backend, --remote_cache for Bazel, a cache node for Gradle, SCCACHE_BUCKET for sccache, Vercel's API or a self-hosted one for Turborepo.
These caches want per-artifact lookups. Tar the whole directory through actions/cache and you restore gigabytes to check a few hashes, which is why that pairing disappoints on large projects. A native remote backend fixes that and adds operational work: it's another service, and its storage and eviction become someone's job.
Avrea hosts native endpoints for eleven of these tools (Bazel, ccache, Docker, Go, Gradle, Maven, Nix, Nx, sccache, Turborepo, Xcode), so there is no service of your own to deploy, and on the M5 Max macOS runners the Xcode layer carries derived data and compiled modules between runs. The kernel numbers at the top of this guide come from this layer: cold on the same Avrea hardware, that build still takes just over nine minutes.
Self-hosted runners: caching by owning the disk
Run your own runners and files on the local disk persist between jobs, which makes the disk itself a cache. Docker's layer store stays warm and RUNNER_TOOL_CACHE keeps toolchains around; package manager stores survive too. Some teams add their own registry mirror (Artifactory, Verdaccio) on the same network for the package half.
The tradeoff is the operating you take on: disk cleanup, jobs reading each other's leftovers, and a wider poisoning surface, because nothing wipes the machine between runs. Avrea keeps the caches persistent and co-located while every job runs in a fresh ephemeral VM, which is the same benefit without a machine to maintain.
Faster cache backends
Co-located caching means the runner reads cache storage from the same machines or the same local network, so restores never cross the public internet. Most third-party runner providers build some version of it, in different shapes: Depot around Docker layer storage, Blacksmith with snapshot-cloned sticky disks, Namespace with per-job cache volumes, RunsOn with an S3 bucket in your own AWS account.
Transfer speed is only part of the comparison. The other part is coverage: which kinds of build state get a cache at all. Avrea is our product; the numbers below are published and linked, so you can check them.
- The Actions cache, drop-in: identical YAML served from the local proxy at 354 MB/s, up to 6x faster than GitHub's restores with a much tighter spread.
- Git checkout acceleration, which preloads the repository from a datacenter-local mirror before
actions/checkoutruns. Enabled per repository, no workflow changes. - Build caches for the eleven tools above, a layer for compiler and task-runner output that
actions/cachehas no equivalent for. - A package mirror across nine ecosystems (JavaScript, Python, Go, Rust, JVM, .NET, Ruby, Swift, and Chocolatey on Windows), with npm entries verified against the hash npm itself publishes, and SwiftPM resolving checksummed archives instead of full git clones, 23 seconds down to 11.
- Git LFS, so large files stop being re-downloaded each run. GitHub still authorizes every transfer.
Cache storage is 25 GB per repository, included on every plan, and the quota can be raised when you need more, up to unlimited cache. Per-layer setup lives in the Avrea cache docs.
Vendor pages, ours included, call this intelligent caching or smart caching. In concrete terms it means each kind of build state gets its own cache layer, pre-configured in the runner, with storage next to the compute.
Managing GitHub Actions caches
The repository's Actions tab lists caches under Management, the REST API exposes the same list and delete operations, and the GitHub CLI has them built in:
Each cache step prints hit or miss in the job log, and gh cache list shows entry sizes, enough to spot a key that never matches or one entry crowding the pool. When a cache underperforms, the checks are quick: read the step's cache-hit output, compare restore time against the install or build time it replaces, list entries with gh cache list --sort last_accessed_at --order asc, and check that the key carries OS, architecture, toolchain version, and the lockfile hash.
Limits, all per repository: 10 GB of storage by default (admins can raise it, and anything above is billed), eviction of entries unaccessed for 7 days, oldest-accessed-first deletion when over quota, and API rate limits of 200 uploads and 1,500 downloads per minute.
Branch scoping is also a security boundary. GitHub's docs put the first rule plainly: "Don't store sensitive information in a cache," because anyone who can open a pull request can read the base branch's caches. The restriction on which refs may write and restore exists to block cache poisoning, where a low-privilege event plants a malicious cache that a later, more privileged workflow restores and trusts.
On Avrea, caches are isolated per repository, the Actions layer is scoped by Git ref on top, and everything is encrypted in transit and at rest. avr cache in the Avrea CLI does the same management jobs with JSON output; eviction follows the same shape, LRU over quota plus 7-day expiry.
What not to cache
Some paths make builds slower or leak data when cached:
- Secrets or credentials, anywhere; anyone who can open a pull request can read base-branch caches
- The
.gitdirectory: its timestamps churn every run, and in Docker builds they invalidate layers (add.gitto.dockerignore) - Huge high-churn directories where transferring the archive costs more than redoing the work
- OS- or toolchain-dependent output whose key doesn't carry OS, architecture, and toolchain version
node_modulesstraight across Node versions; cache the package manager's store instead
Which caching method should you use?
Start from where the minutes go:
- Package downloads slow: a setup action, or the package mirror when your runner has one
- Docker builds slow: BuildKit layer cache,
mode=max, ascopeper image - Compile or test tasks slow: the build tool's native remote cache
- Checkout or LFS slow: sparse checkout, a Git mirror, or an LFS cache
- A later job needs files from this run: artifacts, not caches
FAQ
Is the GitHub Actions cache free? Up to 10 GB per repository, yes. Organization or repository admins can raise the limit, and storage above 10 GB is billed. Entries unused for 7 days are removed regardless of quota.
How long do GitHub caches last? Seven days from last access. When a repository exceeds its storage quota, GitHub also deletes caches oldest-access-first. Avrea keeps the same 7-day expiry with a 25 GB per-repository quota and LRU eviction above it.
Can two branches share a cache in GitHub Actions? Only downward: a run reads caches from its own branch and the default branch, and pull requests read their base branch. Sibling feature branches never share, so new branches warm up from the default branch's saves.
How do I cache Docker builds in GitHub Actions? Use buildx with cache-from and cache-to set to the type=gha backend, with mode=max if you need intermediate layers from multi-stage builds. On GitHub-hosted runners, watch the 10 GB quota and the cache API's rate limits. Co-located backends take the same configuration pointed at a different URL.
Run it twice
Duplicate your slowest workflow, change runs-on: ubuntu-latest to runs-on: avrea-ubuntu-latest, and run it twice. The first run shows the cold speed of the hardware, 2 to 3x across our published benchmarks. The second run shows the restored caches at work, and in those same benchmarks everything above 3x came from cache layers beyond the standard one.
For most workflows the test is one runner-label change; Docker layer caching adds the url_v2 flag from the Docker section, and some build caches take a few lines of tool config. Every plan includes 3,000 free minutes a month, and setup steps are on the Avrea speed page. Workflow-level tactics beyond caching are in how to speed up GitHub Actions.




