Introduction
GitLab CI/CD takes a fundamentally different approach than GitHub Actions or Jenkins. Where those tools bolt CI/CD onto a code hosting platform (or vice versa), GitLab was designed from day one as a single application for the entire DevOps lifecycle — from planning and source control through CI/CD, security scanning, monitoring, and incident management.
This "single platform" philosophy means CI/CD in GitLab isn't a separate service you configure — it's a native capability that activates the moment you add a .gitlab-ci.yml file to your repository root. No marketplace to browse, no third-party integrations to wire up, no separate authentication to manage.
GitLab vs GitHub Approach
The philosophical difference matters for pipeline design:
- GitHub: Composable ecosystem — pick best-of-breed actions from the marketplace, wire them together. Maximum flexibility, more integration work.
- GitLab: Integrated platform — everything built-in with consistent UX. Less flexibility, but zero integration overhead. Security scanning, container registry, environments, and monitoring all share the same configuration syntax.
Neither approach is "better" — they optimise for different constraints. GitLab excels in regulated environments where auditability and a single source of truth matter. GitHub excels in open-source and organisations that want maximum tool choice.
Pipeline Fundamentals
A GitLab pipeline is defined by a single file: .gitlab-ci.yml at the repository root. This file declares stages (ordered execution phases) and jobs (units of work assigned to stages).
flowchart LR
subgraph Build Stage
A[compile]
B[lint]
end
subgraph Test Stage
C[unit-tests]
D[integration-tests]
E[security-scan]
end
subgraph Deploy Stage
F[staging]
G[production]
end
A --> C
A --> D
B --> C
C --> F
D --> F
E --> F
F -->|Manual| G
style A fill:#3B9797,color:#fff
style B fill:#3B9797,color:#fff
style C fill:#16476A,color:#fff
style D fill:#16476A,color:#fff
style E fill:#16476A,color:#fff
style F fill:#132440,color:#fff
style G fill:#BF092F,color:#fff
Here's a complete, production-ready .gitlab-ci.yml:
# .gitlab-ci.yml
stages:
- build
- test
- security
- deploy
variables:
NODE_VERSION: "20"
DOCKER_DRIVER: overlay2
GIT_DEPTH: 20
default:
image: node:${NODE_VERSION}-alpine
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
policy: pull
before_script:
- npm ci --prefer-offline
# Build Stage
compile:
stage: build
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
policy: pull-push # This job populates the cache
script:
- npm run build
artifacts:
paths:
- dist/
expire_in: 1 hour
lint:
stage: build
script:
- npm run lint
allow_failure: true
# Test Stage
unit-tests:
stage: test
script:
- npm run test:unit -- --coverage
coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
artifacts:
reports:
junit: reports/junit.xml
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
integration-tests:
stage: test
services:
- postgres:16-alpine
- redis:7-alpine
variables:
POSTGRES_DB: test_db
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_pass
DATABASE_URL: "postgres://test_user:test_pass@postgres:5432/test_db"
REDIS_URL: "redis://redis:6379"
script:
- npm run test:integration
# Security Stage
sast:
stage: security
include:
- template: Security/SAST.gitlab-ci.yml
dependency-scan:
stage: security
include:
- template: Security/Dependency-Scanning.gitlab-ci.yml
# Deploy Stage
deploy-staging:
stage: deploy
environment:
name: staging
url: https://staging.example.com
script:
- ./deploy.sh staging
rules:
- if: $CI_COMMIT_BRANCH == "main"
deploy-production:
stage: deploy
environment:
name: production
url: https://app.example.com
script:
- ./deploy.sh production
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
needs:
- deploy-staging
Pipeline Visualization
GitLab renders pipeline graphs in the UI showing job status, stage grouping, and dependency relationships. Jobs within a stage run in parallel by default; stages execute sequentially. The needs: keyword overrides this with explicit DAG (Directed Acyclic Graph) dependencies.
Job Configuration
Jobs are the fundamental building blocks of GitLab CI/CD. Each job runs in isolation (its own container) and can be configured with scripts, images, services, variables, artifacts, and rules.
# Complete job configuration reference
my-job:
stage: test
image: python:3.12-slim
# Services: linked Docker containers (databases, caches, etc.)
services:
- name: postgres:16
alias: db
variables:
POSTGRES_DB: myapp
POSTGRES_PASSWORD: secret
# Variables: environment-specific configuration
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.pip-cache"
DATABASE_URL: "postgres://postgres:secret@db:5432/myapp"
# Scripts: execution phases
before_script:
- pip install --cache-dir $PIP_CACHE_DIR -r requirements.txt
script:
- pytest tests/ --junitxml=report.xml --cov=app --cov-report=xml
after_script:
- echo "Cleanup tasks run even if script fails"
# Cache: persisted between pipeline runs
cache:
key: pip-$CI_COMMIT_REF_SLUG
paths:
- .pip-cache/
# Artifacts: passed to downstream jobs and available for download
artifacts:
paths:
- coverage/
reports:
junit: report.xml
coverage_report:
coverage_format: cobertura
path: coverage.xml
expire_in: 7 days
when: always # Upload artifacts even on failure
# Rules: control when job runs
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_TAG
# Resource controls
timeout: 30 minutes
retry:
max: 2
when:
- runner_system_failure
- stuck_or_timeout_failure
interruptible: true
# Tags: select specific runners
tags:
- docker
- linux
Artifacts vs Cache
Artifacts pass data between jobs in the same pipeline. They're uploaded to GitLab after a job completes and downloaded by dependent jobs. Use for build outputs, test reports, compiled binaries.
Cache persists data between pipeline runs on the same runner. It's a performance optimization — never rely on cache for correctness (it can be evicted). Use for dependencies (node_modules, pip packages, Maven .m2).
Rule of thumb: If the pipeline would break without the data, use artifacts. If the pipeline would be slow without it, use cache.
Pipeline Types
GitLab supports four pipeline architectures, each suited to different complexity levels:
Basic Pipeline (Sequential Stages)
# Jobs within a stage run in parallel
# Stages run sequentially
stages:
- build
- test
- deploy
build-app:
stage: build
script: make build
test-unit:
stage: test
script: make test-unit
test-e2e:
stage: test
script: make test-e2e
deploy:
stage: deploy
script: make deploy
DAG Pipeline (needs keyword)
flowchart LR
A[build-frontend] --> C[test-frontend]
B[build-backend] --> D[test-backend]
B --> E[test-integration]
C --> F[deploy]
D --> F
E --> F
style A fill:#3B9797,color:#fff
style B fill:#3B9797,color:#fff
style C fill:#16476A,color:#fff
style D fill:#16476A,color:#fff
style E fill:#16476A,color:#fff
style F fill:#132440,color:#fff
# DAG: jobs start immediately when their dependencies finish
# No waiting for entire stage to complete
stages:
- build
- test
- deploy
build-frontend:
stage: build
script: cd frontend && npm run build
artifacts:
paths: [frontend/dist/]
build-backend:
stage: build
script: cd backend && go build -o bin/server
artifacts:
paths: [backend/bin/]
test-frontend:
stage: test
needs: [build-frontend] # Starts immediately after build-frontend
script: cd frontend && npm test
test-backend:
stage: test
needs: [build-backend] # Doesn't wait for build-frontend
script: cd backend && go test ./...
test-integration:
stage: test
needs: [build-backend]
script: ./run-integration-tests.sh
deploy:
stage: deploy
needs: [test-frontend, test-backend, test-integration]
script: ./deploy.sh
Parent-Child Pipelines
# Parent pipeline triggers child pipelines dynamically
# Useful for monorepos where each service has its own CI config
# .gitlab-ci.yml (parent)
stages:
- trigger
trigger-frontend:
stage: trigger
trigger:
include: frontend/.gitlab-ci.yml
strategy: depend # Parent waits for child to complete
rules:
- changes:
- frontend/**/*
trigger-backend:
stage: trigger
trigger:
include: backend/.gitlab-ci.yml
strategy: depend
rules:
- changes:
- backend/**/*
# frontend/.gitlab-ci.yml (child)
stages:
- build
- test
build:
stage: build
image: node:20
script:
- cd frontend
- npm ci
- npm run build
test:
stage: test
image: node:20
script:
- cd frontend
- npm ci
- npm test
Includes & Templates
GitLab's include: keyword lets you compose pipelines from reusable components. This is how organisations standardise CI/CD across hundreds of projects.
# Include types
include:
# Local: file in the same repository
- local: '/ci/templates/docker-build.yml'
# Remote: file from any URL
- remote: 'https://example.com/ci/shared-pipeline.yml'
# Template: GitLab's built-in templates
- template: Security/SAST.gitlab-ci.yml
- template: Security/Dependency-Scanning.gitlab-ci.yml
# Component: CI/CD catalog component (GitLab 16.0+)
- component: gitlab.com/my-org/ci-components/docker-build@1.0.0
inputs:
image_name: my-app
registry: registry.example.com
# Project: file from another GitLab project
- project: 'my-org/ci-templates'
ref: main
file:
- '/templates/node-pipeline.yml'
- '/templates/deploy.yml'
Creating a reusable template with extends: and hidden jobs:
# ci/templates/node-pipeline.yml (shared template)
# Hidden jobs (prefixed with .) serve as templates
.node-base:
image: node:${NODE_VERSION:-20}-alpine
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
before_script:
- npm ci --prefer-offline
.deploy-base:
image: alpine:3.19
before_script:
- apk add --no-cache curl kubectl
- kubectl config set-cluster k8s --server=$KUBE_SERVER
- kubectl config set-credentials deployer --token=$KUBE_TOKEN
- kubectl config set-context deploy --cluster=k8s --user=deployer
- kubectl config use-context deploy
# Consumers extend these templates:
# my-project/.gitlab-ci.yml
include:
- project: 'my-org/ci-templates'
file: '/ci/templates/node-pipeline.yml'
test:
extends: .node-base
stage: test
script:
- npm test
deploy:
extends: .deploy-base
stage: deploy
script:
- kubectl apply -f k8s/
GitLab Runners
GitLab Runners are agents that execute CI/CD jobs. They connect to your GitLab instance and poll for available work. Runners can be registered at instance, group, or project level.
Docker executor: Most common. Each job runs in a fresh container. Clean isolation, reproducible builds. Requires Docker on the runner host.
Kubernetes executor: Spawns pods for each job. Auto-scales with cluster capacity. Ideal for cloud-native teams running on EKS/GKE/AKS.
Shell executor: Runs jobs directly on the runner's OS. No isolation between jobs. Only use for specialised hardware (GPUs, embedded devices).
Docker Machine executor (legacy): Auto-provisions VMs in the cloud. Being replaced by the Kubernetes executor and GitLab's fleeting model.
# Runner registration and configuration (config.toml)
concurrent = 10
check_interval = 3
[[runners]]
name = "docker-runner-01"
url = "https://gitlab.example.com"
token = "REGISTRATION_TOKEN"
executor = "docker"
[runners.docker]
image = "alpine:3.19"
privileged = false
disable_entrypoint_overwrite = false
volumes = ["/cache", "/var/run/docker.sock:/var/run/docker.sock"]
pull_policy = ["if-not-present"]
[runners.cache]
Type = "s3"
Shared = true
[runners.cache.s3]
ServerAddress = "s3.amazonaws.com"
BucketName = "gitlab-runner-cache"
BucketLocation = "us-east-1"
Auto DevOps
Auto DevOps is GitLab's zero-configuration CI/CD. When enabled, it automatically detects your project type, builds a Docker image, runs tests, performs security scans, and deploys to Kubernetes — all without writing a single line of CI/CD configuration.
Auto DevOps stages:
- Auto Build — Detects language (Node, Python, Java, Go, etc.) via buildpack and creates a Docker image
- Auto Test — Runs language-specific tests (npm test, pytest, go test)
- Auto Code Quality — Analyses code with CodeClimate
- Auto SAST/DAST — Static and dynamic application security testing
- Auto Dependency Scanning — Checks dependencies for known vulnerabilities
- Auto Container Scanning — Scans Docker images for OS-level vulnerabilities
- Auto Review Apps — Deploys each branch to a temporary environment
- Auto Deploy — Deploys to staging/production Kubernetes clusters
- Auto Monitoring — Configures Prometheus metrics collection
# Enable Auto DevOps with customizations
include:
- template: Auto-DevOps.gitlab-ci.yml
variables:
# Override Auto DevOps defaults
AUTO_DEVOPS_DEPLOY_DEBUG: "true"
POSTGRES_ENABLED: "true"
STAGING_ENABLED: "true"
INCREMENTAL_ROLLOUT_ENABLED: "true"
# Kubernetes namespace
KUBE_NAMESPACE: my-app-production
# Custom Dockerfile (skip buildpack auto-detection)
AUTO_DEVOPS_BUILD_IMAGE_EXTRA_ARGS: "--build-arg NODE_ENV=production"
# Override specific Auto DevOps jobs
test:
variables:
CI_DEBUG_TRACE: "true"
script:
- npm ci
- npm run test:ci
- npm run test:e2e
.gitlab-ci.yml files — but Auto DevOps gives you a solid starting point to customise from.
Container Registry Integration
GitLab includes a built-in container registry — no external Docker Hub or ECR needed. Every project gets a registry at registry.gitlab.com/<namespace>/<project>, authenticated with your GitLab credentials.
# Build and push Docker images to GitLab Container Registry
build-image:
stage: build
image: docker:24
services:
- docker:24-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
- docker build --pull -t $IMAGE_TAG .
- docker push $IMAGE_TAG
# Tag as latest for main branch
- |
if [ "$CI_COMMIT_BRANCH" == "main" ]; then
docker tag $IMAGE_TAG $CI_REGISTRY_IMAGE:latest
docker push $CI_REGISTRY_IMAGE:latest
fi
# Multi-stage build with BuildKit and caching
build-optimized:
stage: build
image: docker:24
services:
- docker:24-dind
variables:
DOCKER_BUILDKIT: "1"
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- |
docker build \
--cache-from $CI_REGISTRY_IMAGE:cache \
--build-arg BUILDKIT_INLINE_CACHE=1 \
-t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA \
-t $CI_REGISTRY_IMAGE:cache \
.
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
- docker push $CI_REGISTRY_IMAGE:cache
Environments & Deployments
GitLab environments track where your code is deployed. They provide deployment history, rollback capabilities, and integration with monitoring. Environments can be static (production, staging) or dynamic (per-branch review apps).
# Static environments with protection
deploy-staging:
stage: deploy
environment:
name: staging
url: https://staging.example.com
on_stop: stop-staging
script:
- kubectl apply -f k8s/ --namespace=staging
rules:
- if: $CI_COMMIT_BRANCH == "main"
deploy-production:
stage: deploy
environment:
name: production
url: https://app.example.com
script:
- kubectl apply -f k8s/ --namespace=production
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
resource_group: production # Only one deploy at a time
# Dynamic environments (Review Apps)
deploy-review:
stage: deploy
environment:
name: review/$CI_COMMIT_REF_SLUG
url: https://$CI_COMMIT_REF_SLUG.review.example.com
on_stop: stop-review
auto_stop_in: 1 week
script:
- helm upgrade --install review-$CI_COMMIT_REF_SLUG ./chart
--set image.tag=$CI_COMMIT_SHORT_SHA
--set ingress.host=$CI_COMMIT_REF_SLUG.review.example.com
rules:
- if: $CI_MERGE_REQUEST_IID
stop-review:
stage: deploy
environment:
name: review/$CI_COMMIT_REF_SLUG
action: stop
script:
- helm uninstall review-$CI_COMMIT_REF_SLUG
rules:
- if: $CI_MERGE_REQUEST_IID
when: manual
Security Scanning
GitLab provides integrated security scanning as part of CI/CD — results appear directly in merge requests, and vulnerabilities are tracked in the Security Dashboard.
# Enable all security scanners via includes
include:
- template: Security/SAST.gitlab-ci.yml
- template: Security/Secret-Detection.gitlab-ci.yml
- template: Security/Dependency-Scanning.gitlab-ci.yml
- template: Security/Container-Scanning.gitlab-ci.yml
- template: Security/DAST.gitlab-ci.yml
- template: Security/License-Scanning.gitlab-ci.yml
# Customize SAST settings
variables:
SAST_EXCLUDED_PATHS: "spec,test,vendor"
SAST_BANDIT_EXCLUDED_PATHS: "*/test/*"
# Container scanning targets your built image
container_scanning:
variables:
CS_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
# DAST targets your deployed review app
dast:
variables:
DAST_WEBSITE: https://$CI_COMMIT_REF_SLUG.review.example.com
needs: [deploy-review]
Security scan results integrate with merge requests — reviewers see new vulnerabilities introduced by the change before merging. Critical vulnerabilities can block the merge via approval rules.
Advanced Features
Rules vs Only/Except
# Modern: rules (recommended — more powerful and explicit)
deploy:
rules:
- if: $CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"
when: on_success
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
when: on_success
- when: never # Explicit default
# Legacy: only/except (deprecated — avoid in new projects)
deploy:
only:
- main
- tags
except:
- schedules
Multi-Project Pipelines
# Trigger a pipeline in another project
trigger-deploy-repo:
stage: deploy
trigger:
project: my-org/infrastructure/deploy
branch: main
strategy: depend
variables:
UPSTREAM_PROJECT: $CI_PROJECT_PATH
UPSTREAM_SHA: $CI_COMMIT_SHA
UPSTREAM_REF: $CI_COMMIT_REF_NAME
Resource Groups (Deployment Mutex)
# Ensure only one deployment runs at a time per environment
deploy-production:
resource_group: production
script: ./deploy.sh
# If two pipelines trigger simultaneously, the second
# waits for the first to complete before starting
Merge Request Pipelines
GitLab offers three levels of merge request pipeline sophistication:
- Detached pipelines — Run on the source branch HEAD. Fast but may miss merge conflicts.
- Merge result pipelines — Run on a temporary merge of source into target. Catches integration issues before merging.
- Merge trains — Queue multiple MRs and test them merged sequentially. Ensures main branch never breaks even under high merge velocity.
# Configure merge request pipelines
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_TAG
# Jobs only run in MR context
test:
script: npm test
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
Performance & Optimization
1. Use DAG pipelines — Replace sequential stages with needs: to start jobs as soon as dependencies finish, not when entire stages complete.
2. Cache aggressively — Use cache:key:files to create lockfile-based cache keys. Set policy: pull on most jobs and pull-push only on the job that installs dependencies.
3. Interruptible jobs — Mark jobs as interruptible: true to auto-cancel them when a new pipeline starts for the same branch.
4. Rules-based filtering — Use rules:changes to skip jobs when their relevant files haven't changed.
5. Parent-child pipelines — Split monorepo pipelines into independent child pipelines that run in parallel.
# Optimized pipeline with interruptible jobs and change-based rules
workflow:
auto_cancel:
on_new_commit: interruptible
test-frontend:
interruptible: true
rules:
- changes:
- frontend/**/*
- package.json
script: cd frontend && npm test
test-backend:
interruptible: true
rules:
- changes:
- backend/**/*
- go.mod
script: cd backend && go test ./...
Exercises
Create a .gitlab-ci.yml for a Node.js application with: build → test (unit + integration with PostgreSQL service) → security scan (SAST + dependency scanning) → deploy to staging (automatic) → deploy to production (manual with environment protection). Use caching for node_modules and artifacts for build output.
Design a parent pipeline that detects changes in frontend/, backend/, and infrastructure/ directories. Each changed directory should trigger a child pipeline with its own build, test, and deploy stages. Use strategy: depend so the parent reports child pipeline status.
Create a shared CI/CD template project with reusable hidden jobs for: Docker image building, Kubernetes deployment, and Slack notifications. Configure a consumer project to include these templates and extend them with project-specific configuration.
Implement dynamic review environments that create a unique deployment for every merge request. Include auto-stop after 1 week, a manual stop job, and a Helm-based deployment. The environment URL should be $CI_COMMIT_REF_SLUG.review.example.com.