Cache policies in GitLab CI: pull, push, and fallback keys
A working cache still wastes time in three ways: every job re-uploads it, a lockfile bump starts from zero, and merge requests never see what main warmed up. Cache policies, fallback keys, and the protected-branch split.
- gitlab
- ci-cd
- devops
Part 1 ended with a dependency cache keyed on the lockfile, verified against real job logs. That cache works, and it still wastes time in three distinct ways. Watch a few runs closely: every job re-uploads a store that hasn’t changed, a one-line lockfile bump triggers a full cold install, and merge requests run cold even when main just warmed everything up.
Three problems, three mechanisms. All of them are cheap to fix once you know they exist, and the third one is invisible until you know where to look.
Not every job should upload the cache
The default cache behavior, called pull-push, does two things per job: download the cache at the start, and archive-and-upload it again at the end. For the installer job that makes sense. For lint and typecheck it’s pure waste: they didn’t change the dependency store, yet they spend time at the end of every run compressing and uploading it anyway.
cache:policy splits the roles. One job writes the cache; everyone else only reads:
install-deps:
stage: setup
script: pnpm install --frozen-lockfile
cache:
key:
files:
- pnpm-lock.yaml
paths:
- .pnpm-store
policy: pull-push
lint:
stage: quality
script: pnpm lint
cache:
key:
files:
- pnpm-lock.yaml
paths:
- .pnpm-store
policy: pullA pull job skips the archive-and-upload step entirely. In a real file you’d extract that repeated cache block into a hidden job with extends (part 1 covered how); it’s written out here so the two policies are visible side by side.
There’s also a third value, push, for jobs that produce a cache but never need to read one: a nightly job that warms the cache from scratch is the typical case.
When the key misses: fallback keys
Keying the cache on the lockfile has one sharp edge: the moment anyone touches pnpm-lock.yaml, the key changes and the next job starts from a completely cold cache. A one-line dependency bump pays for a full reinstall.
fallback_keys softens this. If the exact key misses, GitLab tries the fallbacks in order:
cache:
key:
files:
- pnpm-lock.yaml
fallback_keys:
- pnpm-store-main
paths:
- .pnpm-storePair it with a job on the default branch that writes that stable key (a push-policy job is a good fit), and a lockfile change now restores yesterday’s store and installs only the delta. There’s also a global variant, the CACHE_FALLBACK_KEY variable, which is tried last: the lookup order is the exact key, then each entry in fallback_keys, then CACHE_FALLBACK_KEY.
The cache split nobody tells you about
Here’s the one that got us. We had a warm cache on main and cold caches on every merge request, with identical keys in the YAML. The logs eventually gave it away: the main jobs were using pnpm-store-...-protected, and the MR jobs pnpm-store-...-non_protected.
GitLab maintains two caches per key. Pipelines on protected branches and tags get a -protected suffix appended to every cache key; everything else gets non_protected. The reasoning is sound (without the split, anyone who can open an MR could poison a cache that protected release builds later consume), but it means warming a cache on main does nothing for feature branches, and the suffix only shows up if you read the runner log closely.
If that isolation doesn’t buy you anything (for us, the cache held a pnpm store, fully determined by the lockfile), it can be turned off per project: Settings → CI/CD → General pipelines → clear the “Use separate caches for protected branches” checkbox. The behavior and the setting are documented in the caching docs, along with the discussion in issue #360910.
Where this leaves you
The cache now has one writer and many readers, a fallback that absorbs lockfile bumps, and no silent protected/non-protected surprises. That’s about as good as dependency caching gets.
It doesn’t touch the other repetition in the pipeline: the app compiled in build-app still gets compiled again by every job that needs the output. The tempting fix is to put dist/ in the cache too. That’s the wrong tool, and it fails in a sneakier way than a cold install: part 3 is about the right one, artifacts.