This episode covers how to store build outputs with artifacts (paths, name, expire_in, when), transfer them between jobs and stages, limit transfers with dependencies, download them via the UI and API, then publish packages to the GitLab Package Registry.

In episode 6 we built Docker images inside the pipeline and pushed them to the GitLab Container Registry — a perfect example of how a job's output is stored for other jobs to use. But images aren't the only output that needs to move around. Application binaries, zip archives, test reports, coverage reports — all of these are produced in one job and needed in another. Without management, every job is forced to rebuild from scratch, and your pipeline becomes slow and wasteful.
In this episode we'll dissect artifacts — GitLab's mechanism for storing and transferring job outputs — then connect them with the GitLab Package Registry, where you publish packages that can be consumed across projects, even across teams. This is the episode that bridges "build outputs" with "consuming build outputs".
artifactsAny finished job can hand over certain files or directories for GitLab to store. When the next job starts, those files are automatically downloaded and available in its workspace. Let's look at the most common example:
build_app:
stage: build
image: node:20-alpine
script:
- npm ci
- npm run build
artifacts:
paths:
- dist/
- coverage/
name: "$CI_COMMIT_REF_NAME"
expire_in: 1 week
when: on_successFour key keywords to understand:
paths — the list of files/directories to store. Here dist/ (build output) and coverage/ (reports) are stored.name — the archive name when downloaded via the UI. $CI_COMMIT_REF_NAME makes the name contain the branch name, so each branch's archive isn't overwritten.expire_in: 1 week — the artifact's lifetime. GitLab deletes it automatically after a week. This value matters for saving storage; old artifacts are rarely used.when — when the artifact is stored. on_success (default) only when the job succeeds. Other options: on_failure (useful for keeping logs/debug output on failure) and always (stored whatever the outcome).Tip
Use when: on_failure on test jobs to store screenshots or failed logs — this is the fastest way to find the cause of a failure without rerunning the pipeline. The combination of when: always and a short expire_in is also popular for reports that must always be available.
The GitLab workflow pattern: artifacts from the previous stage are automatically transferred to all jobs in the next stage. If build_app in the build stage produces dist/, then all jobs in the deploy stage can read dist/ directly without extra configuration.
The problem: "all jobs receive all artifacts" can be wasteful — especially when an artifact is hundreds of MB and a deploy job only needs part of it. That's where dependencies: comes in: it limits which artifacts a job receives.
deploy_prod:
stage: deploy
image: alpine:latest
dependencies:
- build_app
script:
- ls dist/
- rsync -av dist/ deploy@server:/var/www/myappWith dependencies: [build_app], the deploy_prod job only receives artifacts from the job named build_app — not from other jobs in the build stage. Also note: dependencies only controls which artifacts are received, not execution order. Job order is still governed by stages (or needs:, which we'll cover in episode 9).
Warning
Be careful when writing the dependencies list. If the job name is wrong, or that job is skipped by rules so it produces no artifacts, the pipeline can fail. Always test with a push to a feature branch, not directly on main.
Besides being automatically transferred to the next job, artifacts can also be downloaded by humans. In the GitLab UI, open the job page → Browse tab to inspect the file structure, or click Download to fetch the archive — handy for grabbing the latest build without checking out code.
Through the API, other jobs (or external scripts) can download artifacts with an endpoint that accepts an access token:
curl --header "PRIVATE-TOKEN: $API_TOKEN" \
"$CI_API_V4_URL/projects/$CI_PROJECT_ID/jobs/$CI_JOB_ID/artifacts/download" \
--output build.zipThe UI + API combination makes artifacts the bridge between pipeline and people: QA grabs the latest build without manual builds, automation bots download test results for further processing, and release teams pull archives for archiving.
Artifacts are an internal pipeline mechanism. But sometimes you want to publish packages so other projects — or even teams outside the same repo — can use them. GitLab provides a built-in Package Registry supporting many ecosystems in one place:
| Ecosystem | Package manager | GitLab endpoint |
|---|---|---|
| Node.js | npm / yarn | /api/v4/projects/ID/packages/npm/ |
| Java | Maven / Gradle | /api/v4/projects/ID/packages/maven/ |
| Python | pip / twine | /api/v4/projects/ID/packages/pypi/ |
| Go | go proxy | /api/v4/projects/ID/packages/go/ |
| Helm | helm repo | /api/v4/projects/ID/packages/helm/ |
The publishing pattern is uniform: configure the registry to the project URL, authenticate with CI_JOB_TOKEN, then push with each ecosystem's package tool. Example for npm:
publish_npm:
stage: deploy
image: node:20-alpine
script:
- echo "//${CI_SERVER_HOST}/api/v4/projects/${CI_PROJECT_ID}/packages/npm/:_authToken=${CI_JOB_TOKEN}" > .npmrc
- npm ci
- npm publish --registry "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/npm/"
rules:
- if: $CI_COMMIT_TAGLet's break it down: the .npmrc line writes an auth token built from CI_JOB_TOKEN — a temporary token GitLab automatically injects into every job, so no permanent secret needs to be stored. rules: if: $CI_COMMIT_TAG ensures the package is only published when a release tag exists — a common pattern so package versions stay in sync with release versions. The package name and version are taken from the project's package.json, and GitLab validates them against the project name at publish time.
For Python (PyPI), the pattern is similar — configure twine then push:
pip install twine
python -m twine upload \
--repository-url "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/pypi" \
-u gitlab-ci-token -p "${CI_JOB_TOKEN}" dist/*Notice two consistent things across all ecosystems: the registry URL always contains $CI_API_V4_URL + the project ID, and authentication always goes through $CI_JOB_TOKEN with the username gitlab-ci-token (specific to PyPI). Once published, packages appear on the project's Packages & Registries page and can be installed from other projects — the foundation for sharing internal libraries across teams without publishing them to the internet.
Note
Some package managers need extra configuration to be able to download packages from GitLab, not just upload — for example npm needs a @scope:registry scope in .npmrc, and Go needs a GOPROXY setting. The details are in each ecosystem's Package Registry documentation; what matters here is understanding the pattern: job token, API v4 URL, and project ID.
In this episode 7, you've mastered the "store and move build outputs" flow:
artifacts: paths: stores job outputs; name, expire_in, and when control naming, lifetime, and when they're stored.dependencies: [job] limits this so only needed artifacts are downloaded.Here's what you should realize: artifacts store final results, but every job still has to download dependencies (node_modules, Maven packages) from scratch — and that's the slowest part of a pipeline. In episode 8 we'll cover caching: how to store temporary dependencies so the next build is much faster, when to use cache instead of artifacts, and how to connect runners to object storage for cache shared across runners. See you in episode 8!