GitLab CI/CD Associate study materials
GitLab CI/CD Associate study resources

An 8% cache hit rate, a full artifact quota, and a six-minute deploy

GitLab CI/CD Associate · Put your knowledge to work in context.

All case studies

An 8% cache hit rate, a full artifact quota, and a six-minute deploy

A Node.js 20 monorepo builds three packages in one pipeline. default: sets the cache to key: "$CI_COMMIT_REF_SLUG" with paths: [node_modules/], and the build job publishes dist/ as an artifact with no expire_in. The deploy job declares neither needs: nor dependencies:. Symptoms after a quarter of growth: (1) the cache hit rate is about 8% - every merge request and every new feature branch starts with a cold npm ci, and each branch uploads roughly 700 MB of cache that no other branch ever reads; (2) artifact storage reached 9.4 GB against a 10 GB quota and pipelines now fail at the upload step; (3) deploy takes six minutes because it downloads artifacts from all 14 jobs in earlier stages, including three linters that produce nothing and a test job that exports a 200 MB coverage report nobody downstream reads.

1. Should `node_modules` be a cache or an artifact here, and what key strategy fixes the hit rate? Justify the choice.

Read the worked answer

node_modules is a cache, not an artifact. Artifacts are the pipeline's deliverables: they are uploaded to GitLab storage, are downloadable from the UI, count against project storage, and are meant to be consumed by later jobs or humans. Caches are a best-effort speedup stored on the runner (or an object store if distributed caching is configured) and are explicitly allowed to be missing. Putting 700 MB of dependencies in artifacts is how quotas die.

The key is the actual bug. $CI_COMMIT_REF_SLUG gives every branch its own namespace, so a feature branch can never reuse the cache its target branch already built, and every MR pays full install cost. Key on the thing that determines the dependency tree:

default:
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - node_modules/
    policy: pull-push

cache:key:files hashes the listed files, so all branches with an identical lockfile share one cache entry and it is invalidated precisely when dependencies change. Add cache:key:prefix: "$CI_JOB_NAME" if different jobs need different contents of node_modules.

Two operational caveats: the cache must exist on the runner that picks up the job, so with multiple or autoscaling runners configure distributed caching (S3/GCS/Azure) in config.toml, otherwise the hit rate stays low no matter how good the key is. And set policy: pull on jobs that only read the cache, so test and lint jobs stop re-uploading it.

2. How do you stop `deploy` downloading every artifact, and how do you stop artifact storage from filling the quota?

Read the worked answer

By default a job downloads artifacts from all jobs in earlier stages. With needs:, the job becomes a DAG node and downloads artifacts only from the jobs it needs - that is the modern way to express this, and dependencies should not be combined with it (when needs is present it is the artifact source, and mixing the two is confusing at best).

deploy:
  stage: deploy
  needs:
    - job: build
      artifacts: true
    - job: test:unit
      artifacts: false

artifacts: false keeps the ordering/parallelism benefit while skipping the download. For a job with no needs at all, dependencies: [] downloads nothing.

For the quota, artifacts need an owner and an expiry:

build:
  artifacts:
    name: "$CI_COMMIT_REF_SLUG-$CI_COMMIT_SHORT_SHA"
    paths: [dist/]
    expire_in: 1 week

Also: mark coverage reports as artifacts:reports:coverage_report (or coverage_report/JUnit report types) only where a consumer exists, use artifacts:exclude to drop source maps and caches accidentally caught by paths, and audit existing storage - expiring old artifacts frees quota, but artifacts already marked keep_latest or attached to a release are retained until deleted explicitly.

3. A downstream project's pipeline needs the `dist/` from this project's `build` job. Why is the cache the wrong mechanism, and what is the right one?

Read the worked answer

Caches are scoped to a project and, unless distributed caching is configured, to a single runner's local disk. A different project will not see this project's cache, may land on a different runner, and even on the same runner the key namespace is per project. Caches are also non-deterministic by design: the entry can be evicted or absent, which is fine for a speedup and unacceptable for a build input.

Artifacts are the durable, addressable mechanism, and cross-project consumption is supported through needs:project:

# downstream project
assemble:
  needs:
    - project: acme/platform
      job: build
      ref: main
      artifacts: true

Requirements and caveats: the referencing job's user must be able to read the upstream project (at least Reporter), ref pins which pipeline's artifacts are fetched (a branch, tag, or commit SHA), artifacts: true is the default for needs:project, and the upstream artifact must not have expired - so keep expire_in long enough for the consumer, or publish the deliverable as a package/release instead of a transient artifact. If you need the upstream pipeline to be running or passing first, add strategy: depend on the trigger side rather than relying on needs:project, which fetches artifacts from an existing pipeline.