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 lint
  • rules replaces the older only and except keywords and is evaluated in order — the first matching rule decides.
  • needs creates 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
ConceptScopeNote
cachePer runner, best effortKey it on a lockfile hash
artifactsUploaded, downloadableSet expire_in or you pay for storage
needsWithin a pipelineCreates a DAG, skipping stage order
environmentA named deployment targetEnables tracking and rollback in the UI
extendsOne config into anotherDeep 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.

Triggers, concurrency and matrix builds Testing strategy inside the pipeline

Last refreshed 2026-09-18.