Composer and npm
Most GitLab CI guides show caching using composer.lock or package-lock.json and stop there. Anyone who needs multiple independent caches per job or wants finer-grained invalidation has to go deeper into cache:key.
Table of Contents
- 1. Why a plain branch name is not a good enough cache key
- 2. cache:key:files: hash-based invalidation via lock files
- 3. cache:key:prefix: separating the same hash across different contexts
- 4. Multiple independent caches inside a single job
- 5. Cache policy: using pull, push, and pull-push deliberately
- 6. Fallback keys for the cache miss case
- 7. Do not confuse cache with artifacts
- 8. Debugging cache behavior and finding misconfigurations
- 9. A practical overall strategy for growing projects
- 10. Summary
- 11. FAQ
1. Why a plain branch name is not a good enough cache key
The simplest form of a cache key in GitLab CI is a fixed string or the branch name via $CI_COMMIT_REF_SLUG. That works well enough for small projects, but it has a structural downside: the cache gets rebuilt per branch even when the actual dependencies have not changed at all since the last run. A feature branch pushed ten times a day, with package-lock.json unchanged for days, can still trigger a full reinstall in the worst case if the cache happened to be evicted for any reason.
The real goal of a good cache key is not to represent the branch, but to represent the state of the input data that determines the cache content. If the lock file does not change, the cache key should stay the same too, regardless of which branch or pipeline it is used in. That is exactly what cache:key:files delivers, by deriving the key from the hash of specific files instead of from metadata like the branch name.
2. cache:key:files: hash-based invalidation via lock files
With cache:key:files you specify a list of files whose content GitLab hashes and uses as the cache key. If even a single byte changes in one of those files, a new key is generated automatically and with it a fresh, empty cache, while the old cache entry stays untouched and gets evicted normally later on. As long as the files stay unchanged, the exact same cache is reused consistently across branches and pipelines, which drastically cuts down on redundant dependency installs.
The mechanism can be applied to several files at once, which matters especially for monorepos with multiple languages: a shared key that includes both composer.lock and package-lock.json changes as soon as either file changes. For real precision, though, it is usually better to define several separate caches, each with its own specific key:files entry, so a change to one dependency file does not unnecessarily invalidate the cache of the other language.
install_php:
stage: install
image: composer:2
cache:
key:
files:
- composer.lock
paths:
- vendor/
script:
- composer install --no-progress --prefer-dist
install_node:
stage: install
image: node:20-alpine
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
script:
- npm ci
3. cache:key:prefix: separating the same hash across different contexts
A pure hash from key:files has a downside: two jobs with an identical lock file but a different context, say a job for PHP 8.3 and one for PHP 8.4 in the same project, would get the same cache key with no further distinction and would overwrite each other. This is where prefix comes in: it is prepended to the computed hash and lets the same content-based cache key be cleanly separated across different matrix dimensions such as PHP version, OS image, or job name.
In practice, prefix is often combined with a CI variable coming from a matrix configuration, such as the PHP_VERSION value of a parallelized job. That produces a separate, clearly scoped cache entry per combination of lock-file state and PHP version, one that neither collides with other versions nor gets rebuilt unnecessarily often when only the lock file changes but none of the matrix dimensions do.
test_matrix:
stage: test
parallel:
matrix:
- PHP_VERSION: ["8.2", "8.3", "8.4"]
image: php:${PHP_VERSION}-cli
cache:
key:
files:
- composer.lock
prefix: "php-${PHP_VERSION}"
paths:
- vendor/
script:
- composer install --no-progress
- vendor/bin/phpunit
4. Multiple independent caches inside a single job
Since GitLab 13.9, the cache directive accepts not just a single object but also a list of several cache definitions within the same job. This is especially valuable when a job produces multiple independent kinds of artifacts that change at different rates, for example Composer dependencies that change rarely, and a build directory with compiled assets that changes with every edit to source files. A single shared cache key would in that case either invalidate too often or too rarely, depending on which file was chosen for the hash.
With separate cache entries, each artifact type gets its own key, tuned appropriately, and its own lifetime. The Composer cache stays stable across many pipelines while the asset cache rebuilds on every relevant frontend code change, without the two interfering with each other. This separation significantly cuts down on unnecessary re-downloading of dependencies without risking stale build artifacts.
build_app:
stage: build
image: node:20-alpine
cache:
- key:
files:
- package-lock.json
paths:
- node_modules/
- key:
files:
- webpack.config.js
prefix: "assets"
paths:
- public/build/
script:
- npm ci
- npm run build
5. Cache policy: using pull, push, and pull-push deliberately
By default a job downloads the cache and re-uploads it at the end, which makes sense for the first job in a pipeline but creates unnecessary overhead in downstream jobs that only read the cache without modifying it. The policy: pull option tells GitLab to download the cache but not upload it again at the end, which saves time especially for test or lint jobs that need node_modules but do not install new packages.
Conversely, policy: push suits the first job in a chain that actively builds the cache but does not need any previous state itself, such as a dedicated install job that only installs dependencies. Splitting into a single push job followed by several pull jobs avoids every downstream job redundantly re-uploading the same unchanged cache content, which noticeably saves time and network bandwidth for large node_modules directories.
install:
stage: install
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
policy: push
script:
- npm ci
lint:
stage: test
needs: ["install"]
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
policy: pull
script:
- npm run lint
6. Fallback keys for the cache miss case
A pure hash key has a sharp edge: if the lock file changes even slightly, say through a single updated package version, there is no existing cache entry for the new key, and the entire dependency tree gets installed from scratch. For package managers that support incremental installs, it is often more efficient to at least have an older, similar cache as a starting point instead of starting completely from zero.
GitLab does not offer an automatic fallback mechanism the way some other CI systems do, but a similar effect can be achieved by adding an additional, more coarsely grained cache with a more stable key as a second cache definition in the job, whose content the package manager can use as a baseline before the precisely hashed cache takes over. For npm, for instance, an additional cache of the global npm cache directory helps, since it changes far less often and less drastically than the exact node_modules structure.
7. Do not confuse cache with artifacts
A common mistake is treating cache and artifacts as interchangeable tools. A cache is an optimization with no guarantee: GitLab may evict it at any time, especially under limited disk space on the runner, and a pipeline that hits an empty cache still has to work correctly, just more slowly. Artifacts, by contrast, are a guaranteed output of a job that gets passed to downstream jobs, and the pipeline would fail if they were missing.
Anyone passing build results between jobs of the same pipeline via cache instead of artifacts risks intermittent failures that are hard to reproduce, because they depend on the luck of cache availability on the particular runner. The clear rule is: anything a downstream job in the same pipeline absolutely needs belongs in artifacts, anything that is merely a reuse optimization across multiple pipelines belongs in cache.
8. Debugging cache behavior and finding misconfigurations
When a cache appears not to hit even though the lock file is unchanged, the job log is the first place to look: GitLab explicitly logs there whether a cache was downloaded, which key was searched for, and whether it was a hit or a miss. A common mistake is a mismatched path in cache:paths between two jobs that are supposed to share the same cache, so GitLab formally uses the same key but saves and restores different directories.
Another classic pitfall is a runner switch: when jobs of the same pipeline run on different runners without a distributed cache backend, for example self-hosted runners without an S3-based distributed cache, a cache written on runner A may simply not be visible to a job on runner B. In such environments it is worth setting up a central, S3-compatible cache store in the runner configuration so caches work independently of which runner picks up a job.
9. A practical overall strategy for growing projects
For a growing project with multiple languages and matrix jobs, combining all the techniques covered here works well: separate caches per dependency type with key:files pointing at the respective lock file, a prefix per matrix dimension such as language version, policy: push in a dedicated install job followed by policy: pull in all downstream jobs, and a clear separation between cache for reuse and artifacts for guaranteed handoff within the pipeline.
The table below summarizes the individual mechanisms and their primary use case, so the right combination can be picked quickly when setting up a new pipeline instead of reasoning through the correct cache configuration from scratch for every new job.
| Mechanism | Purpose | Typical example | Pitfall |
|---|---|---|---|
| cache:key:files | Hash-based invalidation | composer.lock, package-lock.json | Mixing several files into one key blurs changes |
| cache:key:prefix | Separate contexts (matrix) | PHP version, Node version | Forgetting prefix: matrix jobs overwrite each other |
| Multiple caches per job | Different lifetimes | Dependencies vs. build assets | One key for everything invalidates too often or too rarely |
| policy: pull/push | Reduce overhead | Install job push, test jobs pull | All jobs pull-push: unnecessary re-upload |
Mironsoft
CI/CD pipelines, zero-downtime deployments and release automation
Deployments that run without downtime and without the nail-biting?
We review existing GitLab pipelines for fragile deployment steps and missing safeguards, then build a release process with zero-downtime deployments, automated checks and a rollback you can actually trust in an emergency.
Pipeline Review
Checking an existing .gitlab-ci.yml for fragility, missing stages and security gaps.
Zero-Downtime Deployment
Building symlink releases, health checks and rollback strategies for Magento stores.
CI/CD Automation
Connecting tests, security scans and deployments into one reliable pipeline.
10. Summary
Cache Key Strategies: Key Takeaways
key:files
Cache key from a file hash instead of the branch name, invalidates only on real change.
prefix
Separates identical hashes across matrix dimensions like PHP or Node version.
Multiple caches
Different artifact types get their own keys and their own lifetimes.
policy
push in the install job, pull in downstream jobs saves time and bandwidth.