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'| Concept | Description |
|---|---|
| Pipeline | All jobs triggered by one event |
| Stage | A named phase; stages run sequentially |
| Job | A unit of work; jobs in the same stage run in parallel |
| Script | Shell commands the job runs |
| Runner | Server running the job |
| Cache | Files preserved across runs |
| Artifact | Files 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: falsewhen: 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: manualEnvironments 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: manualEach 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_TAGIncludes
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 URLShared templates are the way to standardize across many repos.
Runners
| Type | Notes |
|---|---|
| GitLab.com shared runners | Free tier; for OSS or small projects |
| Group/project runners | Self-managed; attach to specific scopes |
| Auto-scaling runners on K8s | Most production: GitLab Runner deployed via Helm |
| Docker Machine runners | Older 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/ --recursiveThe 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_keyCaching and Artifacts
| Cache | Artifact | |
|---|---|---|
| Purpose | Speed up later runs (dep downloads, build outputs) | Pass files to later jobs / for humans |
| Persistence | Per-runner, opportunistic | Stored centrally, attached to the job |
| Lifecycle | Best-effort | Explicit 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.xmlTest 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.