GitLab CI/CD in Depth: Pipelines, Runners, and Production Delivery

GitLab CI/CD is the continuous integration and delivery engine built into GitLab. You declare pipelines in a .gitlab-ci.yml file at the repository root; GitLab schedules jobs on runners that execute your build, test, scan, and deploy steps. Unlike bolting Jenkins onto a separate server, GitLab treats CI/CD as a first-class part of the same place you review merge requests, manage environments, and store container images.

In short

Pipelines are YAML-defined graphs of jobs grouped into stages. Runners pull work from GitLab (or receive it via triggers). Variables, artifacts, caches, rules, and environments turn a commit into a repeatable path from code to production—with security scanning and deployment history in the same UI.

Where GitLab CI/CD sits in the delivery stack

Every mature delivery flow answers the same questions: what changed?, does it build and pass tests?, is it safe?, and how do we promote it to users? GitLab CI/CD answers the middle two continuously and the last one through environments, deployment jobs, and integrations with Kubernetes, Helm, Terraform, and cloud APIs.

GitLab is not the only CI system—Jenkins, GitHub Actions, Tekton, CircleCI, and Azure DevOps all solve similar problems. GitLab’s differentiator for many teams is co-location: merge requests, pipeline status, container registry, package registry, security dashboards, and protected branches live in one product. That reduces glue code and context switching, at the cost of tying more of your toolchain to a single vendor unless you self-host GitLab and integrate externally.

If you are new to Git itself, start with Git & GitHub in depth for commits, branches, and merge-request workflows—the mental model carries directly into GitLab. For the broader cultural arc, see historical foundations of DevOps; for reconciling clusters from Git after CI builds an image, see GitOps principles.

Architecture: instance, runners, and executors

Three pieces matter for every pipeline run:

  1. GitLab instance — GitLab.com (SaaS) or self-managed GitLab (Omnibus, Helm chart, or cloud marketplace). It stores repositories, parses .gitlab-ci.yml, schedules jobs, and exposes the UI/API.
  2. GitLab Runner — A separate agent process (often on its own VM, Kubernetes pod, or bare metal) that registers with the instance and asks for work. Runners are not embedded inside the GitLab Rails app; you install and scale them where your workloads need CPU, GPUs, or network access to private subnets.
  3. Executor — How the runner runs a job: shell (commands on the host), docker (default for many teams—job runs in a container), kubernetes (pod per job), ssh, parallels, virtualbox, and others.

Runners are tagged (e.g. docker, gpu, deploy-prod). Jobs declare tags: so only capable runners pick them up. Shared runners on GitLab.com are convenient for open source; enterprises usually use group or project-specific runners with locked-down networks and IAM roles.

Registration uses a runner token (legacy) or runner authentication tokens (modern workflow). Store tokens in your secrets manager; rotate when people leave or VMs are rebuilt.

The pipeline contract: .gitlab-ci.yml

GitLab looks for .gitlab-ci.yml on the default branch and on every push or merge request, depending on configuration. The file is YAML with a fixed vocabulary: stages, jobs, scripts, images, variables, artifacts, cache, rules, and optional includes from other files or projects.

Minimal mental model:

  • A pipeline is one run for one commit (or trigger).
  • Stages run in order; all jobs in stage N must finish (success or allowed failure) before stage N+1 starts—unless you use needs: to build a DAG.
  • A job is one unit of work on one runner: clone repo, run script, upload artifacts.

Smallest useful pipeline

stages:
  - test
  - build

variables:
  NODE_VERSION: "20"

test:unit:
  stage: test
  image: node:${NODE_VERSION}-alpine
  script:
    - npm ci
    - npm test

build:docker:
  stage: build
  image: docker:27-cli
  services:
    - docker:27-dind
  variables:
    DOCKER_TLS_CERTDIR: "/certs"
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

Built-in predefined variables such as CI_COMMIT_SHA, CI_PROJECT_PATH, CI_REGISTRY_IMAGE, and CI_DEFAULT_BRANCH avoid hard-coding project names. Learn the catalog in GitLab docs—they are the backbone of portable templates.

Stages, jobs, and the DAG with needs

Classic GitLab pipelines are stage-linear: build → test → deploy. That is simple but wasteful when lint and unit could run in parallel, or when integration only needs the API image, not the frontend build.

needs: declares explicit dependencies between jobs, forming a directed acyclic graph (DAG). Jobs with satisfied needs can start before the entire previous stage completes—faster feedback on large monorepos.

stages:
  - build
  - test
  - deploy

build:api:
  stage: build
  script: ["./build-api.sh"]

build:web:
  stage: build
  script: ["./build-web.sh"]

