Steven's Knowledge

GitLab CI

GitLab CI in depth - pipeline syntax, stages vs needs (DAG), runners, parent/child pipelines, OIDC

GitLab CI

GitLab CI is GitLab's built-in CI/CD — a single .gitlab-ci.yml at the repo root defines the whole pipeline. The model is stage-based by default with DAG (needs:) when you want parallelism, distinct from GitHub Actions' workflow-per-file approach.

Anatomy of a Pipeline

# .gitlab-ci.yml
stages: [lint, test, build, deploy]          # ordered phases

default:                                     # defaults for all jobs
  image: node:20-alpine
  cache:
    key:
      files: [package-lock.json]
    paths: [node_modules/, .npm/]

variables:
  NODE_ENV: test

lint:
  stage: lint
  script:
    - npm ci --cache .npm --prefer-offline
    - npm run lint

test:
  stage: test
  script:
    - npm ci --cache .npm --prefer-offline
    - npm test

build:
  stage: build
  image: docker:24
  services: [docker:24-dind]
  script:
    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
    - docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" .
    - docker push  "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
ConceptDescription
PipelineAll jobs triggered by one event
StageA named phase; stages run sequentially
JobA unit of work; jobs in the same stage run in parallel
ScriptShell commands the job runs
RunnerServer running the job
CacheFiles preserved across runs
ArtifactFiles passed to later jobs / downloadable

Stages vs DAG

By default, all jobs in stage N wait for all jobs in stage N-1. That's often slower than necessary. needs: builds a DAG:

unit-tests:
  stage: test
  script: [npm test:unit]

integration-tests:
  stage: test
  script: [npm test:integration]

build:
  stage: build
  needs: [unit-tests]                        # start as soon as unit-tests pass — don't wait for integration
  script: [docker build ...]

deploy:
  stage: deploy
  needs: [build, integration-tests]          # both must pass
  script: [./deploy.sh]

needs: skips the stage barrier — build starts the moment unit-tests is done. For complex pipelines this can cut total runtime in half.

Rules: When a Job Runs

rules: replaces the older only: / except:. The pattern is "first matching rule wins":

deploy-staging:
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      when: never                                          # skip in MR pipelines
    - if: '$CI_COMMIT_BRANCH == "main"'
      when: on_success                                     # run on main if previous stages pass
    - when: never                                          # default fallback

deploy-production:
  rules:
    - if: '$CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+$/'  # only on semver tags
      when: manual                                         # require a human click
      allow_failure: false

when: manual puts a play button in the UI — a controlled gate before destructive actions.

Environments

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

deploy-production:
  stage: deploy
  environment:
    name: production
    url: https://api.example.com
    deployment_tier: production
  script: [./deploy.sh production]
  rules:
    - if: '$CI_COMMIT_TAG'
      when: manual

Environments give you:

  • Per-environment deployment history in the UI.
  • Rollback to a previous deployment with one click.
  • Stop jobs that explicitly tear down (on_stop:) ephemeral environments.

Review Apps (Ephemeral Preview Environments)

deploy-review:
  stage: review
  script: [./deploy.sh "review-$CI_MERGE_REQUEST_IID"]
  environment:
    name: review/$CI_MERGE_REQUEST_IID
    url: https://review-$CI_MERGE_REQUEST_IID.example.com
    on_stop: stop-review
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

stop-review:
  stage: review
  script: [./teardown.sh "review-$CI_MERGE_REQUEST_IID"]
  environment:
    name: review/$CI_MERGE_REQUEST_IID
    action: stop
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      when: manual

Each MR gets its own ephemeral environment with a unique URL. Closing the MR triggers the stop job.

Parent/Child and Multi-Project Pipelines

For monorepos: a parent pipeline triggers child pipelines for each changed subdirectory:

# .gitlab-ci.yml (parent)
service-a:
  trigger:
    include: services/a/.gitlab-ci.yml
    strategy: depend                           # parent waits for child to finish
  rules:
    - changes: [services/a/**/*]

service-b:
  trigger:
    include: services/b/.gitlab-ci.yml
    strategy: depend
  rules:
    - changes: [services/b/**/*]

