GitLab CI pipelines
The .gitlab-ci.yml structure, stages, jobs and rules, includes and templates, runners and tags, caches and artefacts, and environments with manual deploys.
Stages, jobs and rules
# .gitlab-ci.yml
default:
image: node:22
cache:
key:
files: [package-lock.json]
paths: [.npm/]
before_script:
- npm ci --cache .npm --prefer-offline
stages: [build, test, deploy]
variables:
DOCKER_DRIVER: overlay2
GIT_DEPTH: "20"
build:
stage: build
script:
- npm run build
artifacts:
paths: [dist/]
expire_in: 1 week
unit:
stage: test
needs: [build]
script:
- npm run test:unit -- --reporter=junit --output=reports/junit.xml
artifacts:
when: always
reports:
junit: reports/junit.xml
paths: [coverage/]
lint:
stage: test
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- npm run lintrulesreplaces the olderonlyandexceptkeywords and is evaluated in order — the first matching rule decides.needscreates a directed acyclic graph so a job can start as soon as its dependencies finish rather than waiting for a whole stage.- Artefacts are per-job outputs that downstream jobs download; caches are a speed optimisation and must never be required for correctness.
- The built-in
CI_variables carry the pipeline source, commit and merge request context, which is what rules should branch on.
Runners, tags and includes
# route a job to a runner with the right capability
docker-build:
stage: build
tags: [docker, linux, privileged]
script:
- docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" .
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
# DRY the pipeline with includes and a template
include:
- local: '.gitlab/ci/tests.yml'
- project: 'platform/ci-templates'
ref: v3.2.0
file: '/templates/container-build.yml'
- template: Security/SAST.gitlab-ci.yml
# generate jobs dynamically from a script
generate:
stage: build
script:
- ./ci/generate-jobs.sh > generated.yml
artifacts:
paths: [generated.yml]
dynamic:
stage: test
trigger:
include:
- artifact: generated.yml
job: generate
strategy: depend| Concept | Scope | Note |
|---|---|---|
cache | Per runner, best effort | Key it on a lockfile hash |
artifacts | Uploaded, downloadable | Set expire_in or you pay for storage |
needs | Within a pipeline | Creates a DAG, skipping stage order |
environment | A named deployment target | Enables tracking and rollback in the UI |
extends | One config into another | Deep merges, unlike YAML anchors |
Environments and manual deploys
deploy:staging:
stage: deploy
environment:
name: staging
url: https://staging.example.com
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- ./deploy.sh staging "$CI_COMMIT_SHORT_SHA"
deploy:production:
stage: deploy
environment:
name: production
url: https://example.com
when: manual
allow_failure: false
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
script:
- ./deploy.sh production "$CI_COMMIT_BRANCH-$CI_COMMIT_SHORT_SHA"
rollback:
stage: deploy
environment:
name: production
action: stop
when: manual
script:
- ./deploy.sh production "$CI_ENVIRONMENT_NAME" --previous⚠️
A protected environment that only maintainers may deploy to is the actual control; a manual job anyone can press is not. Configure protected environments and protected branches together, or the gate is decorative.
FAQ
rules or workflow rules?
Use
rules on a job to decide whether it runs, and workflow:rules to decide whether the whole pipeline is created. Without workflow rules you get duplicate pipelines for a merge request and its branch.What causes a job to be stuck waiting?
Either no runner matches the tags, or the runner is offline, or the job needs a resource from a previous stage that failed. The pipeline graph view shows which of the three it is.
Related
Triggers, concurrency and matrix builds Testing strategy inside the pipeline
Last refreshed 2026-09-18.