How to Structure a GitLab CI Pipeline With Stages and Caching (So Jobs Stop Reinstalling Everything)

By KP  |  TZoneLabs  |  DevOps & Cloud Engineering

The default .gitlab-ci.yml most teams start with runs npm ci or pip install in every single job, on every single commit. A pipeline with a build, test, and lint job doing the same install three times isn’t three times safer. It’s three times slower for no reason.

We want to walk through how to structure stages properly, the actual difference between cache and artifacts (most of the confusion starts here), and how to verify the cache is doing anything at all.

What a Naive Pipeline Looks Like

build:
  script:
    - npm ci
    - npm run build

test:
  script:
    - npm ci
    - npm test

deploy:
  script:
    - npm ci
    - npm run deploy

No stages defined, so all three run in parallel with no guaranteed order, deploy included. And npm ci runs three separate times, downloading the same dependencies from the registry three separate times, on every commit.

Define Stages, and Assign Jobs to Them

stages:
  - build
  - test
  - deploy

build-job:
  stage: build
  script:
    - npm ci
    - npm run build

test-job:
  stage: test
  script:
    - npm test

deploy-job:
  stage: deploy
  script:
    - npm run deploy
  only:
    - main

Jobs in the same stage run in parallel. Jobs in different stages run in order, and a stage only starts once every job in the previous stage has succeeded. deploy-job restricted to main is doing real work here too: nothing deploys off a feature branch by accident.

Cache vs Artifacts, and Why the Difference Matters

This is the part that trips people up. Cache and artifacts both persist files between runs, but they solve different problems:

  • Cache speeds up a job by reusing dependencies (node_modules, .m2, vendor) across pipeline runs. It’s a performance optimization. GitLab makes no guarantee the cache will be there, treat it as disposable.
  • Artifacts pass specific output (a compiled binary, a dist/ folder, a test report) from one job to a later job in the same pipeline run. If the deploy job needs something the build job produced, that’s an artifact, not a cache.

Using cache to pass a build output between stages is the most common mistake here. Because caching isn’t guaranteed to restore, a deploy job can silently ship a stale or missing build.

Setting Up Cache Correctly

build-job:
  stage: build
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - node_modules/
    policy: pull-push
  script:
    - npm ci
    - npm run build

The key: files setting ties the cache to a hash of package-lock.json. Change a dependency version, the lockfile hash changes, and the job gets a fresh cache instead of silently reusing stale node_modules. Jobs later in the pipeline that only need to read the cache, not update it, should set policy: pull to skip the upload:

test-job:
  stage: test
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - node_modules/
    policy: pull
  script:
    - npm test

Passing Build Output Between Stages With Artifacts

build-job:
  stage: build
  script:
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 hour

deploy-job:
  stage: deploy
  script:
    - ./deploy.sh dist/
  only:
    - main

expire_in matters. Artifacts default to being kept indefinitely on some GitLab configurations, and a year of dist/ folders from every pipeline run adds up in storage fast. An hour is plenty for output that only needs to survive until the next stage picks it up.

Using a Shared Runner Cache Across Branches

By default, GitLab scopes cache per branch, so a feature branch’s first run doesn’t get to reuse main’s cache. If your dependencies rarely change across branches, share the cache deliberately:

cache:
  key: "$CI_COMMIT_REF_SLUG-deps"
  paths:
    - node_modules/
  fallback_keys:
    - "main-deps"

fallback_keys tells the runner: if there’s no cache yet for this branch, use main’s cache as a starting point instead of installing from scratch.

Verifying the Cache Is Actually Working

Don’t assume it’s working because the config looks right. Check the job log:

Checking cache for main-deps...
Downloading cache.zip from https://storage.googleapis.com/...
Successfully extracted cache

versus, on a miss:

Checking cache for feature-x-deps...
No URL provided, cache will not be downloaded.

We run one pipeline with an empty cache and one right after with a warm cache, and compare the install step’s duration in the job timing breakdown. On a mid-sized Node project, that’s usually a 30 to 60 second difference per job, and it compounds across every job that installs dependencies.

Full Pipeline, Put Together

stages:
  - build
  - test
  - deploy

variables:
  NODE_ENV: "production"

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

build-job:
  stage: build
  cache:
    policy: pull-push
  script:
    - npm ci
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 hour

test-job:
  stage: test
  cache:
    policy: pull
  script:
    - npm test

deploy-job:
  stage: deploy
  script:
    - ./deploy.sh dist/
  only:
    - main

The default: cache block sets a shared cache config every job inherits, so each job only needs to override the policy. Less repetition, and one place to change the cache key strategy later.

Key Lessons

  1. Cache and artifacts solve different problems.
    Cache is a disposable speed optimization. Artifacts are the guaranteed way to pass output between stages.
  2. Tie the cache key to the lockfile, not the branch name alone.
    A stale cache from before a dependency bump is worse than no cache at all.
  3. Set policy: pull on jobs that don’t need to update the cache.
    Every job re-uploading the same cache wastes time and runner bandwidth for no benefit.
  4. Always set expire_in on artifacts.
    Indefinite retention on every pipeline run turns into a storage bill nobody budgeted for.
  5. Verify the cache in the job log, don’t assume the YAML is doing what it says.
    “Cache will not be downloaded” is easy to miss when you’re only checking that the job passed.

Summary

Feature Purpose Use When
stages Controls job execution order Any pipeline with more than one logical phase
cache Speeds up repeated dependency installs node_modules, vendor, package manager caches
artifacts Passes files between stages in one run Build output a later stage needs
fallback_keys Reuses another branch’s cache on a miss New branches with the same dependencies as main

None of this requires a paid runner tier or a bigger machine. It’s the difference between a pipeline that treats every commit as a cold start and one that doesn’t.

Read Next

If you’re running Kubernetes in production, follow along on LinkedIn for more guides like this one as they’re published.


Tags:
#GitLab   #CICD   #DevOps   #Caching  
#Pipelines   #SRE

Leave a Comment