test:api:
  stage: test
  needs: ["build:api"]
  script: ["./test-api.sh"]

test:web:
  stage: test
  needs: ["build:web"]
  script: ["./test-web.sh"]

deploy:staging:
  stage: deploy
  needs: ["test:api", "test:web"]
  environment:
    name: staging
  script: ["./deploy-staging.sh"]

Use needs: [] for jobs that should start immediately (e.g. lint on MR). Combine with parallel: matrix jobs to fan out across versions or shards.

rules, workflows, and when pipelines run

Older GitLab used only / except; modern pipelines prefer rules:—a list of conditions that set when: on_success, when: manual, or when: never.

  • Run expensive deploy jobs only on the default branch.
  • Run security scans on merge requests and on default branch.
  • Expose manual production deploy with when: manual and protected environments.
deploy:production:
  stage: deploy
  environment:
    name: production
    url: https://app.example.com
  script: ["./deploy-prod.sh"]
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual
    - when: never

Top-level workflow: rules suppress entire pipelines—for example skip CI when only Markdown changes, using changes: paths or $CI_PIPELINE_SOURCE (push, merge_request_event, schedule, trigger, api).

Variables: scope, protection, and secrets

Variables can be defined in the YAML file, in the GitLab UI (project/group/instance), or passed from trigger APIs. Understand the layers:

KindBehavior
PredefinedSet by GitLab (CI_*); read-only in jobs
CustomYour key/value; can be overridden per environment
ProtectedOnly exposed on protected branches/tags
MaskedHidden in job logs if they match the mask pattern
File typeWritten to a temp file path (good for kubeconfig, keys)

Never commit production passwords in YAML. Use masked/protected variables, HashiCorp Vault integration, or cloud OIDC (JWT ID tokens in GitLab 15.7+) so jobs assume short-lived IAM roles without long-lived keys in variables.

Artifacts, cache, and job outputs

Artifacts pass files between jobs or to humans after the pipeline (test reports, built binaries, Terraform plans). Set artifacts:paths, expire_in, and reports: for JUnit, coverage, or code quality so GitLab surfaces them in the MR widget.

Cache speeds up repeated work (node_modules, .m2/repository, Go module cache). Cache is best-effort and keyed—design keys per branch or lockfile hash. Do not treat cache as durable storage; use artifacts or object storage for releases.

Dependencies between jobs can download artifacts automatically when using classic stage ordering; with needs:, set needs: { job: build, artifacts: true } explicitly.

Environments, deployments, and review apps

An environment is a named deployment target (staging, production, review/$CI_MERGE_REQUEST_IID). GitLab tracks deployment history, last deploy user, and optional deployment tiers and protected environments (only maintainers can deploy to production).

Review apps spin up ephemeral environments per merge request—common with Helm, Kubernetes, or platform-as-a-service APIs. Pair with environment:on_stop jobs to tear down resources when the MR closes, or FinOps debt accrues quietly.

Dynamic environments use variables in the environment name so each feature branch gets an isolated URL—powerful for QA, expensive if cleanup fails.

Includes, templates, and DRY pipelines

Large organizations split CI config across repos:

  • include: local — files in the same repo (ci/test.yml, ci/deploy.yml)
  • include: project — shared templates from a central “pipeline library” project
  • include: remote — raw YAML from HTTPS (pin versions; treat as supply chain)
  • include: template — GitLab-maintained starters (e.g. Auto-DevOps, language SAST templates)

Combine with hidden jobs (names starting with .) as anchors and extends: to reuse script blocks—similar to YAML inheritance in other CI systems.

Merge request pipelines and merge trains

On merge requests, GitLab can run a dedicated MR pipeline (CI_PIPELINE_SOURCE == merge_request_event) that tests the proposed merge result. Configure merge request pipelines in project settings so results gate approval.

Merge trains (premium/ultimate on GitLab.com; available on self-managed tiers) queue merges so each train member runs against the latest default branch plus prior queued MRs—reducing “green on branch, red after merge” drift. Worth it on busy default branches with integration tests.

Security scanning in the pipeline

GitLab ships DevSecOps template jobs (availability depends on tier):

  • SAST — static analysis on source code
  • Dependency scanning — known CVEs in lockfiles
  • Secret detection — committed tokens and keys
  • Container scanning — image CVEs after docker push
  • DAST — dynamic scans against running review apps
  • IaC scanning — Terraform/Kubernetes misconfigurations

Include the official templates, then tune rules so scans run on MRs without blocking every doc-only commit. Treat findings as policy: severities that fail the pipeline vs. warnings tracked in the vulnerability report. Align with shift-left themes in the nature of a DevOps professional—security as a paved road, not a Friday audit.

Container registry and packages

