Backend / CI/CD / 03_gitlab_ci.md

GitLab CI

Updated 7 interview angles 9 min read source
On this page20
  1. Anatomy
  2. Minimal pipeline
  3. Stages — the classic structure
  4. Jobs and dependencies
  5. Rules — when to run
  6. Common CI variables
  7. Artifacts — files between jobs
  8. Caching
  9. Environments — first-class deploys
  10. Services — sidecar containers
  11. Includes — reuse pipeline pieces
  12. Anchors and extends — yaml reuse
  13. Pipeline types
  14. Runners
  15. Manual jobs and gates
  16. Security scanning — built-in templates
  17. Container Registry
  18. GitLab CI vs GitHub Actions
  19. Common pitfalls
  20. Interview angle

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 GitHub Actions.

Anatomy

text
.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

yaml
# .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

yaml
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

yaml
test:
  stage: test
  script: [...]

build:
  stage: build
  script: [...]
  # Implicit: runs after all jobs in `test` succeed

deploy:
  stage: deploy
  # explicit DAG; bypasses stage ordering
  needs: [build]
  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.

yaml
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

yaml
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
yaml
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

yaml
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

yaml
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

yaml
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

yaml
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

yaml
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

yaml
.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:

yaml
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 Self-Hosted Runners.

Manual jobs and gates

yaml
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

yaml
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).

yaml
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 7

  • “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.