DevOps · Cicd

GitLab CI/CD Patterns — A Field Guide

GitLab CI/CD Patterns — A Field Guide is the work that turns a deploy into a system. The deployment is one moment; the system is the next 18 months of uptime, incidents, and.

John Kihiu12 min read

Most .gitlab-ci.yml files start clean and end up as a wall of copy-pasted jobs nobody wants to touch. The features below are the ones that actually keep a pipeline maintainable as it grows: how jobs get selected, how config gets shared instead of duplicated, and how a pipeline decides whether to run at all.

Stages and jobs

A pipeline is built from jobs, and jobs are grouped into stages. Every job in a stage runs in parallel; GitLab waits for a stage to finish (or fail) before starting the next one. A job is just a YAML key with a script: — everything else (stage, image, rules, needs, and so on) configures how and when it runs. The default stage order, if you don't declare one, is build, test, deploy, plus the implicit .pre and .post that always run first and last regardless of where you put them.

rules: vs the deprecated only/except

Older pipelines control job execution with only: and except:. Both still parse, but GitLab has deprecated them in favor of rules:, and for good reason — only/except can't express "run this job only if a variable is set AND the branch is main," while rules: evaluates a list of conditions top to bottom and stops at the first match. Each rule entry can set if:, changes:, exists:, or when:, and a rule with no condition acts as a catch-all. This is also how you implement "run on merge requests but not on pushes to feature branches without an open MR" — a common source of duplicate pipelines under the old syntax.

Don't mix only/except with rules on the same job

GitLab will reject a job that defines both. If you're migrating a legacy pipeline, convert a job fully before moving to the next one rather than trying to run the two systems side by side.

extends: and YAML anchors for DRY config

extends: is GitLab's own inheritance mechanism: a job declares extends: .some-hidden-job (hidden jobs are prefixed with a dot and never run on their own) and merges in that job's keys, with the child's values winning on conflicts. It supports multiple parents and deep merges hash keys like variables:, which plain YAML anchors don't do. YAML anchors (&name / *name) and the <<: merge key are the alternative — they work because .gitlab-ci.yml is just YAML, but the merge is shallow and GitLab has no visibility into it for linting. In practice, reach for extends first; use anchors only for small reusable snippets like a fixed list of tags:.

include: for splitting pipelines across files

Once a pipeline covers more than one component, include: keeps .gitlab-ci.yml from becoming unreadable. You can pull in a local file from the same repo, a file from a project (a shared config repo other pipelines reuse), a remote URL, or a GitLab-maintained template. Included files are merged into the main pipeline as if they were pasted in place, so job names still need to be unique across all of them. This is the standard way to share a deploy job across a dozen microservice repos without copy-pasting it a dozen times — one canonical file, included everywhere.

YAML · .gitlab-ci.yml
include:
  - local: '.gitlab/ci/build.yml'
  - project: 'platform/ci-templates'
    ref: main
    file: '/deploy/kubernetes.yml'
  - template: 'Security/SAST.gitlab-ci.yml'

stages: [build, test, deploy]

variables:
  DOCKER_DRIVER: overlay2

test:
  stage: test
  needs: ["build"]
  script: [npm test]
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

deploy:production:
  extends: .deploy-base
  stage: deploy
  environment:
    name: production
    url: https://app.example.com
  cache:
    key: "$CI_COMMIT_REF_SLUG"
    paths: [node_modules/]
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual

workflow:
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_TAG

needs: for DAG-based job ordering

Stage sequencing forces a job to wait for the entire previous stage to finish, even if the specific job it depends on finished minutes ago. needs: breaks that: a job listing needs: ["build"] starts as soon as build completes, ignoring the rest of that stage, turning the pipeline into a directed acyclic graph instead of a strict waterfall. It also controls artifact passing — a job only downloads artifacts from jobs it needs, not from everything upstream. The trade-off is that a DAG is harder to reason about visually than a stage-by-stage pipeline, so it's worth it mainly once a pipeline has enough independent branches that waiting on whole stages is costing real wall-clock time.

Variables, protected and masked

CI/CD variables are set in the UI under Settings > CI/CD > Variables, in variables: blocks in the YAML, or passed in when a pipeline is triggered. Two flags matter for anything sensitive: Protected restricts a variable to pipelines running on protected branches or tags, so a feature-branch pipeline from a fork can't read production credentials. Masked replaces the value with [MASKED] in job logs, but only if the value satisfies GitLab's masking requirements (no whitespace, minimum length, restricted character set) — a value that fails those requirements silently isn't masked, which is a common way secrets leak into logs. Masking and protection are independent settings; a variable can be one, both, or neither.

environment: blocks for deployment tracking

Adding environment: { name: production, url: https://... } to a deploy job does two things: it links the deployment to GitLab's Environments view so you can see what's currently deployed where and roll back from the UI, and it lets you gate the job with environment-level protected branch rules independent of the branch's own protection. Dynamic environments (using variables in the name, like review/$CI_COMMIT_REF_SLUG) are how review-app pipelines spin up a URL per merge request and tear it down with a matching stop: job when the MR closes.

Caching with cache:key

cache: persists files (typically dependency directories like node_modules/ or vendor/) between pipeline runs to avoid reinstalling from scratch every time. The key: determines cache scope — a static key shares one cache across every branch, which is fast but means a dependency change on one branch can pollute the cache for others. Keying on $CI_COMMIT_REF_SLUG gives every branch its own cache at the cost of more cache storage; keying on a lockfile hash (files: [package-lock.json]) invalidates automatically only when dependencies actually change, which is usually the right default. Cache is best-effort, not guaranteed — don't rely on it for correctness, only for speed.

workflow:rules for controlling whole-pipeline runs

Job-level rules: decide whether one job runs; top-level workflow: rules: decides whether the pipeline runs at all. The classic use is avoiding duplicate pipelines — without it, a push to a branch with an open merge request triggers both a branch pipeline and a merge-request pipeline for the same commit. Adding a workflow:rules block that only allows merge-request-event and default-branch pipelines eliminates the duplicate. It's evaluated once, before any jobs are considered, so it's also the place to skip pipelines entirely on branches you don't want CI running on, like documentation-only mirrors.

None of these features are exotic — they're the difference between a pipeline that scales with the repo and one that gets copy-pasted into unmaintainable shape. Start with rules: and workflow:rules to control what runs, add extends/include once duplication shows up, and reach for needs: only when stage waiting is a measured bottleneck.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.