Skip to content
Français
CI/CD & Automatisation medium

GitHub Actions cache: speeding up your workflows

40 min de lecture

Read this page in French

You have just fixed a typo in your README. You push the commit. GitHub Actions starts, and you wait 4 minutes while npm downloads 847 packages for the 50th time this week.

Frustrating, isn't it?

This guide explains how to divide that time by 10 thanks to the GitHub Actions cache. No need to be an expert: if you can write a basic workflow, you will be using the cache within 10 minutes.

What you will learn

  • Enable the dependency cache in one line with the setup-* actions
  • Understand the cache keys and the role of the lockfile hash
  • Configure actions/cache to cache a build (Next.js, Webpack and so on)
  • Choose what to cache, and what you must never cache
  • Avoid cache poisoning and diagnose a cache that is never reused

The problem: why your workflows are slow

Let us look at what happens when you run a workflow with no cache:

Commit #1 (Monday 9:00)
├── Checkout of the code .......... 2s
├── npm ci (download) ............. 47s <- Internet
├── npm run build ................. 35s
└── npm test ...................... 12s
Total: 96 seconds
Commit #2 (Monday 9:15)
├── Checkout of the code .......... 2s
├── npm ci (download) ............. 47s <- Internet again, the same packages!
├── npm run build ................. 35s
└── npm test ...................... 12s
Total: 96 seconds

Between those two commits, nothing changed in the dependencies. Yet GitHub downloads the same 847 packages, from the same npm servers, for the second time in 15 minutes.

Over a week with 50 commits, that is 39 minutes lost downloading the same files.

The solution: keeping the files around

What it gives you in practice

Commit #1 (first run, empty cache)
├── Checkout of the code .......... 2s
├── Cache restore ................. 0s <- No cache yet
├── npm ci (download) ............. 47s <- Internet
├── Cache save .................... 5s <- We fill the fridge
├── npm run build ................. 35s
└── npm test ...................... 12s
Total: 101 seconds
Commit #2 (cache available)
├── Checkout of the code .......... 2s
├── Cache restore ................. 3s <- The fridge is full!
├── npm ci ........................ 2s <- Nothing to download
├── npm run build ................. 35s
└── npm test ...................... 12s
Total: 54 seconds

Result: 47 seconds saved on every commit. Over 50 commits a week, that is 39 minutes recovered.

How the GitHub Actions cache works: the first run saves, the second one
restores

Enabling the cache: the simplest method

Good news: enabling the cache takes a single line.

Before (no cache)

Here is the starting workflow, with no caching at all. Spot the npm ci step: that is the one downloading everything again on every run. The permissions: block and persist-credentials: false are already there; they change nothing for the speed, but they remain the security baseline this guide never questions.

name: CI
on: [push]
permissions: {}
jobs:
build:
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20'
- run: npm ci # <- 47 seconds, every single time
- run: npm run build
- run: npm test

After (with cache)

name: CI
on: [push]
permissions: {}
jobs:
build:
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20'
cache: 'npm' # <- That is all! One line.
- run: npm ci # <- 2 seconds if the cache exists
- run: npm run build
- run: npm test

The cache: 'npm' line does all the work:

  1. It looks for an existing cache
  2. If it finds one, it restores the files
  3. At the end of the job, it saves the cache for next time

Checking that it works

Once you have added cache: 'npm', run two consecutive workflows. In the logs of the second one, you should see:

Run actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
Cache hit occurred on the primary key: node-cache-Linux-npm-d5ea0750...

The Cache hit message confirms the cache was used. If you see Cache miss, that is normal on the first run: the cache does not exist yet.

How does the cache know what to keep?

You may be wondering: "how does GitHub know that my dependencies have not changed?"

The answer holds in one word: the hash.

What is a hash?

A hash is the fingerprint of a file. If the file changes, even by a comma, the hash changes completely.

package-lock.json (version 1) -> Hash: abc123
package-lock.json (version 2) -> Hash: xyz789 (completely different!)

When you use cache: 'npm', GitHub computes the hash of your package-lock.json file. That hash becomes the label of the cache.

Why it is clever

  • Monday: you push a commit, hash = abc123, cache created
  • Tuesday: a new commit, hash = abc123 (identical!)
  • GitHub says: "I already have a cache for abc123, I reuse it"
  • Result: cache hit, restored in 3 seconds

The system is automatic: if your dependencies change, the cache is renewed. If they do not, the cache is reused.

Why package-lock.json and not package.json?

That is a frequent question. The answer is simple:

FileContentExample
package.jsonVersion ranges"lodash": "^4.17.0" (can be 4.17.0, 4.17.1, 4.18.0 and so on)
package-lock.jsonExact versions"lodash": "4.17.21" (always that precise version)

If you use package.json, the hash can stay identical while the real versions have changed. You then risk restoring a cache that is incompatible with your current dependencies.

The lock file guarantees that the cache matches exactly what will be installed.

The cache key: the label on your fridge

When you use cache: 'npm', GitHub automatically creates a cache key. That key is like the label on a box in the fridge: it says what is inside.

Anatomy of a key

