Artifacts, cache & rules
Pass files, speed builds, run jobs conditionally.
You push a commit. Three jobs run in order: build compiles the app into dist/, test runs against it, deploy ships it. build goes green. Then deploy dies on its first line with ./deploy.sh: dist/: No such file or directory. Nothing is broken here. GitLab CI (continuous integration, the engine that runs your build on every push) is doing exactly what it promises. Every job gets its own rented workshop: a fresh container, a clean checkout of your code, and nothing else. The workshop is bulldozed the second the job ends. dist/ was made inside build's workshop, so dist/ went down with it. Anything you want carried over to the next job has to be declared in writing. That paperwork is what artifacts, cache, and rules are for.
Artifacts: hand files to the next stage
An artifact is a parcel you post to yourself. The job finishes, GitLab collects the files you named, holds them, and hands them back to later jobs in the same pipeline. You list what to keep under artifacts:paths. GitLab zips those paths and, unless you say otherwise, unpacks them into every later-stage job before that job's script runs. One mechanism, two very different uses: it is how a compiled binary reaches deploy, and how a test report shows up on the merge request page. Two fields keep it tidy. expire_in says how long GitLab holds the parcel before deleting it to reclaim storage. Put it on everything. Artifacts with no expiry are the fastest way to burn through a project's storage quota. artifacts:reports:junit tells GitLab to open the file and render pass/fail results inline instead of only filing it away (JUnit is the XML test-result format most test runners can emit). Pair it with when: always so the report uploads even when the tests fail, which is precisely the run you want to read.
stages: [build, test, deploy]build:stage: buildimage: node:22-alpinescript:- npm ci- npm run build # emits dist/artifacts:paths:- dist/expire_in: 1 weektest:stage: testimage: node:22-alpinescript:- npm ci- npm test -- --reporters=jest-junitartifacts:when: always # upload the report even if tests failreports:junit: junit.xmlexpire_in: 3 daysdeploy:stage: deployimage: alpine:3.20script:- ./deploy.sh dist/ # dist/ restored automatically from buildrules:- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
Run it, then read build's log all the way to the bottom. The upload is its own step, performed by the runner after your script exits. If the job goes green and you see no upload lines at all, your paths matched nothing and you have posted an empty parcel.
$ npm run build> vite build✓ built in 4.21sUploading artifacts for successful jobUploading artifacts...dist/: found 42 matching artifact files and directoriesUploading artifacts as "archive" to coordinator... 201 Created id=8471120933 responseStatus=201 CreatedCleaning up project directory and file based variablesJob succeeded
Now look at deploy. Before its script runs, the runner fetches artifacts from every job in every earlier stage. You can watch the downloads scroll past, and dist/ lands on disk byte for byte as build left it.
Preparing environmentGetting source from Git repositoryDownloading artifactsDownloading artifacts for build (8471120933)...Downloading artifacts from coordinator... ok host=gitlab.com id=8471120933 responseStatus=200 OKDownloading artifacts for test (8471120945)...Downloading artifacts from coordinator... ok host=gitlab.com id=8471120945 responseStatus=200 OK$ ./deploy.sh dist/Deploying 42 files from dist/ ...Job succeeded
Notice that deploy pulled build's artifacts and test's. That is the default: a job downloads from every job in all earlier stages, wanted or not. On a wide pipeline that is a lot of copying for nothing. Two knobs fix it. needs: names the specific jobs whose artifacts you want, and as a bonus it lets the job start the moment those jobs finish instead of waiting on the whole stage. dependencies: [] downloads nothing at all. Those two are what stop a forty-job pipeline from dragging gigabytes into every deploy.
Cache: reuse what you can re-fetch
Cache looks like artifacts and solves the opposite problem. Artifacts move outputs forward inside one pipeline. Cache carries re-fetchable inputs sideways across pipelines to save time: npm's ~/.npm, Maven's ~/.m2, the pip download cache (Node, Java and Python package tooling, in that order). It is a pantry. You stock it so you do not drive to the shop every night, and the shop is still open if the pantry is bare. That difference is a contract. An artifact is guaranteed to be waiting for the next stage. A cache is best effort. The runner may hand you a fresh cache, a stale one, an empty one, or none at all, because you landed on a different machine in an autoscaled fleet or the key was evicted. So every job has to succeed from a cold cache. That means npm ci, not a script that assumes node_modules/ is already sitting there. Key it on purpose: cache:key:files hashes one or more lockfiles (the files that pin your exact dependency versions) and reuses the cache only while those files are unchanged, so bumping a dependency invalidates it for you. policy: pull lets a job read the cache without writing a new one back, which is right for jobs that consume dependencies and never change them.
.node-cache: &node-cachekey:files:- package-lock.json # SHA in the key changes only when deps changeprefix: npm # readable label ahead of the file hashpaths:- .npm/ # npm's download cache — NOT node_modules/build:stage: buildimage: node:22-alpinecache:<<: *node-cachepolicy: pull-push # read cache, then write updates backscript:- npm ci --cache .npm --prefer-offline- npm run buildartifacts:paths: [dist/]expire_in: 1 weektest:stage: testimage: node:22-alpinecache:<<: *node-cachepolicy: pull # consume only; never rewritescript:- npm ci --cache .npm --prefer-offline- npm test -- --reporters=jest-junit
Cache the download folder, not node_modules/. npm ci deletes node_modules on every run and rebuilds it from scratch, so a cached node_modules/ gets wiped before it can help you. Point the cache at .npm instead, and pass --cache .npm --prefer-offline so npm installs from files already on disk rather than over the network. The runner prints a cache hit or miss at the top of the log. A miss is not a failure. npm ci falls back to the public registry, refills .npm from scratch, and under pull-push writes the cache back for the next run.
Restoring cacheChecking cache for npm-feef9576d21ee9b6a32e30c5c79d0a0ceb68d1e5-non_protected...Downloading cache from https://storage.googleapis.com/gitlab-com-runners-cache/...Successfully extracted cache$ npm ci --cache .npm --prefer-offlineadded 1214 packages, and audited 1215 packages in 6s # served from local .npm cache, no network fetch
rules: decide whether a job runs
rules is how one .gitlab-ci.yml serves feature branches, merge requests and production without three copies of every job. Read it like a bouncer working down a guest list. The job checks each rules entry from the top and stops at the first if that matches. That entry's when (on_success, manual, delayed or never) settles what happens to the job. If nothing on the list matches, the job is dropped from the pipeline and never appears at all. rules replaced the older only/except keywords, which could not express these conditions cleanly and are now discouraged. Two forms cover most of what you need. rules:if tests pipeline variables such as $CI_PIPELINE_SOURCE (what started this pipeline, a merge request or a plain push?) and $CI_COMMIT_BRANCH (which branch is this?). rules:changes runs a job only when specific paths changed, so a slow container build sits out a commit that touched only docs. Since the first match wins, write your rules most specific first and most general last. And keep real deploys behind when: manual so a person has to click the button.
container-build:stage: buildimage: docker:27services: [docker:27-dind]rules:- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'changes:paths:- Dockerfile- src/**/* # rebuild only when code or image changed- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'script:- docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" .deploy-prod:stage: deployscript: [./deploy.sh production]rules:- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'when: manual # only on the default branch, only on a click- when: never # otherwise this job does not exist
Artifacts, cache and rules also decide who gets to touch what. The dist/ that deploy unpacks is only as trustworthy as the job that packed it. A compromised build job can swap dist/ for something malicious, and nothing in this chain checks the handoff. rules pick which untrusted events are allowed to start a job on your runners in the first place. That is the seam the next lesson pulls at: mapping the whole pipeline the way an attacker would, looking for where inputs get poisoned, where the runner gets abused, and where an artifact gets tampered with in the gap between build and deploy.
Try this
Work through “rules: decide whether a job runs” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.
Takeaway
The trap worth remembering here: expire_in does not always expire. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.