Skip to content

GitLab CI/CD Reference

GitLab CI/CD Reference

.gitlab-ci.yml patterns for builds, tests, Docker images, deployments, and the keywords that trip everyone up: rules vs only/except, needs vs dependencies, and caching.

Pipeline structure — stages, jobs, scripts
# .gitlab-ci.yml — top-level structure
stages:         # define order of execution
  - build
  - test
  - security
  - deploy

variables:       # global variables (available to all jobs)
  NODE_VERSION: "22"
  DOCKER_DRIVER: overlay2

default:         # defaults applied to all jobs
  image: node:22-alpine
  before_script:
    - npm ci --cache .npm --prefer-offline
  cache:
    key: "$CI_COMMIT_REF_SLUG"
    paths:
      - .npm/

# A job — minimum viable
build:
  stage: build
  script:
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 hour

# Multiple scripts — each line is a separate command
# If one fails, the job fails (like set -e)
test:
  stage: test
  script:
    - npm test
    - npm run lint
    - npm run type-check
  coverage: '/Lines\s*:\s*(\d+\.?\d*)%/'   # parse coverage from output
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml
      junit: test-results.xml
rules — when jobs run (modern approach)
# rules: replaces the deprecated only:/except: keywords
# Rules are evaluated top-to-bottom; first match wins

deploy_production:
  stage: deploy
  script: ./deploy.sh
  rules:
    - if: $CI_COMMIT_BRANCH == "main"          # only on main
      when: manual                              # requires click to run
    - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+/ # or on semver tags
      when: on_success                          # run automatically
    - when: never                              # otherwise don't run

# Common rule patterns
rules:
  # Run on MR pipelines only (not branch pipelines)
  - if: $CI_PIPELINE_SOURCE == "merge_request_event"

  # Skip if commit message contains [skip ci]
  - if: $CI_COMMIT_MESSAGE =~ /\[skip ci\]/
    when: never

  # Run on main + tags + manual trigger (covers most CI/CD cases)
  - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  - if: $CI_COMMIT_TAG
  - if: $CI_PIPELINE_SOURCE == "web"

  # Run only when specific files changed
  - changes:
      - src/**/*
      - package.json

# workflow: rules — control whether to create a pipeline at all
workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - if: $CI_COMMIT_TAG
    # This pattern: run on MRs and main/tags but NOT duplicate branch pipelines

Avoid only: and except: in new pipelines. rules: is more powerful, explicit, and handles edge cases better.

needs and dependencies — DAG pipelines
# By default: all jobs in a stage run in parallel
# stages: run sequentially (all jobs in stage N finish before stage N+1)

# needs: — run jobs out of stage order (DAG)
# Job runs as soon as its needs are met, not waiting for the whole stage

deploy_backend:
  stage: deploy
  needs:
    - build_backend    # run as soon as build_backend finishes
    - job: test_backend
      artifacts: false  # don't download artifacts from this job
  script: ./deploy-backend.sh

# needs with artifacts (default: true — downloads artifacts from needed jobs)
build_image:
  stage: build
  script: docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
  artifacts:
    paths: [dist/]

deploy:
  stage: deploy
  needs:
    - job: build_image   # also downloads dist/ artifacts
  script: ./deploy.sh

# dependencies: — which artifacts to download (without affecting run order)
# If not set: downloads artifacts from all previous-stage jobs
# Set to [] to download nothing
deploy:
  stage: deploy
  dependencies:
    - build         # only download from build job
  script: ./deploy.sh
Docker builds and registry
# Build and push to GitLab Container Registry
build_image:
  stage: build
  image: docker:26
  services:
    - docker:26-dind    # Docker-in-Docker
  variables:
    DOCKER_TLS_CERTDIR: "/certs"
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - docker build
        --cache-from $CI_REGISTRY_IMAGE:latest
        --build-arg BUILDKIT_INLINE_CACHE=1
        -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
        -t $CI_REGISTRY_IMAGE:latest
        .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
    - docker push $CI_REGISTRY_IMAGE:latest
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

# Using kaniko instead of DinD (no privileged mode needed)
build_image_kaniko:
  stage: build
  image:
    name: gcr.io/kaniko-project/executor:v1.23.0-debug
    entrypoint: [""]
  script:
    - /kaniko/executor
        --context "$CI_PROJECT_DIR"
        --dockerfile "$CI_PROJECT_DIR/Dockerfile"
        --destination "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
        --destination "$CI_REGISTRY_IMAGE:latest"
        --cache=true
        --cache-repo "$CI_REGISTRY_IMAGE/cache"

