GitLab CI Cache Key Strategies Beyond Composer and npm
AI generated
CI/CD
.yml
GitLab · CI/CD · Performance
Cache Key Strategies Beyond
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.

17 min read cache:key:files cache:key:prefix Multi-Cache Invalidation

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.

11. FAQ: Cache Key Strategies: Key Takeaways

1What is the difference between cache:key and cache:key:files?
cache:key is a static or variable-composed string you maintain yourself, while cache:key:files automatically computes a hash from the content of specified files, tying the key to the actual state of the dependencies.
2Can I specify multiple files for cache:key:files?
Yes, up to two files can be specified, and their combined hash forms the key. For more than two independent dependency files, splitting into several separate cache entries is recommended instead.
3What exactly is cache:key:prefix used for?
Prefix separates the same content hash across different contexts, for example different PHP or Node versions in a matrix job, so caches for different versions do not overwrite each other.
4How many caches can a single job have?
Since GitLab 13.9, cache can be a list, so a job can have any number of independent cache definitions, each with its own key and paths.
5What exactly does policy: pull do?
policy: pull tells the job to download the existing cache at the start but not upload it again at the end, saving time when the job does not modify the cache content.
6Why shouldn't I treat cache like artifacts?
A cache is a non-binding optimization with no guarantee, GitLab can evict it at any time. Artifacts, in contrast, are a guaranteed output passed to downstream jobs, and their absence causes the pipeline to fail.
7Why isn't my cache hitting even though the lock file hasn't changed?
Common causes include a mismatched path in cache:paths between jobs, a missing distributed cache backend with multiple runners, or a key that is too coarse and unintentionally varies due to other variables in the string.
8Is there an automatic fallback to an older cache on a miss?
GitLab does not offer a built-in fallback mechanism like some other CI systems do. A similar effect can only be simulated with an additional, more coarsely grained second cache entry in the same job.
9Does the cache type (zip vs. local) affect the key strategy?
No, the choice of cache key is independent of the storage format. It only determines when a new cache entry gets created instead of reusing an existing one.
10Is the effort for advanced cache strategies worth it on small projects?
On very small projects with short pipelines the effect is limited, but on monorepos with multiple languages or matrix jobs the saved install time quickly adds up to several minutes per pipeline run.