A production-focused GitLab CI/CD guide: include/extends templates, rules and workflow, needs DAGs, cache vs artifacts, protected environments, masked/protected variables and Vault, built-in security scanning, and review apps — with copy-paste examples.
GitLab CI/CD is one of the most complete delivery platforms available: source, pipelines, container registry, environments, and security scanning all in one place. That integration is its superpower and its risk. Because a single .gitlab-ci.yml can do everything, teams tend to grow one enormous file per project, duplicate it across repos, and slowly diverge until no two pipelines behave alike. The best practice is to treat GitLab CI/CD as a platform product with shared templates, explicit rules, and strong environment governance — not as a monolithic YAML that accretes forever.
This guide covers the practices that separate reliable GitLab pipelines from fragile ones, each with a copy-paste example. The principles — standardization, explicit control flow, immutable artifacts, least privilege, and fast feedback — apply to any stack.
include and extends#The strongest anti-drift tool in GitLab is include: — pull shared job definitions from a central repository so every project inherits the same behavior, and a single change updates everyone. Combine it with extends to compose jobs from reusable bases.
# central repo: ci-templates/node.yml
.node-base:
image: node:20
cache:
key:
files: [package-lock.json]
paths: [.npm/]
before_script:
- npm ci --cache .npm --prefer-offline
.lint:
extends: .node-base
script: [npm run lint]
.test:
extends: .node-base
script: [npm test -- --ci]
# service repo: .gitlab-ci.yml — thin and standardized
include:
- project: platform/ci-templates
ref: v3 # pin to a tag, not a moving branch
file: node.yml
stages: [validate, build, deploy]
lint:
extends: .lint
stage: validate
test:
extends: .test
stage: validate
Pinning include to a tag (ref: v3) makes platform-level pipeline changes a deliberate upgrade instead of a surprise that breaks every project at once.
The payoff compounds as the organization grows. A central template repo becomes the place where platform engineers encode hard-won lessons — a caching strategy that actually works, a security scan configured correctly, a deploy job with the right retry and timeout — and every project inherits them for free. When something needs to change everywhere, it changes in one file and rolls out as a version bump that consumers can test in a branch before adopting. Compare that to the alternative, where each team has forked the pipeline long ago and any fleet-wide improvement dies in the backlog of fifty repositories. GitLab also ships a large set of built-in templates (Auto DevOps, language presets, security jobs) you can include directly or use as a starting point for your own.
rules and workflow#only/except are legacy — use rules: for per-job control and workflow: to decide whether a pipeline runs at all. Explicit rules prevent duplicate pipelines (the classic branch + MR double-run) and stop wasted jobs.
workflow:
rules:
# Avoid double pipelines: run for MRs, or for the default branch, nothing else
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
deploy-prod:
stage: deploy
script: [./scripts/deploy.sh production]
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual # human gate for production
environment:
name: production
The double-pipeline problem is worth calling out because almost every team hits it. Without a workflow: guard, pushing to a branch that has an open merge request triggers two pipelines — one for the branch, one for the MR — doubling your runner cost and cluttering the pipeline list. The workflow:rules block above collapses that to a single MR pipeline plus default-branch pipelines and nothing else. Beyond cost, rules give you precise, readable control: you can run a job only when specific files changed (changes:), only for scheduled pipelines ($CI_PIPELINE_SOURCE == "schedule"), or only when a variable is set, all with far clearer semantics than the tangle of only/except combinations that rules replaced.
needs: to Turn Stages into a DAG#Pure stages force every job in a stage to finish before the next starts. needs: builds a directed acyclic graph so independent jobs start as soon as their inputs are ready — often cutting pipeline wall-clock dramatically.
build:
stage: build
script: [make build]
artifacts:
paths: [dist/]
test-unit:
stage: test
needs: [build] # starts the instant build finishes
script: [make test-unit]
test-integration:
stage: test
needs: [build]
script: [make test-integration]
deploy-staging:
stage: deploy
needs: [test-unit, test-integration]
script: [./scripts/deploy.sh staging]
cache and artifacts#A common source of flaky, slow pipelines is misusing these two. Cache is a best-effort speedup for dependencies (npm, pip, Go modules) — it may be missing and that must be fine. Artifacts are guaranteed build outputs that pass forward between jobs and get published. Build once; pass the artifact downstream — never rebuild on promote.
build:
stage: build
cache: # speed only — safe to miss
key: { files: [package-lock.json] }
paths: [.npm/]
script:
- npm ci --cache .npm
- npm run build
artifacts: # guaranteed handoff to later jobs
paths: [dist/]
expire_in: 1 week
GitLab Environments track what is deployed where; protected environments add deployment approvals and restrict which users or branches can deploy. Production policy belongs here, not in a script.
deploy-prod:
stage: deploy
needs: [deploy-staging]
script: [./scripts/deploy.sh production dist/]
environment:
name: production
url: https://app.example.com
deployment_tier: production
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
In project settings, mark production as a protected environment with required approvals and an allowed-to-deploy list. Combined with the manual rule, this gives you an auditable, gated production path while keeping lower environments fully automated.
Environments give you more than a gate. GitLab tracks a deployment history per environment, so you can see exactly which commit is live where and roll back to a previous deployment from the UI when something goes wrong. The deployment_tier field feeds GitLab's DORA analytics so your production deploys are counted correctly. And because approvals and allowed-deployers live on the protected environment rather than in YAML, a developer cannot grant themselves production access by editing .gitlab-ci.yml in a merge request — the policy is enforced by the platform, reviewed separately, and auditable. That separation between the pipeline definition and the deployment policy is the whole point.
Never hardcode secrets in .gitlab-ci.yml. Use CI/CD variables marked masked (hidden in logs) and protected (exposed only on protected branches/tags). For anything sensitive, prefer short-lived secrets from HashiCorp Vault via GitLab's native JWT/OIDC integration so nothing long-lived is stored at all.
deploy-prod:
id_tokens:
VAULT_ID_TOKEN:
aud: https://vault.example.com
script:
- export VAULT_TOKEN="$(vault write -field=token auth/jwt/login role=prod jwt=$VAULT_ID_TOKEN)"
- export DB_PASSWORD="$(vault kv get -field=password secret/app/prod)"
- ./scripts/deploy.sh production
environment: { name: production }
Scope each Vault role to one environment so a staging token can never read production secrets. Where you still use plain variables, keep them masked, protected, and scoped to the right environment.
The distinction between masked and protected matters and is easy to get wrong. Masked only stops the value from being printed in job logs — useful, but it does nothing to restrict which pipelines can read the variable. Protected is the access control: a protected variable is exposed only to pipelines running on protected branches and tags, so a feature branch or a fork MR never sees your production credentials. You want production secrets to be both. The JWT/OIDC path with Vault is strictly better where you can use it, because the pipeline holds no durable secret at all — it presents a short-lived, signed identity token, Vault validates the claims, and hands back a lease that expires on its own. Rotation stops being a pipeline concern and becomes a Vault policy.
GitLab ships SAST, dependency scanning, container scanning, secret detection, and DAST as includable templates. Enabling them is one line; the value comes from making critical findings block the merge.
include:
- template: Jobs/SAST.gitlab-ci.yml
- template: Jobs/Dependency-Scanning.gitlab-ci.yml
- template: Jobs/Secret-Detection.gitlab-ci.yml
- template: Jobs/Container-Scanning.gitlab-ci.yml
container_scanning:
variables:
CS_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
Pair these with merge request approval rules (a Security Approvals policy) so that a new critical vulnerability requires sign-off. A scan whose findings never gate a merge is a report, not a control.
GitLab's advantage here is integration: findings show up directly in the merge request widget as a diff — "this MR introduces two new high-severity vulnerabilities" — so reviewers see the security impact of a change next to the code change itself, before merging. A scan-result policy can then require a security approver whenever a new critical or high finding appears, and block the merge until it is resolved or explicitly waived. The key word is new: gating on newly-introduced vulnerabilities rather than the entire historical backlog keeps the signal actionable and stops teams from drowning in pre-existing findings they cannot fix today. Turn the scanners on early, gate on new criticals, and burn down the backlog on a schedule.
When a repo holds several components, a single flat pipeline becomes slow and hard to read. Parent-child pipelines let each component own its config and run only when its files change.
frontend:
stage: build
trigger:
include: frontend/.gitlab-ci.yml
strategy: depend
rules:
- changes: [frontend/**/*]
backend:
stage: build
trigger:
include: backend/.gitlab-ci.yml
strategy: depend
rules:
- changes: [backend/**/*]
strategy: depend makes the parent reflect the child's status, and rules: changes skips components that were not touched — faster feedback and cheaper runs.
This is the GitLab answer to the monorepo problem. Instead of one flat pipeline where a one-line docs change reruns the entire test matrix for every service, each component owns a self-contained child config that runs only when its files change. Teams get autonomy over their own component's pipeline without a merge conflict every time someone edits the shared root file, and the platform team keeps the thin parent that wires everything together. As the repo grows, this is the difference between pipelines that stay fast and pipelines that slowly become a twenty-minute tax on every commit.
Review apps deploy each MR to a temporary, isolated environment so reviewers see the real change, then tear it down automatically on merge or close. This catches integration issues that unit tests never will.
review:
stage: deploy
script: [./scripts/deploy.sh review $CI_ENVIRONMENT_SLUG]
environment:
name: review/$CI_COMMIT_REF_SLUG
url: https://$CI_ENVIRONMENT_SLUG.review.example.com
on_stop: stop-review
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
stop-review:
stage: deploy
script: [./scripts/teardown.sh $CI_ENVIRONMENT_SLUG]
environment:
name: review/$CI_COMMIT_REF_SLUG
action: stop
when: manual
Fast pipelines need right-sized runners. Tag jobs so heavy builds land on capable runners, keep images small, and cache aggressively. Then track delivery: GitLab's built-in DORA metrics and CI/CD analytics show deployment frequency, lead time, change failure rate, and time to restore directly from your pipeline and environment data.
build-image:
tags: [docker, large] # route to a capable runner
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
Review DORA weekly alongside pipeline duration and job-retry rates. A rising retry rate is usually flaky tests or runner instability, and it quietly erodes trust in the pipeline.
.gitlab-ci.yml copied per repo. Use include from a version-pinned central template.only/except and accidental double pipelines. Move to rules: + a workflow: guard.rules: changes.include template; projects stay thin.workflow: prevents double pipelines; jobs use explicit rules:.needs: turns stages into a DAG for faster wall-clock.rules: changes.In 2026, the teams that win with GitLab CI/CD combine its all-in-one convenience with real discipline: shared templates, explicit control flow, protected environments, and security gates that can actually stop a bad change. These are the compounding habits that turn one big YAML file into a delivery platform you can trust.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
A shared API key between two internal services proves nothing about who is calling. mTLS makes every service present a cryptographic identity instead.
Static keys leak and live forever. Short-lived credentials from STS and Vault expire on their own — here's the token-exchange machinery and the TTL math that make it work.
Explore more articles in this category
Run only the CI jobs a change actually affects using if conditionals, trigger path filters, and per-job path detection in a monorepo.
A prioritized toolkit for cutting CI time: measure the critical path first, then cache, parallelize, run only what changed, and shrink the work itself.
Stop stashing long-lived AWS access keys in GitHub secrets and let OIDC hand your workflows short-lived, scoped credentials instead.
Evergreen posts worth revisiting.