DS DevShelfHub Projects · AI tools
Cheatsheets / GitLab CI/CD
Cheatsheet · Dev tooling

GitLab CI/CD Cheatsheet: Pipelines, Jobs and Rules Reference

By DevShelfHub

.gitlab-ci.yml structure, jobs, stages, rules, variables + OIDC, artifacts, cache, includes, extends, runners, environments, multi-project pipelines.

97 items 10 min Pipelines Jobs Runners

Start hereQuick start · 6 you’ll reach for daily

Config file.gitlab-ci.yml (repo root)
Define jobtest: { script: [pytest] }
Default stagesbuild · test · deploy
Cachecache: { paths: [.cache/] }
Branch gaterules: -if: '$CI_COMMIT_BRANCH == "main"'
DAGneeds: [build]

Target versions · paceVersions

Targets: GitLab ≥ 17.x gitlab-runner ≥ 17.x glab CLI ≥ 1.40

GitLab ships monthly. rules: superseded only:/except: a few years back — new pipelines should use rules. Recent flagship features: CI/CD components (catalog), id_tokens for cloud-native OIDC, !reference, interruptible:, and the unified workflow.rules. Pin the runner to the same major as your GitLab server.

First pipeline · runnersSetup

bash
# 1. Minimal .gitlab-ci.yml at repo root — that's all you need to enable CI
cat > .gitlab-ci.yml <<'YAML'
default:
    image: python:3.12

stages: [build, test, deploy]

test:
    stage: test
    script:
        - pip install -r requirements.txt
        - pytest -q
YAML

git add .gitlab-ci.yml && git commit -m "ci: enable pipelines" && git push

# 2. Validate locally without pushing
glab ci lint                                # GitLab CLI
# or with the official Docker image
docker run --rm -v "$PWD:/work" -w /work registry.gitlab.com/gitlab-org/cli:latest \
    glab ci lint .gitlab-ci.yml

# 3. Run pipelines on your laptop (preview)
gitlab-runner exec docker test              # runs the "test" job locally in Docker

# 4. Register a self-hosted runner
sudo gitlab-runner register \
    --url https://gitlab.com/ \
    --registration-token  \
    --executor docker \
    --description "linux-docker" \
    --tag-list "docker,linux" \
    --docker-image "alpine:latest"

# 5. Tail runner logs
sudo gitlab-runner --debug run

Top-level keywordsYAML structure

stages: [build, test, deploy]Declare ordered stages. Default if omitted: .pre, build, test, deploy, .post.
default: { image: ..., before_script: [...] }Defaults applied to every job — one place to set the executor image.
variables: { KEY: value }Pipeline-wide environment.
workflow: { rules: [...] }Pipeline-level rules — skip the whole run when no rule matches.
include: [{ local: ..., project: ..., template: ... }]Compose YAML from files. Mix local / cross-project / templates / components.
<job_name>: { ... }Top-level keys are jobs (unless they’re reserved like stages).
.hidden_job: { ... }Leading dot → not executed; used as a template via extends.
pages: { ... }Special job: artifacts auto-published to GitLab Pages.
image: python:3.12 · services: [postgres:16]Docker image + side-car services for the job’s executor.
tags: [docker, linux]Match runners that advertise these tags.
interruptible: trueCancel the in-flight pipeline when a new one starts for the same ref.
!reference [.template, key]Pull a specific key from another job. Clearer than YAML anchors.

script · needs · retryJobs

script: [echo hi]Required. List of shell commands.
before_script: [...] · after_script: [...]Pre/post hooks. after_script runs even on failure.
image: python:3.12Per-job override of the default image.
image: { name: ..., entrypoint: [""] }Clear the image entrypoint — common for images that aren’t CI-friendly.
services: [postgres:16, redis:7]Linked containers (DBs, brokers). Accessible at postgres:5432 by name.
stage: buildAssign to a stage. Default is test.
needs: [build]Skip stage order — DAG mode. Job runs as soon as its needs finish.
needs: [{ job: build, artifacts: true }]Full needs syntax — pull artifacts from a specific job.
allow_failure: trueDon’t block downstream on failure. The pipeline still goes green.
retry: 2Auto-retry. {max:2, when: runner_system_failure} for targeted retries.
timeout: 30 minutesPer-job timeout. Project-level default also exists.
interruptible: trueSame-ref new pipeline cancels this one. Strong default for non-deploy jobs.
parallel: 5Run 5 identical copies in parallel (test sharding).
parallel: { matrix: [{ PY: ["3.11","3.12"] }] }Matrix expansion — one job per combination of values.
yaml
# Full pipeline: build → test (parallel matrix) → deploy (manual on main).
default:
    image: python:3.12
    interruptible: true                  # cancel obsolete runs on new pushes

