backend / cicd / 03_gitlab_ci.md

GitLab CI

7 interview angles 9 min read source

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: always for cleanup jobs without allow_failure: true — cleanup runs but its failure breaks the pipeline.
  • Cache keys that don’t reflect content — stale cache hits make failures mysterious.
  • docker:dind without 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: 30m per 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: manual in rules or 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. Supports on_stop for ephemeral environments.
  • “How would you implement a multi-stage Python pipeline?” — stages: lint, test, build, deploy. extends: .python_base for shared setup. Cache pip. Use services: [postgres] for integration tests. rules: if $CI_COMMIT_BRANCH == "main" to gate deploys. Manual job for prod with protected environment.