CoursesSecure CI/CD with GitLabArtifacts, cache & rules

Artifacts, cache & rules

Pass files, speed builds, run jobs conditionally.

Intermediate14 min · lesson 3 of 17

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.

.gitlab-ci.yml
stages: [build, test, deploy]
build:
stage: build
image: node:22-alpine
script:
- npm ci
- npm run build # emits dist/
artifacts:
paths:
- dist/
expire_in: 1 week
test:
stage: test
image: node:22-alpine
script:
- npm ci
- npm test -- --reporters=jest-junit
artifacts:
when: always # upload the report even if tests fail
reports:
junit: junit.xml
expire_in: 3 days
deploy:
stage: deploy
image: alpine:3.20
script:
- ./deploy.sh dist/ # dist/ restored automatically from build
rules:
- 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.

Job log — build
$ npm run build
> vite build
✓ built in 4.21s
Uploading artifacts for successful job
Uploading artifacts...
dist/: found 42 matching artifact files and directories
Uploading artifacts as "archive" to coordinator... 201 Created id=8471120933 responseStatus=201 Created
Cleaning up project directory and file based variables
Job 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.

Job log — deploy
Preparing environment
Getting source from Git repository
Downloading artifacts
Downloading artifacts for build (8471120933)...
Downloading artifacts from coordinator... ok host=gitlab.com id=8471120933 responseStatus=200 OK
Downloading 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.

.gitlab-ci.yml
.node-cache: &node-cache
key:
files:
- package-lock.json # SHA in the key changes only when deps change
prefix: npm # readable label ahead of the file hash
paths:
- .npm/ # npm's download cache — NOT node_modules/
build:
stage: build
image: node:22-alpine
cache:
<<: *node-cache
policy: pull-push # read cache, then write updates back
script:
- npm ci --cache .npm --prefer-offline
- npm run build
artifacts:
paths: [dist/]
expire_in: 1 week
test:
stage: test
image: node:22-alpine
cache:
<<: *node-cache
policy: pull # consume only; never rewrite
script:
- 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.

Job log — cache
Restoring cache
Checking 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-offline
added 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.

.gitlab-ci.yml
container-build:
stage: build
image: docker:27
services: [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: deploy
script: [./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
How an artifact travels between jobs
1build job runs
compiles source → dist/ in the job container
2artifacts:paths uploads
runner zips dist/, sends it to the coordinator
3GitLab coordinator stores it
tied to this pipeline, kept until expire_in
4deploy job starts cold
fresh container, clean checkout, no dist/ yet
5artifacts auto-restored
prior-stage artifacts downloaded before script
6deploy uses dist/
exact bytes build produced, a handoff you can verify
Cache would travel a parallel path with no delivery guarantee, which is why outputs move as artifacts and never as cache.
expire_in does not always expire
By default GitLab holds on to the artifacts of the most recent successful pipeline on every ref (every branch or tag), and that behaviour, the project's 'Keep artifacts from most recent successful jobs' setting, beats expire_in. A token accidentally written into an artifacted path on your default branch can therefore sit there indefinitely with expire_in: 1h on the job, downloadable by anyone with Reporter access, a low rung on the permission ladder. Scope artifacts:paths to exactly your build output. Never artifact a .env or a credential file. Turn the keep-latest setting off on repositories that build sensitive output. Fork merge-request pipelines sharpen all of this: contributor code you have not read runs on your runner, and it can read anything it artifacts or caches.
Quick check
01A deploy job fails with 'dist/: No such file or directory' about one run in five. The build output is handed to deploy through cache:, not artifacts:. What is actually wrong?
Correct — A different runner or an evicted key hands the job an empty cache without raising an error, so a required file disappears at random. Pass outputs forward as artifacts.
Incorrect — expire_in belongs to artifacts, not cache, and it never removes files while a pipeline is running.
Incorrect — Two jobs in one stage would break ordering on every run, not one in five. The tell here is that a cache was used to carry a required file.
Incorrect — Possible on a bad day, but the fact that decides it is a non-guaranteed cache carrying a required output. That is the bug.
02A job has several rules entries. How does GitLab work out whether the job runs?
Incorrect — No. rules is not an any-match OR. Evaluation stops at the first entry that matches.
Correct — First match wins, and a job with no matching entry is left out of the pipeline entirely.
Incorrect — Backwards. The first match wins, which is why specific conditions go first and general ones last.
Incorrect — No. Entries are not ANDed together. A single matching entry decides the outcome.
03A job caches node_modules/ directly and runs npm ci, but node_modules/ is empty at the start of every run and the install never gets faster. Why, and what fixes it?
Incorrect — The key is not the problem. npm ci wipes node_modules on every run however you key the cache.
Incorrect — Even on pull-push the cached node_modules would be deleted by npm ci. The policy is not the cause.
Correct — Cache the re-fetchable download folder rather than the built tree, so npm installs from disk instead of over the network.
Incorrect — expire_in is an artifacts field, not a cache field, and it never deletes files during a running pipeline.

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.

Related