stages: [build, test, deploy]

variables:
    PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"

cache:
    key: "$CI_COMMIT_REF_SLUG"            # branch-scoped cache
    paths: [.cache/pip/, .venv/]
    policy: pull-push

.python_base: &python_base
    before_script:
        - python -m venv .venv && . .venv/bin/activate
        - pip install -r requirements.txt

build:
    <<: *python_base
    stage: build
    script: [python -m build]
    artifacts: { paths: [dist/], expire_in: 1 week }

test:
    <<: *python_base
    stage: test
    parallel:
        matrix:
            - PYTHON: ["3.11", "3.12"]
    image: "python:$PYTHON"
    script: [pytest -q --junitxml=junit.xml]
    artifacts:
        when: always
        reports: { junit: junit.xml }

deploy_prod:
    stage: deploy
    needs: [build]
    environment: { name: production, url: https://app.example.com }
    rules:
        - if: '$CI_COMMIT_BRANCH == "main"'
          when: manual
    script: [./scripts/deploy.sh]

Ordering · DAGStages

Default order.pre, build, test, deploy, .post.
stages: [build, test, deploy]Override the order. Stages run sequentially — all jobs in one stage in parallel.
stage: (omitted)Defaults to test.
.pre · .postAlways first / last regardless of where defined.
needs:Bypasses stage ordering — build a DAG instead.
Empty stageSkipped entirely. Define stages even if some are conditionally empty.

if · changes · when · workflowRules

rules: - if: '$CI_COMMIT_BRANCH == "main"'Run only when the predicate matches. Top-to-bottom first match wins.
rules: - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'Run only on MR pipelines.
rules: - changes: { paths: ["src/**/*"], compare_to: "main" }Only when matching files changed vs base ref.
rules: - exists: [Dockerfile]Only if a path is in the repo.
rules: - when: manualJob appears with a "play" button in the UI.
rules: - when: neverHide the job. Use for negative branches in rule chains.
rules: - allow_failure: truePer-rule allow_failure override.
rules:Top-down evaluation; first match wins, omitted = default behaviour.
only: / except: LegacyStill works. New pipelines should use rules instead.
workflow: { rules: [...] }Same rule syntax, applied to the whole pipeline.
yaml
# rules: are evaluated top to bottom — first match wins.
# Combine if/changes/exists to express precise triggers.

# 1. Only on merge requests against main
mr_lint:
    script: [./scripts/lint.sh]
    rules:
        - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'

# 2. Skip if only docs changed
build:
    script: [make]
    rules:
        - changes: { paths: ["docs/**/*", "*.md"], compare_to: "main" }
          when: never
        - when: on_success

# 3. Manual job; allow_failure so it doesn't block
deploy_canary:
    script: [./deploy_canary.sh]
    rules:
        - if: '$CI_COMMIT_BRANCH == "main"'
          when: manual
          allow_failure: true

# 4. Pipeline-level — skip the whole run on draft MRs
workflow:
    rules:
        - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TITLE =~ /^Draft:/'
          when: never
        - when: always

# 5. Scheduled-only job
nightly_db_dump:
    script: [./scripts/dump.sh]
    rules:
        - if: '$CI_PIPELINE_SOURCE == "schedule"'

Predefined · masked · OIDCVariables & secrets

variables: { KEY: value }Inline pipeline / job variables. Job-level overrides pipeline-level.
Settings → CI/CD → VariablesUI-managed. Per project / group / instance.
masked: trueRedact in logs. Value must match the masking regex (≥8 chars, no spaces).
protected: trueOnly exposed on protected branches / tags.
File-type variable$VAR resolves to the path of a file containing the value. Useful for certs.
variables.KEY: { value, description, options }Surface as a form field on the "Run pipeline" page.
$CI_COMMIT_BRANCH · $CI_COMMIT_TAG · $CI_COMMIT_SHAPredefined per-commit info.
$CI_PIPELINE_SOURCEpush / merge_request_event / schedule / web / trigger.
$CI_MERGE_REQUEST_TITLE · _IID · _TARGET_BRANCH_NAMEAvailable on MR pipelines.
$CI_ENVIRONMENT_NAME · $CI_ENVIRONMENT_URLInside deploy jobs with an environment: block.
id_tokens: { AWS_JWT: { aud: ... } }Preferred OIDC. Trade the JWT for short-lived cloud creds — no static secret.
secrets: { DB_PASS: { vault: 'kv/data/...@db' } }HashiCorp Vault integration via OIDC.
yaml
# Variables live in three places:
#   1. .gitlab-ci.yml (visible to anyone reading the repo)
#   2. Project / Group / Instance settings → CI/CD → Variables (masked + protected)
#   3. Vault / OIDC at runtime (no static secret in GitLab)

variables:
    NODE_ENV: production
    REGION:   us-east-1
    BUILD_NUMBER:
        value: "1"
        description: "Override via 'Run pipeline'"   # surfaces a form field in the UI

deploy:
    image: hashicorp/terraform:1.7
    # Each `id_tokens` block requests a JWT — exchange it for short-lived creds.
    id_tokens:
        AWS_JWT: { aud: https://gitlab.com }
    before_script:
        # Trade the JWT for AWS creds via OIDC
        - aws sts assume-role-with-web-identity
            --role-arn "$AWS_ROLE_ARN"
            --role-session-name "gitlab-$CI_PIPELINE_ID"
            --web-identity-token "$AWS_JWT" > creds.json
        - export AWS_ACCESS_KEY_ID=$(jq -r .Credentials.AccessKeyId creds.json)
        - export AWS_SECRET_ACCESS_KEY=$(jq -r .Credentials.SecretAccessKey creds.json)
        - export AWS_SESSION_TOKEN=$(jq -r .Credentials.SessionToken creds.json)
    script:
        - echo "Deploying $CI_COMMIT_SHORT_SHA to $CI_ENVIRONMENT_NAME"
        - terraform apply -auto-approve
    environment: { name: production }
    rules:
        - if: '$CI_COMMIT_BRANCH == "main"'

Pass data between jobsArtifacts & cache

artifacts: { paths: [dist/] }Upload paths after a job. Downloaded by downstream jobs in the same pipeline.
artifacts: { expire_in: 1 week }Auto-delete. Project default is usually 30 days.
artifacts: { when: on_failure }Only upload if the job failed. Useful for log captures.
artifacts: { reports: { junit: junit.xml } }Parsed reports surface in the MR — failed tests, coverage diffs.
artifacts: { reports: { coverage_report: { coverage_format: cobertura, path: coverage.xml } } }Cobertura coverage shown inline.
cache: { paths: [.cache/pip/, node_modules/] }Persisted across pipelines (per cache key).
cache: { key: "$CI_COMMIT_REF_SLUG" }Branch-scoped cache. Default key shares everything.
cache: { policy: pull / push / pull-push }Direction. pull = use, don’t update.
dependencies: [build]Only pull artifacts from these jobs (default: all in earlier stages).
needs: [{ job: build, artifacts: true }]Combined DAG + artifact pull. Skips stage order.

Reuse YAML across reposIncludes & extends

include: - local: .ci/lint.ymlSame repo.
include: - project: "group/templates" file: "/ci/python.yml" ref: "main"Cross-project — pin to a ref / SHA / tag.
include: - remote: "https://.../ci.yml"From an HTTP URL.
include: - template: "Auto-DevOps.gitlab-ci.yml"GitLab-provided template.
include: - component: gitlab.com/components/sast@1.0.0CI/CD component (catalog). Pin by version.
extends: .base_jobInherit from a hidden job. Single-base.
extends: [.base, .python_image]Multi-extends — keys deep-merged left-to-right.
!reference [.template, key]Pull a specific key from another job — clearer than YAML anchors.
yaml
# include + extends: keep .gitlab-ci.yml short, share templates across repos.

include:
    # 1. From this repo
    - local: .ci/lint.yml

    # 2. From another GitLab project at a specific ref
    - project: "platform/templates"
      file:    "/ci/python.yml"
      ref:     "v3"

    # 3. Reusable CI/CD component (GitLab catalog)
    - component: gitlab.com/components/sast@1.0.0
      inputs:
          stage: test

    # 4. GitLab-provided template (full list under "Pipeline Editor → Browse templates")
    - template: "Security/Container-Scanning.gitlab-ci.yml"

# Hidden jobs (leading dot) act as reusable bases.
.python_base:
    image: python:3.12
    before_script:
        - pip install -r requirements.txt

# Extend one or several bases — keys are deep-merged.
test:
    extends: .python_base
    script: [pytest -q]

lint:
    extends: [.python_base, .needs_no_cache]
    script: [ruff check .]

# !reference grabs a specific key from another job — clearer than YAML anchors.
type_check:
    extends: .python_base
    script:
        - !reference [.python_base, before_script]
        - mypy src

Executors · scopeRunners

Shared · Group · Project runnersThree scopes. Shared = entire instance.
Executors: docker, shell, kubernetes, docker-machineDocker is the default everywhere; Kubernetes for autoscaling.
gitlab-runner register --url ... --token ... --executor dockerRegister a self-hosted runner.
tags: [docker, linux]Per-job: only runners with these tags will pick it up.
tag_list = ["docker","linux"]Per-runner: what it advertises (in /etc/gitlab-runner/config.toml).
concurrent = 4How many jobs a runner host runs in parallel.
FF_USE_FASTZIP=trueFeature flag — faster artifact upload for big folders.

Deployments · review appsEnvironments & deployments

environment: { name: production, url: https://app.example.com }Tags this job as a deployment. Shows on the Environments page.
environment: { name: review/$CI_COMMIT_REF_SLUG, on_stop: stop_review, action: start }Review apps — one preview per branch / MR.
environment: { action: stop }Paired teardown job — manual / automatic.
environment: { auto_stop_in: 1 day }Auto-tear-down after a period of inactivity.
environment.deployment_tier: productionCompliance tier — surfaces in dashboards.
resource_group: productionSerialize deployments — one at a time per group.
release: { tag_name: $CI_COMMIT_TAG, description: "..." }Create a GitLab Release on tag pipelines.

trigger · parent-childMulti-project & child pipelines

trigger: { project: group/downstream }Run a pipeline in another project (multi-project).
trigger: { project: ..., branch: main, strategy: depend }Wait for the downstream pipeline. strategy: depend = fail this job if it fails.
trigger: { include: child-pipeline.yml }Parent-child — run nested pipeline from a YAML file in the same repo.
trigger: { include: [{ artifact: gen.yml, job: generate }] }Dynamic child pipeline — YAML generated by an upstream job.
needs: [{ pipeline: $UPSTREAM_PIPELINE_ID, job: build }]Cross-pipeline needs — pull artifacts.

Full pipeline · ~45 linesEnd-to-end · Node app

Lint → test → build → review-app on every MR → manual prod deploy on main, with cache, JUnit reports, an auto-stopping review environment, and serialized prod via resource_group.

yaml
# End-to-end: lint → test → build → deploy.
# Review apps on every MR, manual prod deploy from main.
default:
    image: node:20-alpine
    interruptible: true

stages: [lint, test, build, deploy]

variables:
    NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm"

cache:
    key: "$CI_COMMIT_REF_SLUG"
    paths: [.npm/, node_modules/]
    policy: pull-push

.node_setup: &node_setup
    before_script: [npm ci --prefer-offline]

lint:
    <<: *node_setup
    stage: lint
    script: [npm run lint]

unit:
    <<: *node_setup
    stage: test
    script: [npm test -- --ci]
    artifacts: { when: always, reports: { junit: junit.xml } }

build:
    <<: *node_setup
    stage: build
    script: [npm run build]
    artifacts: { paths: [dist/], expire_in: 1 week }
    needs: [lint, unit]

review:
    stage: deploy
    needs: [build]
    environment:
        name: review/$CI_COMMIT_REF_SLUG
        url:  https://$CI_COMMIT_REF_SLUG.preview.example.com
        on_stop: stop_review
        auto_stop_in: 1 week
    rules:
        - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    script: [./scripts/deploy_review.sh]

stop_review:
    stage: deploy
    rules: [{ if: '$CI_PIPELINE_SOURCE == "merge_request_event"', when: manual, allow_failure: true }]
    environment: { name: review/$CI_COMMIT_REF_SLUG, action: stop }
    script: [./scripts/stop_review.sh]

deploy_prod:
    stage: deploy
    needs: [build]
    environment: { name: production, url: https://app.example.com }
    resource_group: production            # serialize prod deploys
    rules: [{ if: '$CI_COMMIT_BRANCH == "main"', when: manual }]
    script: [./scripts/deploy.sh]

Best practiceGood to know

Make every job interruptible: true unless it deploys. Force-pushing a fixup to a busy MR shouldn’t leave a ghost pipeline burning CI minutes for two hours. Deploy jobs stay non-interruptible so you don’t kill a real rollout.
Use needs: to DAG-ify the pipeline. Stages are a coarse default. needs: turns the same YAML into a real DAG — jobs run as soon as their inputs finish, often shaving 30%+ off wall-clock.
OIDC + id_tokens instead of static cloud creds. Trade the GitLab JWT for short-lived AWS / GCP / Azure / Vault credentials. No static secret stored in CI variables means no leak surface and no rotation chores.

Common trapsWatch out for

Masked variables silently fail to mask. If the value contains spaces, equals signs, or is shorter than 8 characters, GitLab refuses to mask it — but the variable still works. Watch the "Variables" UI for the warning, or grep your job logs after the first run.
Default cache is global — cross-branch. Without an explicit cache.key, every branch shares the same cache and races each other’s entries. Always set key: "$CI_COMMIT_REF_SLUG" at minimum.
Mixing rules: with only:/except: is undefined. GitLab rejects the YAML at lint time, but cross-file overrides via extends can smuggle the conflict in. Pick one syntax per repo — prefer rules:.

Go deeperSee also

GitLab CI/CD FAQ

What is GitLab CI/CD?

GitLab CI/CD is the built-in continuous integration and delivery platform in GitLab. Pipelines are defined in a .gitlab-ci.yml file at the root of your repository. Each pipeline consists of stages (like build, test, deploy) containing jobs that GitLab runners execute automatically on every push, merge request, or schedule.

What is the difference between rules and only/except in GitLab CI?

rules is the modern, flexible replacement for the legacy only/except keywords. It evaluates conditions in order and takes the first match, supporting if expressions, file changes, and when conditions all in one block. New pipelines should use rules — only/except is deprecated and lacks some functionality of rules.

What are GitLab CI artifacts and how do they differ from cache?

Artifacts are files produced by a job that GitLab stores and makes available to subsequent stages or as downloads (e.g., compiled binaries, test reports). Cache is for speeding up jobs by reusing downloaded dependencies (e.g., node_modules) across pipeline runs. Artifacts flow between stages; cache persists across pipeline runs.

How does OIDC token authentication work in GitLab CI?

GitLab can inject a short-lived OIDC JWT (id_tokens) into a job, which you exchange for cloud credentials (AWS, GCP, Azure) without storing long-lived secrets. Configure id_tokens in your job, then use the token to call the cloud provider's STS endpoint and assume a role. This eliminates the need for static access keys in CI variables.

What is the difference between include and extends in GitLab CI?

include pulls in external YAML files (from the same repo, other projects, or URLs) to reuse whole pipeline definitions. extends lets individual jobs inherit configuration from a template job defined in the same file or an included file — similar to object inheritance. Use includes for sharing across projects; use extends for DRY job templates within a pipeline.

Is GitLab CI/CD free?

GitLab CI/CD is free for self-hosted GitLab instances with no pipeline minute limits. On GitLab.com (SaaS), the Free tier includes 400 CI/CD compute minutes per month on shared runners. Premium and Ultimate tiers provide more minutes, better runners, and advanced features like multi-project pipelines and compliance frameworks.