Structure of a cache key: prefix, OS and hash of the
dependencies

A typical key looks like this:

npm-Linux-d5ea0750abc123def456
│ │ └── Hash of package-lock.json
│ └── Operating system
└── Cache type (npm, pip, maven and so on)

Why those three parts?

PartRoleExample
PrefixAvoids collisions between cache typesnpm- vs pip-
OSCompiled dependencies differ per OSLinux vs Windows
HashIdentifies the exact version of the dependenciesChanges when the lockfile changes

What happens when the key does not match?

Imagine you add a new dependency. The hash changes. GitHub looks for a cache under the new key, and finds nothing.

That is where the restore-keys (fallback keys) come in.

key: npm-Linux-xyz789 # The exact key (new)
restore-keys: |
npm-Linux- # Fallback: any npm cache on Linux
npm- # Last resort: any npm cache
  1. Exact lookup

    GitHub looks for npm-Linux-xyz789. Not found? On to the next step.

  2. Prefix lookup

    GitHub looks for a cache starting with npm-Linux-. It finds npm-Linux-abc123 (the old cache). It restores it.

  3. Partial install

    npm ci runs. 95 % of the packages are already there (from the old cache). Only the new dependency is downloaded.

  4. A new cache

    At the end, GitHub saves the new cache under the npm-Linux-xyz789 key.

Result: instead of downloading everything (47 seconds), you only download the difference (5 seconds).

Advanced configuration: taking control

The cache: 'npm' method is magical, but sometimes you need more control. That is where the actions/cache action comes in.

When should you use actions/cache?

The cache: 'npm' line is enough for the dependencies, but that is all it knows how to cache. As soon as you want to cache something else (a build cache, several paths, a custom key), you have to move to the actions/cache action. The table below tells you which of the two methods to pick depending on the need.

SituationSolution
Basic dependency cachecache: 'npm' (simple)
Build cache (Next.js, Webpack and so on)actions/cache (advanced)
Several paths to cacheactions/cache (advanced)
A custom keyactions/cache (advanced)

Example: caching the Next.js build

Next.js stores its build cache in .next/cache. Without that cache, the build can take 2 minutes. With it, it takes 30 seconds.

name: CI with a build cache
on: [push]
permissions: {}
jobs:
build:
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
# Dependency cache (the simple method)
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '20'
cache: 'npm'
# Next.js build cache (the advanced method)
- name: Cache the Next.js build
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: .next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('src/**') }}
restore-keys: |
nextjs-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-
nextjs-${{ runner.os }}-
- run: npm ci
- run: npm run build # <- 30s instead of 2 min!
- run: npm test

Let us break that key apart:

nextjs-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('src/**') }}
│ │ │ │
│ │ │ └── Changes when the source code changes
│ │ └── Changes when the dependencies change
│ └── Linux, macOS or Windows
└── Prefix identifying this cache

The cache is invalidated if:

  • You change operating system
  • You change the dependencies
  • You change the source code

That is logical: the build depends on those three things.

Example: caching several paths

You can cache several folders in a single entry:

- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.npm
.next/cache
node_modules/.cache
key: all-caches-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}

What you should cache (and what you should not)

Cache this: the dependencies

The best candidate for the cache is the downloaded dependencies: they rarely change and they weigh a lot. The "What to cache" column gives the global cache path of each ecosystem, not the local install folder: it is that global directory you want to target, because it is more reliable and more portable than node_modules.

LanguageWhat to cacheWhy
Node.js~/.npmThe global npm cache, works everywhere
Python~/.cache/pipThe packages downloaded by pip
Java~/.m2/repositoryThe Maven artefacts
Go~/go/pkg/modThe Go modules
Rust~/.cargoThe Cargo crates

Cache this too: the build outputs

Caching the dependencies avoids downloading them again; caching the build output avoids recompiling, which often pays off much more. The "Typical gain" column gives an indicative range, measured on real projects: it depends heavily on the size of your code, so take it as an order of magnitude, not as a promise.

ToolWhat to cacheTypical gain
Next.js.next/cache50-80 %
Webpacknode_modules/.cache40-60 %
Gradle~/.gradle/caches50-70 %
Rusttarget/60-80 %

What you must NOT cache

Do not cacheWhy
node_modules directlyFragile, depends on the exact OS
Secrets, tokensThe cache is readable from PRs
Large files that are never reusedWastes space (10 GB limit)
Executable codeA security risk (cache poisoning)

Security: who can reach my cache?

The cache is not shared just any old way. GitHub applies strict isolation rules.

The access rules

Cache isolation per branch: allowed and refused
accesses

FromCan reach the cache ofAllowed?
A feature branchmain (the default branch)Yes
The feature-a branchfeature-b (a sibling branch)No
An external forkThe parent repositoryNo
A PRThe target branchYes

Why those restrictions?

Imagine an attacker forks your project. If they could write into your cache, they could inject malicious code there. On the next build on main, that code would run with access to your secrets.

The restrictions prevent that scenario: forks cannot touch the cache of the parent repository.

To harden your workflows further, also think about pinning your actions by SHA.

The cache poisoning risk

Limits and quotas

