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
-
Cache and artifacts solve different problems.
Cache is a disposable speed optimization. Artifacts are the guaranteed way to pass output between stages. -
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. -
Set
policy: pullon jobs that don’t need to update the cache.
Every job re-uploading the same cache wastes time and runner bandwidth for no benefit. -
Always set
expire_inon artifacts.
Indefinite retention on every pipeline run turns into a storage bill nobody budgeted for. -
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