Skip to content

GitLab CI/CD

First PublishedByAtif Alam

GitLab CI/CD is GitLab’s built-in automation platform. Pipelines are defined in a single .gitlab-ci.yml file at the repository root and run on GitLab-hosted shared runners or self-managed runners. Its tight integration with GitLab’s merge requests, container registry, environments, and package registry makes it an all-in-one DevOps platform.

Event (push, MR, schedule, tag, ...)
Pipeline (.gitlab-ci.yml)
├── Stage: build
│ └── Job: compile
├── Stage: test
│ ├── Job: unit-tests
│ └── Job: lint
└── Stage: deploy
└── Job: deploy-staging
ConceptWhat It Is
PipelineA collection of jobs organized into stages, triggered by an event
StageA phase of the pipeline; all jobs in a stage run in parallel; stages run sequentially
JobA set of commands that run on a runner; the basic unit of execution
RunnerThe machine (shared, group, or project) that executes jobs
.gitlab-ci.yml
stages: # Define stage order
- build
- test
- deploy
variables: # Pipeline-level variables
NODE_ENV: production
default: # Defaults applied to all jobs
image: node:20-alpine
before_script:
- npm ci
compile: # Job name
stage: build # Which stage this job belongs to
script: # Commands to run
- npm run build
artifacts: # Files to pass to later stages
paths:
- dist/
expire_in: 1 hour
unit-tests:
stage: test
script:
- npm test -- --coverage
coverage: '/Lines\s*:\s*(\d+\.?\d*)%/' # Parse coverage from output
artifacts:
reports:
junit: junit.xml # GitLab parses JUnit for MR widget
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
lint:
stage: test
script:
- npm run lint
deploy-staging:
stage: deploy
script:
- echo "Deploying to staging..."
- ./deploy.sh staging
environment:
name: staging
url: https://staging.myapp.com
only:
- main
KeywordPurposeExample
stagesDefine stage execution order[build, test, deploy]
imageDocker image to run the job innode:20, python:3.12
scriptCommands to execute- npm test
before_scriptCommands before script (e.g. install deps)- pip install -r requirements.txt
after_scriptCommands after script (cleanup, always runs)- echo "Job finished"
artifactsFiles to save and pass to later stagespaths: [dist/]
cacheDependencies to cache across pipeline runspaths: [node_modules/]
variablesEnvironment variablesAPP_ENV: staging
environmentDeploy target with URL and trackingname: production
only / exceptSimple branch/tag filters (legacy)only: [main]
rulesAdvanced conditional logic (preferred)See below
needsDAG dependencies (skip stage ordering)needs: [compile]
extendsInherit from another job definitionextends: .deploy-template
includeImport external YAML filesinclude: 'templates/deploy.yml'
deploy-production:
stage: deploy
script:
- ./deploy.sh production
rules:
- if: $CI_COMMIT_BRANCH == "main" # Only on main branch
when: manual # Require manual click
allow_failure: false # Block the pipeline until clicked
- if: $CI_PIPELINE_SOURCE == "schedule" # Also run on schedules
- when: never # Skip for everything else
VariableMeaningExample
$CI_COMMIT_BRANCHBranch name== "main"
$CI_COMMIT_TAGTag name (set only for tag pipelines)=~ /^v\d+/
$CI_PIPELINE_SOURCEWhat triggered the pipeline== "merge_request_event"
$CI_MERGE_REQUEST_SOURCE_BRANCH_NAMEMR source branch== "feature/x"
changesFile path patterns that changedchanges: [src/**]
ValueBehavior
on_successRun if previous stages succeeded (default)
on_failureRun only if a previous stage failed
alwaysRun regardless of previous stage status
manualWait for a user to click “Run” in the UI
delayedRun after a delay (start_in: 30 minutes)
neverDon’t run this job
test:
stage: test
script:
- npm test
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"

This runs the job for both merge request pipelines and pushes to main.

build:
stage: build
script:
- npm run build
artifacts:
paths:
- dist/ # Files available to later stages
expire_in: 1 week # Auto-delete after 1 week
reports:
junit: test-results.xml # Parsed by GitLab for MR widget
default:
cache:
key:
files:
- package-lock.json # Cache key changes when lockfile changes
paths:
- node_modules/
policy: pull-push # pull: restore only, push: save only, pull-push: both
ArtifactsCache
PurposePass files between stages/jobs in the same pipelineSpeed up jobs across pipeline runs
ScopeSingle pipelineAcross pipelines (same branch, then default branch)
ExampleBuild output, test reportsnode_modules, .pip, .gradle
ExpirationConfigurable (expire_in)LRU eviction
# In .gitlab-ci.yml
variables:
APP_NAME: myapp
DEPLOY_TIMEOUT: "300"
# Job-level variables (override pipeline-level)
deploy:
variables:
APP_ENV: production
script:
- echo "Deploying $APP_NAME to $APP_ENV"

Set secrets in Settings > CI/CD > Variables:

SettingWhat It Does
MaskedValue is hidden in job logs (***)
ProtectedOnly available in protected branches/tags
FileWritten to a temp file; $VAR contains the file path
Environment scopeOnly available for jobs targeting a specific environment

GitLab provides 100+ predefined variables:

VariableValue
CI_COMMIT_SHAFull commit SHA
CI_COMMIT_SHORT_SHAFirst 8 chars
CI_COMMIT_BRANCHBranch name
CI_COMMIT_TAGTag name (if tag pipeline)
CI_PIPELINE_IDPipeline ID
CI_PROJECT_NAMERepository name
CI_REGISTRY_IMAGEContainer registry image path
CI_JOB_TOKENToken for API access within the pipeline
deploy-staging:
stage: deploy
script:
- kubectl apply -f k8s/staging/
environment:
name: staging
url: https://staging.myapp.com
on_stop: stop-staging # Job to run when environment is stopped
stop-staging:
stage: deploy
script:
- kubectl delete -f k8s/staging/
environment:
name: staging
action: stop
when: manual

Review apps create a temporary environment for each merge request:

review:
stage: deploy
script:
- helm install review-$CI_MERGE_REQUEST_IID ./chart \
--set image.tag=$CI_COMMIT_SHORT_SHA \
--set ingress.host=$CI_MERGE_REQUEST_IID.review.myapp.com
environment:
name: review/$CI_MERGE_REQUEST_IID
url: https://$CI_MERGE_REQUEST_IID.review.myapp.com
on_stop: stop-review
auto_stop_in: 1 week
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
stop-review:
stage: deploy
script:
- helm uninstall review-$CI_MERGE_REQUEST_IID
environment:
name: review/$CI_MERGE_REQUEST_IID
action: stop
when: manual
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"

Each MR gets its own URL — reviewers can test the change live before merging.

TypeScopeWho Manages
SharedAvailable to all projects on the instanceGitLab (SaaS) or instance admin
GroupAvailable to all projects in a groupGroup owner
ProjectAvailable to a single projectProject maintainer

The executor determines how the runner runs jobs:

ExecutorHow It RunsBest For
DockerEach job runs in a fresh Docker containerMost common — clean, reproducible
KubernetesEach job runs as a Kubernetes podAuto-scaling on K8s clusters
ShellRuns directly on the runner’s OSSimple, but no isolation
Docker MachineAuto-provisions cloud VMs (Docker Machine)Auto-scaling on cloud (legacy)
InstanceAuto-provisions cloud VMs (newer, replaces Docker Machine)Auto-scaling on cloud
Terminal window
# Install GitLab Runner
curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | sudo bash
sudo apt install gitlab-runner
# Register with the GitLab instance
sudo gitlab-runner register \
--url https://gitlab.com \
--registration-token <TOKEN> \
--executor docker \
--docker-image node:20-alpine \
--description "Docker runner" \
--tag-list "docker,linux"
build:
tags:
- docker # Only run on runners with the "docker" tag
- linux
script:
- make build

By default, stages are sequential. The needs keyword creates a Directed Acyclic Graph (DAG) — jobs start as soon as their dependencies finish, regardless of stage:

stages:
- build
- test
- deploy
build-frontend:
stage: build
script: npm run build:frontend
artifacts:
paths: [frontend/dist/]
build-backend:
stage: build
script: go build -o api ./cmd/api
test-frontend:
stage: test
needs: [build-frontend] # Starts as soon as build-frontend finishes
script: npm run test:frontend
test-backend:
stage: test
needs: [build-backend] # Doesn't wait for build-frontend
script: go test ./...
deploy:
stage: deploy
needs: [test-frontend, test-backend] # Waits for both tests
script: ./deploy.sh
build-frontend ──► test-frontend ──┐
├──► deploy
build-backend ──► test-backend ──┘

Without needs, test-frontend would wait for both build-frontend AND build-backend to finish (because they’re in the same stage).

Split a large .gitlab-ci.yml into smaller files:

# .gitlab-ci.yml (parent)
stages:
- triggers
trigger-frontend:
stage: triggers
trigger:
include: frontend/.gitlab-ci.yml
strategy: depend # Parent pipeline waits for child
trigger-backend:
stage: triggers
trigger:
include: backend/.gitlab-ci.yml
strategy: depend

Trigger a pipeline in another project:

deploy-infra:
stage: deploy
trigger:
project: myorg/infrastructure # Trigger pipeline in another repo
branch: main
strategy: depend

Import pipeline definitions from external sources:

include:
# From the same project
- local: 'templates/deploy.yml'
# From another project
- project: 'myorg/ci-templates'
ref: main
file: '/templates/docker-build.yml'
# From a URL
- remote: 'https://example.com/ci-templates/lint.yml'
# From a CI/CD template gallery
- template: Security/SAST.gitlab-ci.yml

Inherit from a hidden job (prefixed with .):

.deploy-template: # Hidden job (template)
image: bitnami/kubectl:latest
before_script:
- kubectl config use-context $KUBE_CONTEXT
script:
- kubectl apply -f k8s/$APP_ENV/
- kubectl rollout status deployment/$APP_NAME
deploy-staging:
extends: .deploy-template
variables:
APP_ENV: staging
KUBE_CONTEXT: staging-cluster
environment:
name: staging
deploy-production:
extends: .deploy-template
variables:
APP_ENV: production
KUBE_CONTEXT: prod-cluster
environment:
name: production
when: manual

Auto DevOps is GitLab’s convention-over-configuration pipeline. Enable it and GitLab automatically:

  1. Builds your app (auto-detects language via buildpack).
  2. Tests with built-in test suites.
  3. Scans for vulnerabilities (SAST, DAST, dependency scanning, container scanning).
  4. Deploys to Kubernetes (if a cluster is connected).
  5. Creates review apps for merge requests.
  6. Sets up monitoring with Prometheus.
# Enable in .gitlab-ci.yml (or via Settings > CI/CD)
include:
- template: Auto-DevOps.gitlab-ci.yml

Auto DevOps is great for getting started quickly, but most teams customize their pipeline as complexity grows.

GitLab CI supports OIDC for cloud authentication without stored secrets:

deploy-aws:
image: amazon/aws-cli:latest
id_tokens:
AWS_TOKEN:
aud: https://gitlab.com # Audience claim
script:
- >
export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s"
$(aws sts assume-role-with-web-identity
--role-arn arn:aws:iam::123456789012:role/gitlab-deploy
--role-session-name "GitLabCI-${CI_JOB_ID}"
--web-identity-token "${AWS_TOKEN}"
--query "Credentials.[AccessKeyId,SecretAccessKey,SessionToken]"
--output text))
- aws s3 ls
stages:
- test
- build
- deploy
default:
image: node:20-alpine
cache:
key:
files: [package-lock.json]
paths: [node_modules/]
lint:
stage: test
script:
- npm ci
- npm run lint
test:
stage: test
script:
- npm ci
- npm test -- --coverage
coverage: '/Lines\s*:\s*(\d+\.?\d*)%/'
artifacts:
reports:
junit: junit.xml
build:
stage: build
script:
- npm ci
- npm run build
artifacts:
paths: [dist/]
expire_in: 1 day
rules:
- if: $CI_COMMIT_BRANCH == "main"
build-image:
image: docker:24
services:
- docker:24-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_SHORT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
- docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA $CI_REGISTRY_IMAGE:latest
- docker push $CI_REGISTRY_IMAGE:latest
rules:
- if: $CI_COMMIT_BRANCH == "main"
deploy-staging:
image: bitnami/kubectl:latest
stage: deploy
script:
- kubectl set image deployment/myapp myapp=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
- kubectl rollout status deployment/myapp --timeout=120s
environment:
name: staging
url: https://staging.myapp.com
rules:
- if: $CI_COMMIT_BRANCH == "main"
FeatureGitLab CIGitHub Actions
Config file.gitlab-ci.yml (single).github/workflows/*.yml (multiple)
StagesExplicit stages: blockImplicit via needs
Reuseinclude, extends, templatesReusable workflows, composite actions
MarketplaceSmaller (include from URLs)Large marketplace
RunnersShared + group + project, multiple executorsGitHub-hosted + self-hosted
EnvironmentsBuilt-in with review appsBuilt-in with protection rules
Container registryBuilt-in ($CI_REGISTRY)GitHub Packages (GHCR)
OIDCid_tokens keywordBuilt-in provider
DAGneeds keywordneeds in jobs
Auto DevOpsYes (convention-over-configuration)No equivalent
Parent-child pipelinesYes (trigger + include)No direct equivalent
Review appsBuilt-in with dynamic environmentsManual setup
Best forAll-in-one DevOps platformGitHub-hosted projects
  • GitLab CI is configured in a single .gitlab-ci.yml file at the repository root.
  • Stages define execution order; jobs in the same stage run in parallel.
  • Use rules (not only/except) for conditional job execution.
  • needs creates DAG pipelines for faster execution (skip waiting for unrelated stages).
  • include and extends enable reusable pipeline templates across projects.
  • Review apps create temporary environments for every merge request.
  • Runners come in Docker, Kubernetes, and Shell executors — Docker is most common.
  • Auto DevOps provides a zero-config pipeline for building, testing, scanning, and deploying.
  • GitLab CI shines as an all-in-one platform — code, CI/CD, registry, environments, and monitoring in one tool.