# Built-in CI variables for the registry:
# $CI_REGISTRY          = registry.gitlab.com
# $CI_REGISTRY_IMAGE    = registry.gitlab.com/group/project
# $CI_REGISTRY_USER     = gitlab-ci-token
# $CI_REGISTRY_PASSWORD = $CI_JOB_TOKEN (auto, no setup)
Caching and artifacts
# cache: — persist files between pipelines on the SAME runner (best effort)
# artifacts: — share files between jobs in the SAME pipeline (guaranteed)

# Cache: npm dependencies
cache:
  key:
    files:
      - package-lock.json   # invalidate when lockfile changes
  paths:
    - node_modules/
    - .npm/

# Cache by branch (separate cache per branch)
cache:
  key: "$CI_COMMIT_REF_SLUG"
  paths:
    - .gradle/caches/

# Pull cache but don't push (read-only — good for release branches)
cache:
  key: "$CI_COMMIT_REF_SLUG"
  paths:
    - .venv/
  policy: pull     # pull-push (default), pull, push

# Artifacts: always downloaded by subsequent-stage jobs (unless dependencies:[])
artifacts:
  paths:
    - dist/                    # directories + files
    - "*.log"                  # glob
  exclude:
    - dist/**/*.map            # exclude source maps
  expire_in: 1 week            # auto-cleanup
  when: always                 # upload even on failure (good for test reports)
  when: on_failure             # only upload if job failed (for crash dumps)
  reports:
    junit: test-results.xml
    dotenv: deploy.env         # exposes variables to downstream jobs
    sast: gl-sast-report.json

# Pass variables between jobs via dotenv artifact
build:
  script:
    - echo "IMAGE_TAG=$CI_COMMIT_SHA" >> deploy.env
  artifacts:
    reports:
      dotenv: deploy.env

deploy:
  needs: [build]
  script:
    - echo $IMAGE_TAG    # available from dotenv artifact
Environments and deployments
# environment: — tracks deployments in GitLab UI
deploy_staging:
  stage: deploy
  script: ./deploy.sh staging
  environment:
    name: staging
    url: https://staging.example.com

deploy_production:
  stage: deploy
  script: ./deploy.sh production
  environment:
    name: production
    url: https://example.com
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual    # require manual approval

# Dynamic environments (per MR)
review:
  stage: deploy
  script: ./deploy-review.sh $CI_COMMIT_REF_SLUG
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    url: https://$CI_COMMIT_REF_SLUG.example.com
    on_stop: stop_review    # auto-cleanup
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

stop_review:
  stage: deploy
  script: ./teardown-review.sh $CI_COMMIT_REF_SLUG
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    action: stop
  when: manual
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      when: manual
Useful CI variables and reusable templates
# Common predefined CI variables
$CI_COMMIT_SHA            # full commit SHA
$CI_COMMIT_SHORT_SHA      # first 8 chars
$CI_COMMIT_BRANCH         # branch name
$CI_COMMIT_TAG            # tag name (only in tag pipelines)
$CI_COMMIT_REF_SLUG       # branch/tag as slug (no special chars, lowercase)
$CI_DEFAULT_BRANCH        # default branch (usually main)
$CI_PROJECT_NAME          # project name
$CI_PROJECT_PATH          # group/project
$CI_PROJECT_DIR           # /builds/group/project (repo root)
$CI_REGISTRY_IMAGE        # registry.gitlab.com/group/project
$CI_PIPELINE_SOURCE       # push, merge_request_event, schedule, web, trigger
$CI_MERGE_REQUEST_IID     # MR number (only in MR pipelines)
$CI_ENVIRONMENT_NAME      # current environment name

# YAML anchors — reuse job definitions
.test_template: &test_template
  stage: test
  image: node:22
  before_script:
    - npm ci
  cache:
    paths: [node_modules/]

unit_tests:
  <<: *test_template          # merge the anchor
  script: npm run test:unit

integration_tests:
  <<: *test_template
  script: npm run test:integration
  services:
    - postgres:16

# !reference — GitLab's cross-job reference (better than anchors for scripts)
.setup:
  before_script: &setup_script
    - apt-get update && apt-get install -y curl
    - curl -fsSL https://get.docker.com | sh

build:
  before_script: !reference [.setup, before_script]
  script: docker build .

🔍 Free tool: GitHub Actions Security Checker — if you also run GitHub Actions alongside GitLab CI, check your workflow files for 8 security issues.

Founded

2023 in London, UK

Contact

hello@releaserun.com