What you have to know

Two limits shape your cache strategy: the total quota of 10 GB per repository and the 7-day retention. The first one explains the automatic purge described just after; the second one means that a cache on a quiet branch eventually disappears on its own. Remember those two values, the other rows are guardrails you will rarely reach.

LimitValueWhat it means
Size per entry10 GBA single cache cannot exceed 10 GB
Total size10 GBEvery cache of the repository combined
Retention7 daysThe cache is removed if it is not used for 7 days
Key length512 charactersWatch out for overly complex keys

When the cache is full

If you go past 10 GB, GitHub removes the oldest caches (the ones unused for the longest time) until it is back under the limit.

A potential problem: if all your caches are large and used regularly, you can enter a create-and-delete cycle called cache thrashing. Every run creates a cache that evicts the previous one.

Fixes:

  • Cache less (only what is needed)
  • Ask for a quota increase (enterprises)
  • Watch it with gh cache list

Debugging when it does not work

The cache is never used

Symptom: you always see "Cache miss" in the logs.

Possible causes:

  1. The key changes on every run

    # ❌ Bad: github.sha changes on every commit
    key: cache-${{ github.sha }}
    # ✅ Good: only changes when the dependencies change
    key: cache-${{ hashFiles('**/package-lock.json') }}
  2. The hashed file does not exist

    # ❌ The file does not exist -> empty hash -> always a different key
    key: cache-${{ hashFiles('package-lock.json') }}
    # ✅ A glob pattern that finds the file
    key: cache-${{ hashFiles('**/package-lock.json') }}
  3. No restore-keys

    Without restore-keys, the slightest change means a complete cache miss.

How to see what is going on

Add a debugging step:

- name: Debug the cache
run: |
echo "Computed key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}"
echo "Does the file exist?"
ls -la package-lock.json || echo "File not found!"

Listing and deleting the caches

The GitHub CLI lets you manage your caches:

Fenêtre de terminal
# See every cache of the repository
gh cache list
# Delete one specific cache
gh cache delete "npm-Linux-abc123"
# Delete everything (useful to start from scratch)
gh cache delete --all

A complete annotated workflow

Here is a production-ready workflow with every good practice:

name: Production CI
on:
push:
branches: [main]
pull_request:
branches: [main]
# No rights by default: the job asks for the minimum (security)
permissions: {}
jobs:
build:
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
# 1. Fetch the code
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# 2. Set up Node.js with the dependency cache
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20'
cache: 'npm'
# Equivalent to:
# - Look for a cache under the key npm-Linux-<hash of package-lock.json>
# - Restore ~/.npm if found
# - Save ~/.npm at the end
# 3. Next.js build cache (optional but recommended)
- name: Cache the Next.js build
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('src/**', 'app/**') }}
restore-keys: |
nextjs-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-
nextjs-${{ runner.os }}-
# 4. Install the dependencies
# - With a cache hit: around 2 seconds
# - Without a cache: around 45 seconds
- name: Install dependencies
run: npm ci
# 5. Build
# - With the build cache: around 30 seconds
# - Without a cache: around 2 minutes
- name: Build
run: npm run build
# 6. Tests
- name: Test
run: npm test

Adapting it per ecosystem

The principles of this page hold for every package manager, but each one puts its files elsewhere and reacts differently to a badly chosen key. Three companion guides detail the most common cases, with the exact paths to put in path: and the traps specific to each tool:

  • Python cache: adapting these keys to pip, Poetry and uv.
  • Node.js cache: npm, yarn and pnpm, including the trap of caching node_modules as it is.
  • Java cache: Maven and Gradle, where the cache weighs the most and pays off the most.

Key points

  1. One line is enough to start

    Add cache: 'npm' (or pip, maven and so on) to your setup-* action. That is all. You have just saved 30 to 60 seconds per run.

  2. The lockfile hash is the key

    The cache is identified by the hash of your lock file (package-lock.json, poetry.lock and so on). If the file does not change, the cache is reused.

  3. The restore-keys are your safety net

    Even when the exact key does not exist, the restore-keys let you recover a partial cache. Always configure them.

  4. Cache the global, not the local

    Prefer ~/.npm to node_modules. It is more robust and more secure.

  5. The cache is isolated per branch

    Feature branches can read the cache of main, but forks cannot reach the cache of the parent repository.

  6. 10 GB maximum, 7 days of retention

    Watch your usage with gh cache list. Only cache what is needed.

  7. Check the logs

    Look for "Cache hit" or "Cache miss" to confirm the cache is working.

Next steps

  • Concurrency: preventing two runs of the same branch from fighting over the same key.
  • Runners: introduction: what the choice of machine changes for the cache, between a fresh VM on every job and a persistent disk.
  • Ephemeral runners: the case where the local cache disappears by design, and what replaces it.

Is this site useful to you?

Fewer than 1% of readers support this site.

I maintain more than 700 free guides, with no ads and no tracking. Any support, even a symbolic one, helps cover hosting and keeps these resources free. Thank you for the help.

The form does not show? Open Ko-fi in a new tab.

Subscribe and follow my DevSecOps work on LinkedIn