Cache: how it differs from artifacts
We speed up dependency installs between runs. A key by the lock file's hash, and why caching node_modules for npm ci makes no sense.
How to speed up installing dependencies between pipeline runs without ending up with a build that depends on luck.
Why this matters
Each job starts in a clean container, so npm ci downloads every package again on every
pipeline. On the training project that's seconds; on a frontend with hundreds of packages it's a
minute or two, and that happens on every branch and every commit. A cache lets you reuse
what was already downloaded in a previous run. It's easy to set up wrong: the cache will dutifully
get saved and restored, and bring no benefit at all. Or worse, the build starts depending on
whatever happens to be in the cache.
Two similar things, different jobs
You already know artifacts: they carry files forward through the pipeline. The cache solves a different problem, it moves files between runs.
The difference is in direction. An artifact travels forward, from stage to stage within one pipeline. A cache travels sideways, from yesterday's pipeline into today's.
There's also a difference in reliability. GitLab stores artifacts itself and delivers them reliably. The cache lives wherever the runner is installed, or in object storage (S3) if the runners share a common cache.
That gives you the rule for choosing. A build result that the deploy needs is an artifact. Downloaded dependencies that you wouldn't mind losing are a cache.
The key decides everything
build:
image: node:22.11.0-alpine
cache:
key:
files:
- package-lock.json
paths:
- .npm/
script:
- npm ci --cache .npm --prefer-offline
- npm run build
cache:paths says what to save after the job. cache:key gives the cache a name: every job with
the same key shares one cache, even across different pipelines. If no key is set, it equals
default.
key:files computes a SHA from the contents of the listed files, and that hash becomes part of
the cache name. In the job log it looks like this:
Restoring cache
Checking cache for 0_package-lock-69002804654f84e32d6750cf64eeedd8a1476ef2-protected...
.npm: found 21 matching artifact files and directories
Successfully extracted cache
The -protected suffix means the pipeline ran on a protected branch. By default protected and
unprotected branches don't share a cache.
Dependencies changed, so package-lock.json changed, and with it the hash and the cache name.
The job starts with an empty cache and saves a new one after a successful run. Nothing changed,
so the old one gets reused. Exactly the behavior you want.
The key can also be a static string (key: node-deps). Then after a dependency update, you'll get
a cache built for the old lock file. With npm ci that's just extra archives sitting in the
cache. It's worse if the pipeline takes the cache on faith, for example installing packages only
when the node_modules folder is missing. Then the build quietly assembles with old package
versions, and that's what the second lab digs into.
key:files holds at most two files by default. GitLab 19.5 raised the limit to ten, but behind a
feature flag that's off by default. Globs like **/package-lock.json are allowed. A missing file
is simply left out of the calculation. If none exist, the key falls back to default.
There's a similar key, key:files_commits: it hashes not the content but the last commit that
touched the files. A new commit gives a new cache even if the content didn't change.
Why .npm, not node_modules
The most common mistake here: caching node_modules. With npm install that works, with npm ci
it's pointless.
npm ci starts its work by deleting node_modules entirely. That's its contract: a
reproducible install from scratch, driven by the lock file. The folder restored from the cache
gets wiped in the first second, and the packages download all over again.
So instead you cache npm's download folder, not the installed packages:
- npm ci --cache .npm --prefer-offline
--cache .npm moves the whole npm cache inside the project: the downloaded package archives and
the registry metadata both. By default it lives in the home folder (~/.npm), and GitLab only
caches paths inside the project. An absolute path, or a path with .. going outside, won't work
in cache:paths.
--prefer-offline tells npm not to re-check over the network what's already in the cache, and
only reach the registry for what's missing. npm ci already finds archives in the cache by their
integrity hash from the lock file; the flag just saves on metadata requests.
How to tell the cache is working
A green pipeline and the line Successfully extracted cache don't prove anything yet. The
node_modules cache under npm ci also "successfully extracts".
Watch the install step. Compare two runs back to back with the same lock file: on the second one packages shouldn't download again. On a real project you can see this from the install time and the network requests; in the lab, the training runner prints exactly how many archives came from the registry.
Separately, watch the Restoring cache and Creating cache lines in the log: how long the actual
exchange with storage took. If the cache weighs hundreds of megabytes, downloading and unpacking
it can eat up the time you saved on install.
The cache can lie
The cache comes with no guarantees. GitLab's docs flat out call it an optimization that might not work. The cache lives on a specific runner or in object storage, gets cleaned up by its own rules, and a neighboring job might land on a different runner and not find it at all.
The pipeline must work with an empty cache. If a build is only green when the cache happened to be there, that's not a speedup anymore, it's a hidden dependency. And the other way around: if the build's result changes based on what's in the cache, the cache has replaced the install.
Hence the practice: cache for what can be downloaded again, artifacts for what is
guaranteed to arrive.
When a build starts behaving strangely for no obvious reason, you can reset the cache with the Clear runner caches button on the Build → Pipelines page. GitLab doesn't delete the old cache, it changes its internal name, and the next pipeline starts with an empty one. That's a one-time fix: if the problem is in the key, it'll come back with the very next dependency update.
Common mistakes
Caching node_modules under npm ci. The cache unpacks, npm ci deletes it, packages
download again. Symptom: install time doesn't change from run to run.
.npm/ in cache:paths, but no --cache .npm. npm writes to the container's home folder,
.npm never shows up in the project. The Creating cache log line will carry a warning,
WARNING: .npm: no matching files.
A static key plus "install if the folder is missing". test -d node_modules || npm ci plus
key: node-deps gives you a build with old packages after every lock-file update.
Pipeline is only green with the cache. The build pulls from the cache something that's never created anywhere else. The very first run on a new runner, or after clearing the cache, will fail.
What to remember
Artifacts carry files forward through stages, a cache moves them between pipeline runs.
key:files ties the cache to the lock file's hash: dependencies update, a new cache arrives.
For npm ci you cache .npm with the --cache .npm flag, because that command deletes
node_modules before installing.
The cache isn't guaranteed: the pipeline has to build with an empty cache too.
What you'll need for the tasks
In the labs, the pipeline runs through a training runner on your laptop. It runs each job in a
clean node:22.11.0-alpine container, keeps the cache between runs in the cache/<key>/ folder,
like GitLab Runner's local cache. npm packages come from a training registry in a neighboring
container, the job has no other access to the internet. After each job the runner prints how many
package archives were downloaded from the registry. The training runner computes the cache name
similarly to GitLab, but not byte for byte.
git -C work commit -am "...": the runner, like GitLab, only takes what's committed.bash run-pipeline.sh: run the pipeline against the latest commit.- The
cache:key,cache:key:filesandcache:pathskeys in.gitlab-ci.yml. - The flags
npm ci --cache <folder> --prefer-offline. ls -a cache/*/: see what the runner saved into the cache.
Next we learn to control which jobs even run: rules, manual buttons, and branches.
Done reading?
Mark the lesson as read to move your track progress forward.
Lesson marked as done.
Go through it with an AI agent
The agent explains anything unclear in plain words, gives you 2 labs for this lesson and drills you on interview questions. $49 one-time, lifetime access.
Tell the agent: use MCP izi, give me lesson cicd-practice-08-cache
Progress saved in this browser
Clear your history or open this on your phone, and the marks will be gone. Leave your email and they'll move to your account. No password needed.
Done. moved to your account. You can see your progress on any device. My progress