For cross-project — kick off a downstream project's pipeline after a release:

deploy:
  trigger:
    project: myorg/deployer
    branch: main
    strategy: depend
  variables:
    UPSTREAM_VERSION: $CI_COMMIT_TAG

Includes

Split a large .gitlab-ci.yml or share across repos:

include:
  - local: .gitlab/ci/jobs.yml                 # local file
  - project: 'myorg/ci-templates'
    ref: v2.1.0
    file: '/templates/docker.yml'              # shared central template
  - template: 'Security/SAST.gitlab-ci.yml'    # GitLab-provided template
  - remote: 'https://example.com/ci.yml'       # remote URL

Shared templates are the way to standardize across many repos.

Runners

TypeNotes
GitLab.com shared runnersFree tier; for OSS or small projects
Group/project runnersSelf-managed; attach to specific scopes
Auto-scaling runners on K8sMost production: GitLab Runner deployed via Helm
Docker Machine runnersOlder auto-scale model; deprecated for K8s

Runner selection by tags:

build-large:
  tags: [docker, large-memory]
  script: [./big-build.sh]

Common runner topology: small "default" runners for most jobs, plus a few large-memory runners with a large-memory tag.

OIDC: Short-Lived Cloud Credentials

GitLab CI supports OIDC the same way GitHub Actions does:

deploy:
  id_tokens:
    AWS_TOKEN:
      aud: sts.amazonaws.com
    VAULT_TOKEN:
      aud: vault.example.com
  script:
    - aws sts assume-role-with-web-identity \
        --role-arn "$DEPLOY_ROLE_ARN" \
        --web-identity-token "$AWS_TOKEN" \
        --role-session-name gitlab-ci
    - aws s3 cp dist/ s3://bucket/ --recursive

The AWS IAM role's trust policy restricts which project / branch / ref can assume it — same security model as GHA.

For Vault:

deploy:
  id_tokens:
    VAULT_ID_TOKEN:
      aud: https://vault.example.com
  script:
    - export VAULT_TOKEN=$(vault write -field=token \
        auth/jwt/login role=gitlab jwt="$VAULT_ID_TOKEN")
    - vault read -field=value secret/data/deploy/api_key

Caching and Artifacts

CacheArtifact
PurposeSpeed up later runs (dep downloads, build outputs)Pass files to later jobs / for humans
PersistencePer-runner, opportunisticStored centrally, attached to the job
LifecycleBest-effortExplicit expiration
build:
  cache:
    key: ${CI_COMMIT_REF_SLUG}                # per-branch cache
    paths: [node_modules/, .next/cache/]
  artifacts:
    paths: [dist/]
    expire_in: 1 week
    reports:
      junit: junit.xml                        # parsed by GitLab — test report tab
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura.xml

Test reports and coverage are first-class — they show up in the MR UI without extra plugins.

Useful Built-Ins

# Auto-cancel redundant pipelines
workflow:
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
    - if: '$CI_COMMIT_TAG'
  auto_cancel:
    on_new_commit: interruptible

# Mark jobs that can be safely killed
job:
  interruptible: true
  script: [...]

# Retry on transient failures
job:
  retry:
    max: 2
    when: [runner_system_failure, stuck_or_timeout_failure]

workflow.rules controls whether the pipeline runs at all; rules on a job controls whether that job runs.

Patterns

Manual deploy from the UI

deploy-production:
  stage: deploy
  script: [./deploy.sh]
  environment: production
  when: manual
  allow_failure: false
  rules:
    - if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/'

Pipeline matrix

test:
  parallel:
    matrix:
      - NODE_VERSION: ['18', '20', '22']
        OS: ['alpine', 'debian']
  image: node:${NODE_VERSION}-${OS}
  script: [npm ci && npm test]

Conditional job inclusion

include:
  - local: .gitlab/ci/frontend.yml
    rules:
      - changes: [frontend/**/*]
  - local: .gitlab/ci/backend.yml
    rules:
      - changes: [backend/**/*]

Only the relevant CI files are loaded based on what changed.

What's Next

You know the GitLab CI model in depth. Compare with GitHub Actions, and then read Best Practices for cross-platform principles — speed, security, pipeline design.

On this page