How to Speed Up GitHub Actions CI: Caching, Sharding, Docker (2026)
This guide is for anyone whose GitHub Actions pipeline has crept from three minutes to fifteen and who wants it back without buying a bigger runner first. By the end you will have a workflow that cancels superseded runs, skips work that does not apply, restores dependencies and Docker layers from cache, splits the test suite across parallel jobs, and a checklist for when the cache stubbornly misses. Everything is checked against the current releases as of 20 October 2026: actions/cache v6.1.0, actions/checkout v7.0.1, actions/setup-node v7.1.0, docker/build-push-action v7.4.0 and docker/setup-buildx-action v4.4.1. You need a repository with an existing workflow and the GitHub CLI (gh) for the measurement step.
A note on pins: every uses: line below is pinned to a full commit SHA with the version in a comment, because tags can be moved by whoever controls the action. The reasons are in the companion post on GitHub Actions security. Resolve SHAs yourself with git ls-remote --tags https://github.com/OWNER/REPO vX.Y.Z rather than trusting a blog post, mine included.
Step 1: Measure where the time goes
Optimising a pipeline you have not measured is how teams end up caching a 4-second npm ci while a 9-minute integration test runs serially. Start with the last twenty runs of the slow workflow and the per-job timings of a typical one.
# Last 20 runs of one workflow: id, result, start and end
gh run list --workflow ci.yml --limit 20 \
--json databaseId,conclusion,startedAt,updatedAt \
--jq '.[] | [.databaseId, .conclusion, .startedAt, .updatedAt] | @tsv'
# Per-job timings for one run (replace the id)
gh run view 1234567890 --json jobs \
--jq '.jobs[] | [.name, .startedAt, .completedAt] | @tsv'
Then open the run in the browser and expand the slowest job: each step shows its duration. You are looking for three things: setup steps (installing dependencies, pulling images) that repeat identical work every run, a long serial test step, and queue time before the job even starts. For trends, the repository's Insights tab has Actions performance metrics, generally available since March 2025, with average run time, queue time and failure rate per job for up to a year back. They show averages, not percentiles, so a job that is usually fast and occasionally awful will look fine there.
Write down the baseline wall-clock time. Every later step should move that number, or be reverted.
Step 2: Cancel superseded runs and skip irrelevant work
The fastest job is the one that does not run. Two settings do most of this.
# .github/workflows/ci.yml (top level)
name: ci
on:
push:
branches: [main]
pull_request:
concurrency:
# one active run per workflow per branch or PR
group: ${{ github.workflow }}-${{ github.ref }}
# cancel older runs on PRs, but let every main build finish
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
The concurrency block is documented in the workflow syntax reference. Making cancel-in-progress conditional matters: on main you usually want every commit's build to complete, because deploys and caches hang off it.
For skipping, the obvious tool is paths or paths-ignore on the trigger. The catch, stated in the same reference, is that a workflow skipped by a path filter leaves its checks in "Pending", and a pull request that requires those checks cannot merge. If the workflow is a required check, filter inside the workflow instead with a job that decides what changed:
jobs:
changes:
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: read # lets the filter list PR files via the API
outputs:
app: ${{ steps.filter.outputs.app }}
steps:
# checkout is needed on push events, where the filter diffs with git
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: filter
with:
filters: |
app:
- 'src/**'
- 'package-lock.json'
test:
needs: changes
if: needs.changes.outputs.app == 'true'
runs-on: ubuntu-24.04
steps:
- run: echo "tests go here"
A job skipped by if: reports as skipped, which satisfies a required check, unlike a workflow that never started.
Step 3: Turn on dependency caching in the setup actions
Most language setup actions already wrap actions/cache and will build the key from your lockfile. You only need to opt in correctly.
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Node: caches the global package store (~/.npm, pnpm store, yarn cache), not node_modules
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
cache: npm # or pnpm / yarn; pnpm must be installed first
cache-dependency-path: package-lock.json
- run: npm ci
Details that trip people up, from the setup-node README and its release notes. Since v6, automatic caching only happens for npm, and only when package.json declares npm in packageManager or devEngines.packageManager; Yarn and pnpm need cache: set explicitly. The action never caches node_modules, so npm ci still runs, it just downloads nothing. For workflows with elevated privileges, such as release jobs, the README recommends turning automatic caching off with package-manager-cache: false.
Python and Go follow the same pattern:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.13'
cache: pip # also pipenv or poetry
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache-dependency-path: go.sum # default key is go.mod; go.sum tracks real changes
setup-go caches by default, and its README now keys on go.mod unless you point cache-dependency-path at go.sum. go.mod lists direct requirements, while go.sum records a checksum for every module the build uses, so I key on go.sum.
Step 4: Cache anything else with actions/cache v6
For build outputs, tool caches and compilers (Gradle, Cargo target/, ccache, Next.js .next/cache), use actions/cache directly. The current major is v6, an ESM rebuild of v5; v5 moved to Node.js 24 and needs runner 2.327.1 or newer, which matters on self-hosted runners (release notes). If you still pin a v4 SHA older than v4.2.0, it stopped working when the legacy cache service was retired in February 2025.
- name: Restore Cargo cache
id: cargo-cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
cargo-${{ runner.os }}-
- run: cargo build --locked --release
- name: Save Cargo cache
# save only on main and only when the key was new
if: github.ref == 'refs/heads/main' && steps.cargo-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ steps.cargo-cache.outputs.cache-primary-key }}
Splitting restore and save buys two things. You save once, from main, so PR branches stop filling the cache with near-duplicates; and you can save even when a later step fails. The rules behind this, from GitHub's dependency caching reference:
- Lookup order is exact
key, then prefix match onkey, then eachrestore-keysentry.cache-hitistrueonly on an exact match. - A run can restore caches from its own branch, the default branch, and for PRs the base branch. It cannot see sibling or child branches. That is why seeding the cache from
mainworks and why a cache saved onfeature-ais invisible tofeature-b. - Entries not accessed for seven days are evicted, and once the repository passes its size limit the least recently used go first. The default limit is 10 GB; since November 2025 admins on paid plans can raise it, with usage above 10 GB billed.
- There are rate limits of 200 uploads and 1,500 downloads per minute per repository, which a wide matrix that saves from every leg can hit.
- Since 26 June 2026, untrusted triggers such as
pull_request_target,issue_commentand fork-drivenworkflow_runget read-only access to default-branch caches, and saves log a warning instead of writing. You can state intent with thecache-modekey (read,write,write-only,none), added in September.
# top of a PR-only workflow: restore, never write
cache-mode: read
Step 5: Cache Docker layers with buildx and type=gha
Docker builds are where caching pays most, because a cold build reinstalls the OS packages and dependencies every time. BuildKit can export its layer cache straight into the Actions cache.
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
push: false
load: true
tags: myapp:ci
# one scope per image, or images overwrite each other's cache
cache-from: type=gha,scope=myapp
# mode=max exports intermediate stages too, not just the final one
cache-to: type=gha,scope=myapp,mode=max
Per Docker's gha backend docs, the default scope is buildkit, so two images built in the same repository without a scope keep evicting each other. mode=min, the default, only caches layers of the final stage, which is nearly useless for multi-stage builds. The backend now uses GitHub's v2 cache API exclusively; the v1 API was shut down on 15 April 2025, and Docker's cache guide lists Buildx 0.21.0 and BuildKit 0.20.0 as minimums. Hosted runners already have them; old self-hosted runners may not.
Caching only helps if the Dockerfile lets it. Copy the lockfile and install dependencies before copying the source, so a code change does not invalidate the dependency layer. The multi-stage image guide covers that ordering in detail.
Step 6: Shard the test suite with a matrix
Once setup is cached, the long pole is usually the tests. Most modern runners can split a suite deterministically: Jest and Vitest take --shard=N/M, as does Playwright.
test:
runs-on: ubuntu-24.04
strategy:
fail-fast: false # see every failing shard, not just the first
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
cache: npm
- run: npm ci
- run: npx playwright test --shard=${{ matrix.shard }}/4
Four shards cost roughly four times the setup minutes, so sharding only wins when setup is short, which is why it comes after caching. Watch the slowest shard in Step 1's timings: if one shard consistently takes twice as long, a single slow spec file is dominating and splitting further will not help.
Step 7: Move to a larger runner only if you are CPU-bound
If a compile or test step pegs the CPU after all of the above, a larger runner is the remaining lever. From GitHub's pricing reference, a standard Linux 2-core x64 runner is $0.006 a minute, 4-core $0.012, 8-core $0.022 and 16-core $0.042; arm64 2-core is $0.005. Included minutes do not apply to larger runners, and jobs round up to the minute. The arithmetic is simple: moving from 2 to 4 cores doubles the per-minute price, so it saves money only if the job finishes in under half the time. Builds that parallelise well (Rust, C++, Gradle) often do; a test suite waiting on a database usually does not. Hosted prices fell by up to 39 percent on 1 January 2026, per GitHub's announcement, so it is worth rerunning this sum if you ruled bigger runners out last year.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Log says "Cache restored from key" but cache-hit is false |
Restored via restore-keys, a partial match |
Expected. cache-hit is only true on an exact key; gate saves on it |
"Cache not found" on a PR although main was cached |
main never saved, or the cache is on a sibling branch |
Run the workflow on push to main so it seeds the default-branch cache |
| Save warns about read-only access | Untrusted trigger (pull_request_target, issue_comment) since June 2026 |
Save from a push workflow; keep untrusted workflows restore-only |
| Cache from Linux never restores on Windows | Cache version includes the path and compression tool | Use separate keys per OS, or set enableCrossOsArchive: true |
| Old caches vanish within hours | Repository over its size limit, LRU eviction | Delete stale keys (gh cache list, gh cache delete), save only from main, or raise the limit |
| Saves fail with HTTP 429 | Over 200 uploads per minute from a wide matrix | Save from one matrix leg only, or from a single follow-up job |
| Docker build ignores the gha cache | Shared default scope, or mode=min on a multi-stage build |
Set scope=<image> on both sides and mode=max on cache-to |
| buildx gha cache errors on self-hosted runners | Buildx or BuildKit too old for the v2 cache API | Upgrade to Buildx 0.21.0+ and BuildKit 0.20.0+ |
| Required check stuck in "Pending" | Workflow skipped by paths filter |
Filter inside the workflow with a changes job and job-level if: |
actions/cache v5 or v6 fails to start on self-hosted |
Runner older than 2.327.1, no Node.js 24 | Update the runner before bumping the action |
To see what is actually in the cache, gh cache list --sort size_in_bytes --limit 20 shows keys, sizes and refs; gh cache list --key cargo- filters by prefix.
FAQ
Why is my GitHub Actions cache not being used?
Check three things in order: whether the key matched exactly (restore-key matches do not set cache-hit), whether the cache was saved on your branch or the default branch (sibling branches cannot share), and whether the trigger is allowed to write (untrusted triggers have been read-only since June 2026). The cache step's log tells you which key it tried and which, if any, it restored.
Should I cache node_modules?
Usually not. setup-node caches the package manager's global store, and npm ci then installs from it without network downloads. Caching node_modules directly ties the cache to the exact Node version and OS, and breaks in confusing ways when native modules are involved.
How big can the GitHub Actions cache be?
Ten gigabytes per repository by default, with entries evicted after seven days without access. Since November 2025, admins on paid plans can raise the limit and pay for storage above 10 GB.
Are larger runners worth it?
Only for CPU-bound jobs that roughly halve in duration when cores double. A 4-core Linux runner costs twice the 2-core rate and gets no included minutes, so measure the job on both before switching the whole workflow.
When a pipeline at PandaStack (my company, a Firecracker microVM cloud) gets slow, the first thing I open is the step durations of one run, not the workflow file, and the answer is often a cache that silently stopped matching. I keep caches saved only from main, Docker scoped per image with mode=max, and I treat the cache as a performance hint that a malicious PR must never be able to write, which is the same discipline the CI/CD and agents post argues for now that bots open so many of the PRs. The official references worth bookmarking are GitHub's dependency caching reference, the workflow syntax reference and Docker's GitHub Actions cache guide.
Related: GitHub Actions Security in 2026: 76 Hijacked Tags and the Fixes That Work, DevOps Still Matters in 2026: AI Cut Delivery Stability 7.2%, Then Doubled Merged PRs and Added 91% to Review Time and CI/CD in 2026: Agents Opened 1M PRs in 5 Months, Bots Write 1 in 5 Reviews, and Most Agent PRs Get No Human Look.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Set Up Argo CD for GitOps: Step-by-Step Guide (2026)
Install Argo CD 3.5.5 with Helm chart 10.10.5, structure a GitOps repo, bootstrap with app-of-apps and ApplicationSet, configure sync policies, waves and hooks, add SSO and RBAC, deploy Helm and Kustomize apps, and fix OutOfSync, Degraded and ComparisonError.
14 minOct 20, 2026How to Set Up Backstage as an Internal Developer Portal (2026)
Step-by-step Backstage 1.55 setup for 2026: create-app on Node 22 or 24, PostgreSQL, GitHub sign-in, catalog-info.yaml, software templates, TechDocs, a Kubernetes deploy with the official Helm chart, alternatives, and fixes for common errors.
14 minOct 20, 2026How to Do Canary Deployments with Argo Rollouts 1.10 (2026)
Step-by-step Argo Rollouts canary guide for 2026: install v1.10.1 with Helm and the Gateway API plugin v0.17.0, write a Rollout with weighted steps, add a Prometheus AnalysisTemplate, watch automated rollback, and fix the common errors.
14 min