GitLab provides a container registry per project (registry.gitlab.com/group/project). CI jobs authenticated as CI_JOB_TOKEN can push images scoped to the project—no separate registry password in many flows.

Similarly, Package Registry hosts Maven, npm, PyPI, NuGet, Helm charts, and generic packages. Pipelines can publish libraries after tests pass, with version tags tied to Git tags or semver variables.

Kubernetes, Terraform, and cloud deploy patterns

Common production patterns:

  • Docker build + push + Helm upgrade — Job uses kubectl or helm upgrade --install with a kubeconfig from a file variable; image tag = CI_COMMIT_SHA.
  • GitLab Agent for Kubernetes — Cluster pulls authorized GitLab connections; CI/CD can deploy without exposing the API server to the public internet.
  • OpenTofu/Terraform — Plan on MR, apply on default branch with remote state; store plan artifacts and use manual approval for production workspaces (see Terraform & IaC for everyone).
  • Cloud OIDC — GitLab job obtains JWT; AWS/GCP/Azure trusts GitLab as IdP and issues role credentials—preferred over static cloud keys in variables.

After deploy, GitOps controllers (Flux, Argo CD) can reconcile manifests that reference the image tag CI produced—separating “build and attest” from “declare desired cluster state” as in GitOps principles.

Multi-project pipelines, triggers, and parent–child

Monorepos are not universal. GitLab supports:

  • Parent–child pipelines — Parent trigger: job spawns a child pipeline with its own .gitlab-ci.yml (microservices in one group).
  • Multi-project pipelines — Trigger downstream project pipelines on success; pass variables downstream.
  • Pipeline triggers — Token or API trigger for external systems (release tooling, scheduled rebuilds).

Design clear contracts: which project owns versioning, which artifact URLs downstream jobs consume, and how failures bubble up to the parent MR status.

Schedules, resource groups, and reliability knobs

  • Pipeline schedules — Cron-like runs for nightly tests, certificate checks, or dependency refresh bots.
  • Resource groups — Serialize jobs that must not run concurrently (e.g. one Terraform apply per environment).
  • retry: — Flaky network to registry; use sparingly on deploy jobs.
  • timeout: — Kill runaway jobs before they exhaust runner pools.
  • interruptible: — Cancel obsolete MR pipelines when new commits push—saves runner minutes (FinOps tie-in: see FinOps in plain English).

GitLab CI/CD vs GitHub Actions (quick orientation)

ConceptGitLab CI/CDGitHub Actions
Config file.gitlab-ci.yml.github/workflows/*.yml
Execution unitJob on RunnerJob on hosted/self-hosted runner
ParallelismStages + needs DAGJobs; needs between jobs
Reusable configinclude, extendsReusable workflows, composite actions
RegistryBuilt-in container/package registryGHCR + marketplace actions
MR integrationMR pipelines, merge trainsPR checks, required statuses

Many teams use both in polyrepo environments. Principles transfer: pinned images, least-privilege tokens, artifact immutability, and deployment audit trails.

Production checklist (habits that survive on-call)

  • Pin CI images to digests or minor versions; avoid latest drifting under you.
  • Separate runner pools for untrusted forks (MRs from forks should not access production secrets—use separate projects or cautious rules).
  • Protect default branch; require passing pipeline + code owners for sensitive paths.
  • Store Terraform state remotely; never commit *.tfstate or kubeconfigs.
  • Make production deploys manual or canary with observability gates—not automatic on every green build unless you truly run continuous deployment.
  • Document rollback: previous image tag in registry, helm rollback, or Git revert plus pipeline.
  • Export metrics: pipeline duration, queue time, failure rate per stage—SRE-friendly signals alongside app SLOs.

Common pitfalls

  • God YAML — One 2,000-line file nobody reviews; split with include and ownership per folder.
  • Secrets in logs — Echoing env vars; fix with mask variables and careful set -x usage.
  • Cache poisoning assumptions — Cache is shared per key; untrusted MRs on shared runners need isolation policies.
  • Stage overload — Everything in serial stages; migrate hot paths to needs.
  • Orphan environments — Review apps without stop jobs inflate cloud bills.
  • Ignoring workflow:rules — Wasting thousands of runner minutes on doc commits.

Further reading

  • GitLab CI/CD documentation — YAML reference, predefined variables, runners, Kubernetes agent
  • GitLab — Auto DevOps and security scanning template guides
  • GitLab — CI/CD examples repository (language-specific patterns)
  • Google — Site Reliability Engineering (release engineering, canarying, error budgets)

Blog index · Jenkins CI/CD · GitHub CI/CD · Git & GitHub in depth · GitOps principles · DevOps foundations · Terraform & IaC

Back to blog list