Caching
CI cache speeds up pipelines by re-using artifacts (deps, build output, Docker layers) between runs. The hard part is keying: same key = cache hit; key changes too often = always miss.
Per-platform caches + Docker layer cache
EXAMPLE
# 1) The core idea
# Pipelines repeat the same work (npm install, gradle build, pip install).
# Cache them — keyed by file hashes (package-lock.json, pom.xml, etc.).
# Cache miss → install fresh + save. Cache hit → restore + skip install.
# === GitHub Actions ===
# 2) Built-in setup-* caches
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm' # auto-caches ~/.npm keyed by package-lock.json
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip' # caches ~/.cache/pip
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: gradle # caches ~/.gradle
- uses: actions/setup-go@v5
with:
go-version: '1.22'
cache: true # caches ~/go/pkg/mod
# 3) Manual cache — actions/cache@v4
- uses: actions/cache@v4
with:
path: |
~/.cache/pip
${{ github.workspace }}/.venv
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
# 4) Multi-path cache — Cypress / Playwright binaries + node_modules
- uses: actions/cache@v4
with:
path: |
~/.cache/Cypress
node_modules
key: ${{ runner.os }}-cypress-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-cypress-
# === GitLab CI ===
# 5) GitLab cache — global + per-job
cache:
key: { files: [package-lock.json] }
paths: [.npm/, node_modules/]
build:
script:
- npm ci --prefer-offline --cache .npm
- npm run build
# Per-environment cache
build-prod:
cache:
key: 'prod-${CI_COMMIT_REF_SLUG}'
paths: [dist/, .npm/]
policy: pull-push # pull-only on read-only jobs
# === Docker layer cache ===
# 6) BuildKit + GitHub Actions cache (GHA backend)
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/me/myapp:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
# 7) BuildKit + Registry-based cache (works on any CI)
docker buildx build \
--push \
-t ghcr.io/me/myapp:${TAG} \
--cache-from type=registry,ref=ghcr.io/me/myapp:buildcache \
--cache-to type=registry,ref=ghcr.io/me/myapp:buildcache,mode=max \
.
# 8) Inline cache (simpler, baked into the image)
docker buildx build \
--push \
-t ghcr.io/me/myapp:${TAG} \
--cache-to type=inline \
--cache-from ghcr.io/me/myapp:latest \
.
# === Cache key strategy ===
# 9) Good keys
# - hashFiles('package-lock.json') → invalidates on lockfile change
# - hashFiles('Cargo.lock')
# - hashFiles('go.sum')
# - hashFiles('poetry.lock', 'pyproject.toml')
# 10) Avoid these as keys
# • Source file hashes — invalidate every commit
# • Date — invalidates every day, even when nothing changed
# • Branch name only — caches won't share between feature branches
# 11) Restore-keys — fall back to a slightly older cache
key: os-pip-${{ hashFiles('requirements.txt') }}
restore-keys: |
os-pip-
# If exact match miss, restore newest cache with prefix 'os-pip-' and run a partial install.
# === Cache hierarchy: per-language patterns ===
# Node.js
paths: ['~/.npm', 'node_modules']
key: '${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}'
# Python (pip)
paths: ['~/.cache/pip']
key: '${{ runner.os }}-pip-${{ hashFiles('requirements*.txt') }}'
# Python (poetry)
paths: ['~/.cache/pypoetry/virtualenvs']
key: '${{ runner.os }}-poetry-${{ hashFiles('poetry.lock') }}'
# Rust
paths: ['~/.cargo/registry', '~/.cargo/git', 'target']
key: '${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock') }}'
# Use Swatinem/rust-cache@v2 for a smart default
# Go
paths: ['~/go/pkg/mod', '~/.cache/go-build']
key: '${{ runner.os }}-go-${{ hashFiles('go.sum') }}'
# Gradle / Maven
paths: ['~/.gradle/caches', '~/.gradle/wrapper']
key: '${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle', '**/gradle-wrapper.properties') }}'
# === Cleanup + cost ===
# 12) GitHub Actions cache has limits:
# - 10 GB per repo, evicted by LRU
# - Keys not used in 7 days are deleted
# Don't bloat: don't cache things faster to rebuild than to restore.
# 13) Don't cache secrets! Caches are scoped per repo, but a public fork can run an action
# and potentially read the cache. Never put env vars / .env files in cached paths.
# === Anti-patterns ===
# 14) Useless caches
# • Caching dist/ across PRs (source changes invalidate it; rebuild is the same speed)
# • Caching test results (too small to bother)
# • Caching with a date-based key (always misses)
# 15) When to invalidate intentionally
# - After a CVE in a dep — bump lockfile, cache invalidates automatically
# - Quarterly cleanup — delete the cache UI in CI to start fresh
# - When debugging suspect 'works on stale cache, fails on fresh' — clear + rebuild
# 16) Measure the win
# • CI minutes BEFORE: npm install = 90s, build = 30s
# • CI minutes AFTER: cache hit on lockfile → npm install = 8s
# • Save ~80s per pipeline → 1000 runs/month = 22 hours of CI saved
# 17) Layered caching strategy for a typical Node app
# 1. setup-node with cache: 'npm'
# 2. actions/cache for node_modules keyed on package-lock.json
# 3. actions/cache for build output keyed on package-lock + git ref (with restore-keys fallback)
# 4. docker/build-push-action with cache-from/to: type=gha
# Combined: 5-min pipelines become 30 seconds on incremental changes.
Why it matters
Key the cache on lockfile hashes; never on source file or date. Pair with restore-keys for partial hits. Cache dependencies, not source builds — the latter are usually faster to recompute than restore.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ~/.pnpm-store
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-
Try it Yourself »
Exercise
Cache action repository name.
uses: actions/
@v4
Five letters.
Discussion
Loading…