GitLab CI
GitLab’s built-in CI/CD. Configuration in .gitlab-ci.yml. Tightly integrated with merge requests, container registry, environments, and security scanning. Different vocabulary than GitHub Actions but the same underlying concepts.
For GitHub Actions equivalents see 02_github_actions.md.
Anatomy
.gitlab-ci.yml
├── stages (declared order)
├── variables (global)
├── default (defaults for all jobs)
├── include (other yaml files / templates)
└── <job_name>
├── stage
├── image (Docker image to run in)
├── services (sidecar containers)
├── before_script / script / after_script
├── rules (when to run)
├── needs (DAG dependencies)
├── artifacts (files to keep / pass between jobs)
├── cache (dependency cache)
├── environment (deployment target)
└── tags (which runners can pick this up)
Minimal pipeline
# .gitlab-ci.yml
stages:
- test
- build
- deploy
variables:
PYTHON_VERSION: "3.12"
test:
stage: test
image: python:${PYTHON_VERSION}
script:
- pip install -r requirements.txt
- pytest
build:
stage: build
image: docker:latest
services:
- docker:dind
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
deploy:
stage: deploy
script:
- deploy.sh
environment:
name: production
url: https://api.example.com
rules:
- if: $CI_COMMIT_BRANCH == "main"
stages declares the linear sequence. Jobs in the same stage run in parallel; later stages start when the previous stage’s jobs all pass.
Stages — the classic structure
stages:
- lint
- test
- build
- deploy:staging
- deploy:production
Default stages if none declared: .pre, build, test, deploy, .post. The .pre and .post stages are special — they run before / after everything.
For non-linear workflows, use needs: to bypass stage ordering (see “DAG mode” below).
Jobs and dependencies
test:
stage: test
script: [...]
build:
stage: build
script: [...]
# Implicit: runs after all jobs in `test` succeed
deploy:
stage: deploy
needs: [build] # explicit DAG; bypasses stage ordering
script: [...]
needs: lets a job start as soon as its dependencies finish, even before the rest of the previous stage. “DAG mode” — pipelines run as a directed acyclic graph instead of strict stages.
build_backend:
stage: build
script: [...]
test_backend:
stage: test
needs: [build_backend] # starts immediately after build_backend, even if build_frontend is still running
script: [...]
build_frontend:
stage: build
script: [...]
test_frontend:
stage: test
needs: [build_frontend]
script: [...]
DAG mode is critical for big monorepos — parallel work doesn’t wait on slowest-stage-member.
Rules — when to run
deploy_prod:
stage: deploy
script: [...]
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
- if: $CI_COMMIT_TAG
when: on_success
- when: never # default: don't run
rules: is evaluated top-down; first match wins. Each rule has:
| Field | Means |
|---|---|
if: |
condition (uses CI variables) |
changes: |
file paths that must have changed |
exists: |
files that must exist |
when: |
on_success, manual, delayed, always, never |
allow_failure: |
don’t fail the pipeline if this job fails |
variables: |
set additional vars when this rule matches |
test_python:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes:
- "**/*.py"
- "requirements*.txt"
Run only when Python files or requirements changed in an MR pipeline.
Common CI variables
GitLab pre-populates many variables:
| Variable | Means |
|---|---|
$CI_COMMIT_SHA |
full commit SHA |
$CI_COMMIT_SHORT_SHA |
8-char SHA |
$CI_COMMIT_BRANCH |
branch name (only when on a branch) |
$CI_COMMIT_TAG |
tag (only on tag pipelines) |
$CI_COMMIT_MESSAGE |
last commit message |
$CI_PIPELINE_ID |
unique pipeline ID |
$CI_JOB_ID |
unique job ID |
$CI_REGISTRY_IMAGE |
the URL of this repo’s container registry |
$CI_PROJECT_PATH |
group/project |
$CI_DEFAULT_BRANCH |
usually main |
$CI_PIPELINE_SOURCE |
trigger: push, merge_request_event, schedule, etc. |
$CI_MERGE_REQUEST_IID |
MR number (on MR pipelines) |
Plus your own under Settings → CI/CD → Variables.
Artifacts — files between jobs
build:
stage: build
script:
- python -m build
artifacts:
paths:
- dist/*.whl
expire_in: 1 week
reports:
junit: pytest-results.xml
coverage_report:
coverage_format: cobertura
path: coverage.xml
test:
stage: test
needs: [build]
script:
- pip install dist/*.whl
- pytest
Artifacts auto-download into dependent jobs (matching needs: / stage order). Reports get parsed by GitLab and shown in MRs (test status badges, coverage diff, security findings).
Caching
test:
cache:
key:
files:
- requirements.txt
- poetry.lock
paths:
- .cache/pip
- .venv/
before_script:
- pip install -r requirements.txt
script:
- pytest
Cache is keyed by hash of declared files. Cache reused when those files don’t change.
Cache vs artifacts:
| Cache | Artifacts | |
|---|---|---|
| Purpose | speed up subsequent runs (deps) | preserve output of a job |
| Where stored | runner’s local disk (or shared cache) | GitLab server |
| Lifetime | until evicted | expire_in setting |
| Order | restored before, saved after | uploaded after job, downloaded into next |
Environments — first-class deploys
deploy_staging:
stage: deploy
script: deploy.sh staging
environment:
name: staging
url: https://staging.example.com
on_stop: stop_staging
stop_staging:
stage: deploy
script: teardown.sh staging
environment:
name: staging
action: stop
when: manual
Environments track deployments per environment-name. GitLab UI shows current deployment, history, and lets you click through.
on_stop: chains a “stop” job — useful for ephemeral review environments (auto-spin-up per MR, auto-tear-down on close).
Services — sidecar containers
test:
image: python:3.14
services:
- name: postgres:16
alias: db
variables:
POSTGRES_PASSWORD: test
- redis:7
variables:
DATABASE_URL: postgres://postgres:test@db:5432/postgres
REDIS_URL: redis://redis:6379
script:
- pip install -r requirements.txt
- pytest
Services networked by name (or alias). Faster than spinning up via docker-compose in the script.
Includes — reuse pipeline pieces
include:
- local: .gitlab-ci-base.yml
- project: org/templates
file: python.yml
ref: v1
- template: Security/SAST.gitlab-ci.yml
- remote: https://example.com/ci.yml
| Source | Use |
|---|---|
local |
another file in the same repo |
project |
shared template from another GitLab project |
template |
GitLab-provided template (security scans, deployment patterns) |
remote |
any URL |
Centralize common patterns in an org-wide CI template repo; include from each project.
Anchors and extends — yaml reuse
.test_template:
image: python:3.14
before_script:
- pip install -r requirements.txt
cache:
paths: [.cache/pip]
test_unit:
extends: .test_template
stage: test
script: pytest tests/unit
test_integration:
extends: .test_template
stage: test
script: pytest tests/integration
services:
- postgres:16
Hidden jobs (prefixed with .) don’t run; they’re templates other jobs extend. Cleaner than YAML anchors.
Pipeline types
| Type | Trigger |
|---|---|
| Branch | push to a branch |
| Merge request | MR opened / updated |
| Merged results | runs against the would-be post-merge code |
| Merge train | sequential merges that test the integrated state |
| Tag | push to a tag |
| Scheduled | cron-style |
| Trigger | API / external webhook |
| Parent-child | one pipeline triggers another |
| Multi-project | cross-project triggering |
For most teams: branch + MR pipelines. Merge results/trains for high-traffic repos. Tag for releases.
Runners
GitLab CI jobs execute on runners. Three types:
| Type | Hosted by | When |
|---|---|---|
| Shared runners | GitLab.com (or your GitLab admin) | default, easy |
| Group runners | your group | shared across projects in a group |
| Project runners | one project | dedicated, often self-hosted |
Runners pick up jobs matching their tags:
deploy:
tags:
- production
- aws
script: deploy.sh
Only runners registered with both tags can pick up this job. Used to route specialized work (GPU, VPN-connected, custom infrastructure).
See 04_self_hosted_runners.md.
Manual jobs and gates
deploy_prod:
stage: deploy
script: deploy.sh prod
when: manual
rules:
- if: $CI_COMMIT_BRANCH == "main"
Job appears in the pipeline but doesn’t run until someone clicks “play.” Combined with allow_failure: false (the default for manual jobs), the pipeline blocks here until a human approves.
For protected environments, only authorized users can trigger the manual job.
Security scanning — built-in templates
include:
- template: Security/SAST.gitlab-ci.yml
- template: Security/Dependency-Scanning.gitlab-ci.yml
- template: Security/Container-Scanning.gitlab-ci.yml
- template: Security/Secret-Detection.gitlab-ci.yml
Each template adds a job that runs the relevant scanner. Findings show up in the MR as inline comments and in the project’s Security dashboard.
For competitive comparison: GitHub has CodeQL + Dependabot + Trivy; GitLab has its own equivalents integrated.
Container Registry
GitLab includes a Docker registry per project ($CI_REGISTRY_IMAGE).
build:
stage: build
image: docker:latest
services:
- docker:dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
before_script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
$CI_REGISTRY_USER and $CI_REGISTRY_PASSWORD are auto-populated for each pipeline. No setup needed beyond enabling the registry.
GitLab CI vs GitHub Actions
| GitLab CI | GitHub Actions | |
|---|---|---|
| Config | .gitlab-ci.yml |
.github/workflows/*.yml |
| Stages | first-class | implicit via needs: |
| Runners | tagged routing | labeled runners + cloud sizes |
| Reusability | include, extends |
reusable workflows, composite actions |
| Built-in registry | yes (free with project) | GHCR |
| Container scanning | built-in templates | needs third-party action |
| Free CI minutes | depends on tier | depends on plan |
| Marketplace | smaller ecosystem | huge marketplace |
| Environments / deployments | first-class | environments concept exists |
Both are excellent. Pick by which Git host you’re on; rarely worth switching just for the CI.
Common pitfalls
- No
needs:in big pipelines — strict stage ordering wastes time when work could overlap. - Stages with one job each — gain nothing over a single linear sequence. Use stages to group parallel work.
when: alwaysfor cleanup jobs withoutallow_failure: true— cleanup runs but its failure breaks the pipeline.- Cache keys that don’t reflect content — stale cache hits make failures mysterious.
docker:dindwithout TLS — security issue; the dind socket gets exposed. Use TLS or rootless build alternatives (kaniko, buildah).- Manual deploy job without protected environment — anyone can click play. Configure environment protection.
- No timeout — jobs hang forever. Set
timeout: 30mper job.
Interview angle
- “How is a GitLab CI pipeline structured?” — YAML in
.gitlab-ci.yml. Top-level:stages,variables,default,include. Jobs assigned to stages; jobs in the same stage run in parallel; stages run in order.needs:enables DAG mode (jobs start before whole stages finish). - “What’s
needs:and why use it?” — declares a job depends on specific other jobs, bypassing stage ordering. Lets independent work proceed in parallel across stages — critical for big monorepos. - “What’s a runner in GitLab?” — the agent that executes jobs. Shared (GitLab.com or admin-managed), group-level (shared in a group), or project (often self-hosted). Routed by tags.
- “How do you trigger a deploy job manually?” —
when: manualinrulesor directly on the job. Appears in the pipeline as a play button; waits for a human click. Combined with protected environments for authorization. - “What’s the difference between cache and artifacts?” — cache is for speeding up subsequent runs (dependencies, build outputs that can be regenerated). Artifacts are job outputs you want to preserve / pass to dependent jobs / show in MRs.
- “What are environments in GitLab CI?” — named deployment targets (
staging,production). The UI tracks current deployment, history, lets you click to the deployed URL. Supportson_stopfor ephemeral environments. - “How would you implement a multi-stage Python pipeline?” — stages: lint, test, build, deploy.
extends: .python_basefor shared setup. Cache pip. Useservices: [postgres]for integration tests.rules: if $CI_COMMIT_BRANCH == "main"to gate deploys. Manual job for prod with protected environment.