GitLab CI#

GitLab CI/CD is GitLab’s built-in continuous integration and delivery system. Configuration lives in a .gitlab-ci.yml file at the repository root, defining pipelines as code that execute on every push, merge request, or schedule.


.gitlab-ci.yml Structure#

A minimal pipeline:

stages:
  - build
  - test
  - deploy

build-app:
  stage: build
  image: node:20-alpine
  script:
    - npm ci
    - npm run build
  artifacts:
    paths:
      - dist/

test-app:
  stage: test
  image: node:20-alpine
  script:
    - npm ci
    - npm test
  coverage: '/Lines\s*:\s*(\d+\.?\d*)%/'

deploy-staging:
  stage: deploy
  script:
    - deploy-to-staging.sh
  environment:
    name: staging
    url: https://staging.example.com
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

Top-Level Keywords#

KeywordPurposeExample
stagesDefine pipeline stages and their order[lint, test, build, deploy]
variablesGlobal environment variablesNODE_ENV: production
defaultDefault values for all jobsimage, before_script, tags
includeImport external CI configurationTemplates, shared pipelines
workflowControl when entire pipeline runsFilter by branch, variable

Stages, Jobs, and Artifacts#

Stages#

Stages run sequentially. All jobs within a stage run in parallel by default:

graph LR
    subgraph "Stage: lint"
        A[eslint]
        B[prettier]
    end
    subgraph "Stage: test"
        C[unit-tests]
        D[integration-tests]
    end
    subgraph "Stage: build"
        E[docker-build]
    end
    
    A --> C
    B --> C
    A --> D
    B --> D
    C --> E
    D --> E

Jobs#

Jobs are the fundamental unit of execution:

job-name:
  stage: test                    # Which stage this belongs to
  image: python:3.12-slim       # Docker image to run in
  tags: [docker]                # Runner selection
  before_script:                # Setup commands
    - pip install -r requirements.txt
  script:                       # Main commands (required)
    - pytest tests/
  after_script:                 # Cleanup (runs even on failure)
    - echo "Tests complete"
  allow_failure: false          # Whether failure blocks pipeline
  timeout: 10m                  # Maximum execution time
  retry: 2                      # Retry on failure

Artifacts#

Artifacts pass files between stages or make them available for download:

build:
  stage: build
  script:
    - npm run build
  artifacts:
    paths:
      - dist/
      - coverage/
    reports:
      junit: test-results.xml
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml
    expire_in: 1 week
    when: always  # Upload even if job fails
Artifact TypePurpose
pathsFiles passed between stages
reports:junitTest results shown in MR UI
reports:coverage_reportCoverage visualisation in MR diff
reports:sastSecurity scan results
reports:dependency_scanningVulnerable dependency report

Variables#

Variable Hierarchy (precedence, highest first)#

LevelSet WhereExample
Job variablesIn job definitionvariables: { DEBUG: "true" }
Pipeline trigger variablesAPI or trigger-F variables[KEY]=value
Project CI/CD settingsGitLab UI → Settings → CI/CDSecrets, tokens
Group CI/CD settingsGroup → Settings → CI/CDShared across projects
Instance-level variablesAdmin → CI/CDOrganization-wide
.gitlab-ci.yml globalTop of YAML filevariables: { NODE_ENV: "prod" }

Predefined Variables#

VariableValue
CI_COMMIT_SHAFull commit hash
CI_COMMIT_SHORT_SHAFirst 8 characters of hash
CI_COMMIT_BRANCHBranch name (empty for tags)
CI_COMMIT_TAGTag name (empty for branches)
CI_PIPELINE_IDUnique pipeline identifier
CI_JOB_NAMECurrent job name
CI_MERGE_REQUEST_IIDMR number (only in MR pipelines)
CI_PROJECT_DIRRepository checkout directory
CI_REGISTRY_IMAGEProject’s container registry path

Variable Types#

variables:
  # Plain text
  APP_VERSION: "1.2.3"
  
  # Expanded from another variable
  IMAGE_TAG: "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
  
  # Protected (only available on protected branches/tags)
  # Set in UI with "Protected" checkbox
  
  # Masked (hidden in job logs)
  # Set in UI with "Masked" checkbox

Rules, Only/Except#

rules is the modern, flexible way to control when jobs run:

deploy-production:
  stage: deploy
  script: ./deploy.sh
  rules:
    # Run on main branch, manual trigger
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual
      allow_failure: false
    
    # Run on tags automatically
    - if: $CI_COMMIT_TAG
      when: on_success
    
    # Run on MR if deploy label exists
    - if: $CI_MERGE_REQUEST_LABELS =~ /deploy/
      when: manual
    
    # Never run otherwise
    - when: never

# Only run if specific files changed
test-frontend:
  rules:
    - changes:
        - "frontend/**/*"
        - "package.json"

Only/Except (Legacy)#

# Legacy syntax — prefer rules: instead
deploy:
  only:
    - main
    - tags
  except:
    - schedules
