Artifacts in GitLab CI: build once, reuse everywhere
Your deploy job rebuilds the app that another job just built. Caching dist/ seems to fix it, until it silently deploys stale code. Artifacts, expiry, failure reports, and passing computed values between jobs with dotenv.
- gitlab
- ci-cd
- devops
Your pipeline compiles the app in build-app. Then the deploy job starts, gets a clean checkout like every job does, finds no dist/, and compiles the whole thing again. Twice the build time for one pipeline, and it gets worse with every job that needs the output.
The tempting fix is the tool you already know: put dist/ in the cache. It will even appear to work. But a cache miss (and part 2 showed how many ways there are to miss) doesn’t fail the job. It silently gives the deploy job an empty or stale dist/, and the failure mode of “deployed last week’s build” is a much worse day than “the build ran twice.” Wrong tool. This post is about the right one.
Cache and artifacts solve different problems
The two mechanisms look interchangeable at first, and the distinction is worth spelling out:
- Cache is for work you’d rather not redo across pipelines: dependency installs, downloaded toolchains. It’s best-effort by design (a miss is slower, never wrong) and it lives wherever the runner’s cache backend puts it.
- Artifacts are the outputs of a job within one pipeline: the compiled app, the test report. They’re guaranteed to reach later jobs, they’re stored by GitLab itself, and you can browse and download them from the UI.
Rule of thumb: inputs you can regenerate → cache. Outputs other jobs consume → artifacts. The reason dist/-in-cache backfires is exactly the “best-effort” property: acceptable for inputs, unacceptable for the thing you’re about to ship.
Build once, reuse everywhere
Declaring artifacts is one block on the producing job:
build-app:
stage: build
script: pnpm build
artifacts:
paths:
- dist/
expire_in: 3 hoursJobs in later stages download the artifacts of every earlier job automatically, so a deploy job after build-app finds dist/ already in place and never runs a build of its own. That’s the build-once pattern, and it’s the single biggest cleanup in most pipelines: exactly one job compiles, everything downstream consumes.
Set expire_in consciously. Artifacts accumulate fast: every MR pipeline uploading a dist/ that lives for 30 days adds up to real storage. We run 3 hours for build outputs, since anything that needs them runs in the same pipeline minutes later. Reports we keep longer.
Artifacts you need most when the job fails
By default, artifacts upload only when the job succeeds. For build outputs that’s right. For test reports it’s backwards: the run you want to inspect is the one that failed:
test:
stage: quality
script: pnpm test
artifacts:
when: always
reports:
junit: junit.xmlThe reports:junit variant does double duty: GitLab parses it and annotates the merge request with exactly which tests failed, so nobody digs through raw logs to find the broken spec.
Passing values between jobs, not just files
Sometimes what a downstream job needs isn’t a directory but a couple of computed values: a version, a target, a URL. Writing them to a file and parsing it downstream works, but GitLab has a first-class mechanism, the dotenv report:
prepare-release:
stage: setup
script:
- echo "APP_VERSION=$(node -p "require('./package.json').version")" >> run.env
- echo "DEPLOY_TARGET=preview" >> run.env
artifacts:
reports:
dotenv: run.env
deploy-preview:
stage: deploy
script: ./scripts/deploy.sh "$DEPLOY_TARGET" "$APP_VERSION"Every variable in run.env becomes a plain environment variable in the jobs that follow. No sourcing, no parsing. We use this pattern to compute deploy metadata once and consume it in three different deploy jobs, and later in the series it carries E2E run statistics into a reporting stage.
Where this leaves you
One job builds, everything downstream consumes, failures ship their reports, and computed values travel as environment variables instead of ad-hoc files.
Notice, though, how the outputs move: along stage boundaries. The deploy job waits for the entire build stage even if it only consumes dist/ from one job in it. On a pipeline with a handful of jobs that’s invisible; as the file grows, the waiting becomes the slowest part. Part 4 replaces stage barriers with a real dependency graph (needs) and splits the slowest jobs across runners with parallel.