rules keywordMeaning
ifEvaluate CI/CD variable expression
changesRun if specified files changed
existsRun if file exists in repo
whenon_success, manual, always, never, delayed
allow_failureWhether failure blocks the pipeline

Includes and Templates#

Include Types#

include:
  # File from same repository
  - local: '/templates/.lint.yml'
  
  # File from another project
  - project: 'devops/ci-templates'
    ref: 'v2.0.0'
    file: '/templates/docker-build.yml'
  
  # Remote URL
  - remote: 'https://example.com/ci-template.yml'
  
  # GitLab-provided template
  - template: 'Security/SAST.gitlab-ci.yml'

Reusable Job Templates#

# Define a template (prefixed with dot = hidden job)
.deploy-template:
  image: alpine:3.19
  before_script:
    - apk add --no-cache curl
  script:
    - curl -X POST "$DEPLOY_URL" -d "version=$CI_COMMIT_SHA"

# Extend the template
deploy-staging:
  extends: .deploy-template
  variables:
    DEPLOY_URL: "https://staging.example.com/deploy"
  environment: staging

deploy-production:
  extends: .deploy-template
  variables:
    DEPLOY_URL: "https://prod.example.com/deploy"
  environment: production
  rules:
    - if: $CI_COMMIT_TAG

Services#

Services are Docker containers that run alongside the job (databases, caches, etc.):

test-with-database:
  stage: test
  image: python:3.12-slim
  services:
    - name: postgres:16-alpine
      alias: db
      variables:
        POSTGRES_DB: test_db
        POSTGRES_USER: test
        POSTGRES_PASSWORD: test
    - name: redis:7-alpine
      alias: cache
  variables:
    DATABASE_URL: "postgresql://test:test@db:5432/test_db"
    REDIS_URL: "redis://cache:6379"
  script:
    - pip install -r requirements.txt
    - pytest tests/integration/
Service FeatureDescription
nameDocker image to run
aliasHostname for the service (network alias)
variablesEnvironment variables for the service container
commandOverride the default container command
entrypointOverride the container entrypoint

Runners#

Runners are agents that execute CI/CD jobs:

TypeWhere It RunsBest For
Shared runnersGitLab-managed infrastructureGeneral-purpose, no setup needed
Group runnersSelf-hosted, available to groupTeam-specific needs, custom hardware
Project runnersSelf-hosted, single projectSecurity-sensitive, special requirements

Runner Tags#

# Job will only run on runners with these tags
build-docker:
  tags:
    - docker
    - linux
    - amd64

build-ios:
  tags:
    - macos
    - xcode15

Runner Executors#

ExecutorIsolationSpeedUse Case
DockerContainerFastStandard CI jobs
KubernetesPodMediumCloud-native, auto-scaling
ShellNone (bare metal)FastestSpecial hardware, GPU
Virtual MachineFull VMSlowestMaximum isolation

Caching#

# Global cache configuration
cache:
  key:
    files:
      - Gemfile.lock
    prefix: $CI_COMMIT_REF_SLUG  # Per-branch cache
  paths:
    - vendor/ruby/
  policy: pull-push

# Job-specific cache override
test:
  cache:
    key: test-cache-$CI_COMMIT_REF_SLUG
    paths:
      - .pytest_cache/
    policy: pull  # Never update the cache

Cache Policies#

PolicyBehaviour
pull-pushDownload at start, upload at end (default)
pullDownload only — never update cache
pushUpload only — useful for cache-warming jobs

DAG Pipelines#

Directed Acyclic Graph (DAG) pipelines allow jobs to depend on specific jobs rather than entire stages:

stages:
  - build
  - test
  - deploy

build-frontend:
  stage: build
  script: npm run build
  artifacts:
    paths: [dist/]

build-backend:
  stage: build
  script: go build -o app

test-frontend:
  stage: test
  needs: [build-frontend]  # Only waits for frontend build
  script: npm test

test-backend:
  stage: test
  needs: [build-backend]   # Only waits for backend build
  script: go test ./...

deploy:
  stage: deploy
  needs: [test-frontend, test-backend]
  script: ./deploy.sh
graph LR
    A[build-frontend] --> C[test-frontend]
    B[build-backend] --> D[test-backend]
    C --> E[deploy]
    D --> E

needs vs Stage Dependencies#

FeatureStage-basedDAG (needs)
DependencyAll jobs in previous stageSpecific named jobs
ParallelismWithin a stage onlyAcross stages
SpeedWaits for slowest job in stageStarts ASAP
Artifact accessAll previous stage artifactsOnly from needs jobs

Key Takeaways#

  • .gitlab-ci.yml defines your entire pipeline as code — version it, review it, test it
  • Stages provide order; jobs provide parallelism within stages; DAG (needs) unlocks cross-stage parallelism
  • Use rules (not only/except) for conditional job execution
  • include and extends enable DRY pipeline code across projects
  • Services provide ephemeral dependencies (databases, caches) for integration testing
  • Caching speeds up repeated jobs; artifacts pass data between stages
  • Choose runner type and executor based on security, speed, and isolation requirements