What is GitLab CI/CD?
GitLab CI/CD is an integrated continuous integration and continuous delivery/deployment system built directly into the GitLab platform. Instead of relying on external services like Jenkins or Travis CI, GitLab provides a complete DevOps pipeline solution that lives alongside your repositories. At its core, GitLab CI/CD automatically builds, tests, and deploys your code every time you push changes to your repository, merge branches, or create tags.
The system is powered by a configuration file called .gitlab-ci.yml that lives in the root of your repository. This single YAML file defines your entire pipeline — every job, every stage, every environment variable, every deployment target. When GitLab detects changes in your repository, it reads this file, parses the pipeline definition, and dispatches jobs to runners that execute them. Runners are lightweight agents that can run on your own infrastructure, in Docker containers, on Kubernetes clusters, or on GitLab's shared fleet.
Why GitLab CI/CD Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →The value of GitLab CI/CD extends far beyond simple automation. Here are the key reasons it has become essential for modern development teams:
- Single source of truth: Your CI/CD configuration lives in the same repository as your code. Every branch, every commit, every tag carries its own pipeline definition. This eliminates the disconnect between "what the code does" and "how the code gets deployed."
- Branch strategy enforcement: You can define different pipeline behaviors for different branches. Feature branches might run full test suites while hotfix branches trigger accelerated deployment paths with specific approval gates.
- Security and compliance: Every pipeline run is fully logged and auditable. You know exactly who triggered what, when, and with which variables. Secrets management through masked variables keeps credentials out of source code.
- Cost efficiency: Because CI/CD is integrated into GitLab (both SaaS and self-hosted), you avoid the operational overhead of maintaining separate CI servers, plugins, and integrations.
- Docker-native workflows: Every job can run inside an isolated container with precisely the tools you need, eliminating "works on my machine" problems in CI environments.
Core Concepts
Pipelines
A pipeline is the top-level construct that orchestrates everything. When you push code to GitLab, a pipeline is created automatically. Pipelines consist of stages, and stages contain jobs. Pipelines can be triggered by pushes, merges, tags, scheduled cron expressions, or even manual button clicks from the GitLab UI.
Stages
Stages define the sequential phases of your pipeline. Common stages include build, test, staging, and deploy. Jobs within the same stage run in parallel (if sufficient runners are available), while stages themselves execute sequentially. If any job in a stage fails, subsequent stages are not executed by default, preventing broken code from reaching production.
Jobs
A job is the smallest unit of work in GitLab CI. Each job runs a set of commands in a fresh environment. Jobs can pull Docker images, install dependencies, run scripts, produce artifacts, and trigger downstream actions. Jobs are defined with a script keyword and can optionally specify Docker images, environment variables, cache rules, and artifact paths.
Runners
Runners are the execution agents that pick up jobs from GitLab and run them. A runner can be specific to a project, shared across a group, or available to an entire GitLab instance. Runners communicate with GitLab via a polling mechanism and support multiple executors: Shell, Docker, Docker Machine, Kubernetes, and custom executors.
Artifacts
Artifacts are files generated by a job that are passed to subsequent jobs or made available for download. A compiled binary from a build stage can be passed as an artifact to a deploy stage, eliminating the need to rebuild. Artifacts have expiration policies to manage storage.
Setting Up Your First .gitlab-ci.yml
Let's create a complete, production-ready pipeline configuration from scratch. Place this file at the root of your repository as .gitlab-ci.yml:
# .gitlab-ci.yml
# Complete pipeline definition for a Node.js application
# Global pipeline configuration
default:
image: node:18-alpine
tags:
- docker
# Retry failed jobs automatically (transient failures)
retry:
max: 2
when:
- runner_system_failure
- stuck_or_timeout_failure
# Global cache configuration for node_modules
# Caches are shared between jobs on the same branch
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
policy: pull-push
# Define the stages in execution order
stages:
- install
- lint
- test
- build
- deploy-staging
- deploy-production
# ─── INSTALL STAGE ────────────────────────
# Install dependencies once and cache them
install_dependencies:
stage: install
script:
- npm ci --prefer-offline --no-audit
- echo "Dependencies installed successfully"
artifacts:
paths:
- node_modules/
expire_in: 1 hour
# Only run on default branch and merge requests
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# ─── LINT STAGE ───────────────────────────
# Run ESLint and Prettier checks
lint_code:
stage: lint
needs:
- install_dependencies
script:
- npm run lint
- npm run format:check
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# ─── TEST STAGE ───────────────────────────
# Run unit tests with coverage reporting
run_unit_tests:
stage: test
needs:
- install_dependencies
script:
- npm run test:ci
- npm run test:coverage
artifacts:
when: always
paths:
- coverage/
reports:
junit: junit.xml
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# Integration tests (runs in parallel with unit tests)
run_integration_tests:
stage: test
needs:
- install_dependencies
script:
- npm run test:integration
services:
- postgres:15-alpine
variables:
DATABASE_URL: postgres://postgres:postgres@postgres:5432/testdb
POSTGRES_PASSWORD: postgres
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# ─── BUILD STAGE ──────────────────────────
# Build production artifacts
build_application:
stage: build
needs:
- run_unit_tests
- run_integration_tests
- lint_code
script:
- npm run build
artifacts:
paths:
- dist/
- package.json
- package-lock.json
expire_in: 1 week
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# Build Docker image
build_docker_image:
stage: build
needs:
- build_application
image: docker:24-dind
services:
- docker:24-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- 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 == $CI_DEFAULT_BRANCH
# ─── DEPLOY STAGING ──────────────────────
deploy_to_staging:
stage: deploy-staging
needs:
- build_docker_image
image: alpine:3.19
environment:
name: staging
url: https://staging.example.com
script:
- apk add --no-cache curl bash
- |
curl -X POST https://api.kubernetes.example.com/deploy \
-H "Authorization: Bearer ${K8S_DEPLOY_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"image\": \"${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}\",
\"namespace\": \"staging\",
\"service\": \"my-app-staging\"
}"
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# ─── DEPLOY PRODUCTION ───────────────────
deploy_to_production:
stage: deploy-production
needs:
- build_docker_image
image: alpine:3.19
environment:
name: production
url: https://production.example.com
script:
- apk add --no-cache curl bash
- |
curl -X POST https://api.kubernetes.example.com/deploy \
-H "Authorization: Bearer ${K8S_DEPLOY_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"image\": \"${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}\",
\"namespace\": \"production\",
\"service\": \"my-app-production\"
}"
# Manual approval for production deployment
when: manual
# Only allow manual trigger on default branch
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
allow_failure: false
# Require specific approvers (configured in GitLab settings)
# This job will wait for manual click in the UI
This configuration demonstrates a realistic pipeline for a Node.js application. It uses six stages, caches node_modules intelligently, runs integration tests against a real PostgreSQL database using GitLab services, builds both application artifacts and Docker images, and deploys to staging and production environments with a manual approval gate on production.
Pipeline Stages Deep Dive
Understanding how to structure stages effectively is critical to building efficient pipelines. Let's explore advanced stage configurations:
Conditional Stages with Rules
The rules keyword is the modern way to control when jobs run. It replaces the older only/except syntax and provides much finer control. Each rule is evaluated in order, and the first matching rule determines the job's behavior:
# Advanced rules configuration example
security_scan:
stage: test
script:
- trivy image --severity HIGH,CRITICAL $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
rules:
# Run on default branch pushes
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: always
# Run on merge requests
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: always
# Run on tagged commits (releases)
- if: $CI_COMMIT_TAG
when: always
# Don't run on draft MRs or WIP commits
- if: $CI_MERGE_REQUEST_DRAFT
when: never
# Default: don't run for other cases
- when: never
# Scheduled security scans
nightly_full_scan:
stage: test
script:
- trivy image --severity UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL $CI_REGISTRY_IMAGE:latest
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
when: always
- when: never
The needs Keyword (Directed Acyclic Graph)
By default, jobs wait for all jobs in the previous stage to complete. The needs keyword creates a directed acyclic graph (DAG) where a job can start as soon as its specific dependencies finish, without waiting for the entire previous stage. This dramatically reduces pipeline duration:
# DAG pipeline example — jobs run as soon as dependencies complete
stages:
- build
- test
- deploy
build_backend:
stage: build
script: make build-backend
artifacts:
paths: [backend-binary]
build_frontend:
stage: build
script: make build-frontend
artifacts:
paths: [frontend-bundle]
# This job starts immediately after build_backend finishes
# It does NOT wait for build_frontend
test_backend:
stage: test
needs: [build_backend]
script: make test-backend
# This starts immediately after build_frontend finishes
test_frontend:
stage: test
needs: [build_frontend]
script: make test-frontend
# This starts only when both tests complete
deploy:
stage: deploy
needs: [test_backend, test_frontend]
script: make deploy
environment: production
Variables and Environments
Variables in GitLab CI/CD come from multiple sources and follow a strict precedence order. Understanding this hierarchy is essential for secure configuration:
# Variable demonstration with different scopes
# Precedence (lowest to highest):
# 1. Project variables (Settings > CI/CD > Variables)
# 2. Group variables
# 3. .gitlab-ci.yml variables
# 4. Pipeline variables (triggered via API)
# 5. Manual pipeline run variables
variables:
# Global variables available to all jobs
NODE_ENV: production
DOCKER_DRIVER: overlay2
# Variables can reference other variables
DEPLOY_PATH: /opt/app/${CI_PROJECT_NAME}
# Job-specific variables override global ones
deploy_to_aws:
stage: deploy
variables:
NODE_ENV: staging # Overrides global NODE_ENV
AWS_REGION: us-east-1
AWS_ACCOUNT_ID: "123456789012"
script:
- echo "Deploying to ${NODE_ENV} in ${AWS_REGION}"
- aws ecs update-service --cluster ${CI_PROJECT_NAME}-${NODE_ENV}
environment:
name: staging
url: https://staging.${CI_PROJECT_NAME}.example.com
# Masked variables for secrets (configured in GitLab UI)
# These are NEVER printed in logs
deploy_with_secrets:
stage: deploy
script:
# $AWS_SECRET_ACCESS_KEY is masked — safe to use in scripts
- export AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
- export AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
- aws s3 cp dist/ s3://${CI_PROJECT_NAME}-assets/ --recursive
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# File-type variables allow storing multi-line values like certificates
push_certificates:
stage: deploy
script:
- cat ${CERT_FILE} > /etc/ssl/certs/app.pem
- chmod 600 /etc/ssl/certs/app.pem
variables:
CERT_FILE: /tmp/cert.pem
Environment Management
Environments in GitLab represent deployment targets like staging, production, or review apps. They track deployments, provide rollback capabilities, and display the current deployed commit:
# Environment with advanced features
deploy_review_app:
stage: deploy
script:
- kubectl apply -f k8s/review-app.yaml
- kubectl set image deployment/review-${CI_MERGE_REQUEST_IID} app=${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}
environment:
name: review/${CI_MERGE_REQUEST_IID}
url: https://review-${CI_MERGE_REQUEST_IID}.example.com
# Automatically stop this environment when MR is merged/closed
on_stop: stop_review_app
# Auto-delete environment after 7 days
auto_stop_in: 7 days
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
stop_review_app:
stage: deploy
script:
- kubectl delete deployment review-${CI_MERGE_REQUEST_IID}
- kubectl delete service review-${CI_MERGE_REQUEST_IID}
environment:
name: review/${CI_MERGE_REQUEST_IID}
# This action stops the environment
action: stop
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: manual
# Production with rollback capability
deploy_production:
stage: deploy
script:
- kubectl apply -f k8s/production.yaml
- kubectl set image deployment/production app=${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}
- kubectl rollout status deployment/production --timeout=5m
environment:
name: production
url: https://app.example.com
when: manual
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Artifacts and Dependencies
Artifacts are the mechanism for passing data between jobs and stages. GitLab offers sophisticated artifact management with expiration policies, report types, and dependency declarations:
# Comprehensive artifact management
stages:
- build
- test
- package
- deploy
# Build produces multiple artifact types
build_artifacts:
stage: build
script:
- make build
- make generate-docs
- make generate-sbom
artifacts:
# Named artifacts for selective downloading
name: "${CI_PROJECT_NAME}-${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHORT_SHA}"
paths:
- dist/
- docs/
- sbom.json
# Exclude large unnecessary files
exclude:
- dist/**/*.map
- dist/**/*.test.js
# Expire artifacts after 30 days to save storage
expire_in: 30 days
# Always upload artifacts even if job fails (for debugging)
when: always
# Consume artifacts selectively
run_tests_with_artifacts:
stage: test
# dependencies keyword limits which artifacts to download
# This saves time by not downloading unnecessary artifacts
dependencies:
- build_artifacts
script:
- npm run test
# Produce test reports that GitLab can parse
artifacts:
reports:
# JUnit XML test reports appear in GitLab UI
junit: test-results/**/*.xml
# Coverage visualization
coverage_report:
coverage_format: cobertura
path: coverage/cobertura.xml
# SAST security reports
sast: gl-sast-report.json
# License compliance
license_scanning: gl-license-scanning-report.json
# Dependency scanning
dependency_scanning: gl-dependency-scanning-report.json
# Package job uses artifacts from build
package_docker:
stage: package
dependencies:
- build_artifacts
script:
- docker build -t ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA} .
- docker push ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}
# Dotenv artifacts pass environment variables to downstream jobs
artifacts:
reports:
dotenv: build.env
# Deploy consumes the dotenv variables
deploy_with_dotenv:
stage: deploy
dependencies:
- package_docker
script:
# Variables from package_docker's build.env are available here
- echo "Deploying version ${BUILD_VERSION} to ${DEPLOY_TARGET}"
- kubectl set image deployment/app app=${CI_REGISTRY_IMAGE}:${BUILD_VERSION}
environment:
name: production
Docker Integration
GitLab CI/CD offers first-class Docker support. You can run jobs inside any Docker image, build Docker images within pipelines, and use the integrated container registry. Here are complete examples covering common patterns:
Docker-in-Docker (DinD)
# Building Docker images inside GitLab CI using DinD
build_and_push_docker:
stage: build
image: docker:24-dind
# DinD requires a Docker daemon service
services:
- docker:24-dind
variables:
# Required for DinD TLS communication
DOCKER_TLS_CERTDIR: "/certs"
DOCKER_HOST: tcp://docker:2376
DOCKER_CERT_PATH: "/certs/client"
DOCKER_TLS_VERIFY: "1"
before_script:
# Wait for Docker daemon to be ready
- |
for i in $(seq 1 30); do
docker info > /dev/null 2>&1 && break
echo "Waiting for Docker daemon..."
sleep 2
done
- docker login -u ${CI_REGISTRY_USER} -p ${CI_REGISTRY_PASSWORD} ${CI_REGISTRY}
script:
# Multi-stage build with cache optimization
- |
docker build \
--cache-from ${CI_REGISTRY_IMAGE}:cache \
--tag ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA} \
--tag ${CI_REGISTRY_IMAGE}:latest \
--build-arg CI_COMMIT_SHA=${CI_COMMIT_SHA} \
.
- docker push ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}
- docker push ${CI_REGISTRY_IMAGE}:latest
# Push cache image for future builds
- |
docker tag ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA} ${CI_REGISTRY_IMAGE}:cache
docker push ${CI_REGISTRY_IMAGE}:cache
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Kaniko (Alternative to DinD)
# Kaniko builds Docker images without requiring privileged mode
# Safer for Kubernetes-based runners
build_with_kaniko:
stage: build
image:
name: gcr.io/kaniko-project/executor:v1.19.0-debug
entrypoint: [""]
script:
# Kaniko builds directly from context, no Docker daemon needed
- |
/kaniko/executor \
--context "${CI_PROJECT_DIR}" \
--dockerfile "${CI_PROJECT_DIR}/Dockerfile" \
--destination "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}" \
--destination "${CI_REGISTRY_IMAGE}:latest" \
--cache=true \
--cache-ttl=168h \
--build-arg "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \
--build-arg "VCS_REF=${CI_COMMIT_SHA}" \
--label "org.opencontainers.image.created=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \
--label "org.opencontainers.image.revision=${CI_COMMIT_SHA}" \
--label "org.opencontainers.image.source=${CI_PROJECT_URL}"
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Multi-Service Integration
# Full integration test with multiple services
e2e_tests:
stage: test
image: cypress/included:13.6
services:
# Application backend
- name: ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}
alias: backend
# PostgreSQL database
- postgres:15-alpine
# Redis for caching
- redis:7-alpine
# MailHog for email testing
- mailhog/mailhog:v1.0.1
variables:
# PostgreSQL configuration
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
# Backend service connection
BACKEND_URL: http://backend:3000
DATABASE_URL: postgres://testuser:testpass@postgres:5432/testdb
REDIS_URL: redis://redis:6379
MAILHOG_URL: http://mailhog:8025
before_script:
# Wait for all services to be healthy
- |
echo "Waiting for backend..."
for i in $(seq 1 30); do
curl -s http://backend:3000/health && break
sleep 3
done
- |
echo "Waiting for PostgreSQL..."
for i in $(seq 1 30); do
pg_isready -h postgres -U testuser -d testdb && break
sleep 2
done
script:
- npm ci
- npx cypress run --browser chrome --headed --config baseUrl=${BACKEND_URL}
artifacts:
when: always
paths:
- cypress/videos/
- cypress/screenshots/
expire_in: 7 days
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
Advanced Features
Parallel Matrix Jobs
# Run the same job across multiple configurations
# Uses parallel:matrix to create a job for each combination
test_matrix:
stage: test
image: node:${NODE_VERSION}
parallel:
matrix:
- NODE_VERSION: ["16", "18", "20", "21"]
DATABASE: ["postgres", "mysql", "sqlite"]
variables:
DB_TYPE: ${DATABASE}
script:
- echo "Testing Node.js ${NODE_VERSION} with ${DATABASE}"
- npm ci
- npm test -- --database=${DATABASE}
# Exclude unsupported combinations
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
parallel:
matrix:
- NODE_VERSION: ["18", "20", "21"]
DATABASE: ["postgres", "mysql", "sqlite"]
- NODE_VERSION: ["16"]
DATABASE: ["postgres", "sqlite"] # MySQL not supported on Node 16
Child and Multi-Project Pipelines
# Trigger downstream pipelines
# Parent pipeline triggers child pipelines in other projects
trigger_component_tests:
stage: test
script:
- echo "Triggering component pipeline"
trigger:
# Trigger pipeline in another project
project: my-group/component-tests
branch: main
strategy: depend # Parent waits for child to complete
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# Trigger using artifact data
trigger_deployment_pipeline:
stage: deploy
variables:
DEPLOY_VERSION: ${CI_COMMIT_SHORT_SHA}
DEPLOY_ENV: production
trigger:
project: my-group/deployment-automation
branch: ${CI_COMMIT_REF_NAME}
strategy: depend
# Forward artifacts to child pipeline
inherit:
variables: true
# Dynamic child pipeline generation
generate_child_pipeline:
stage: build
script:
- |
cat > child-pipeline.yml << EOF
stages:
- deploy-staging
- test-staging
- deploy-prod
deploy_staging:
stage: deploy-staging
script: echo "Deploying to staging"
environment: staging
test_staging:
stage: test-staging
script: echo "Testing staging"
needs: [deploy_staging]
deploy_prod:
stage: deploy-prod
script: echo "Deploying to prod"
environment: production
when: manual
EOF
artifacts:
paths:
- child-pipeline.yml
# This job reads the generated config and triggers the child pipeline
run_child_pipeline:
stage: build
needs: [generate_child_pipeline]
trigger:
include:
- artifact: child-pipeline.yml
job: generate_child_pipeline
strategy: depend
Include: Modular Pipeline Configuration
# Main .gitlab-ci.yml that includes modular configuration files
# Include can reference local files, remote URLs, or templates
include:
# Local files in the repository
- local: /ci-templates/build-template.yml
- local: /ci-templates/test-template.yml
- local: /ci-templates/deploy-template.yml
# Remote includes (from another repo or URL)
- remote: https://gitlab.example.com/templates/security-scans.yml
# GitLab's built-in templates
- template: Jobs/SAST.gitlab-ci.yml
- template: Jobs/Secret-Detection.gitlab-ci.yml
- template: Jobs/Dependency-Scanning.gitlab-ci.yml
# Include with input variables
- template: Jobs/Deploy/EC2.gitlab-ci.yml
inputs:
ENVIRONMENT: production
REGION: us-east-1
# Local template example (ci-templates/build-template.yml):
# .build_template:
# stage: build
# image: node:18
# script:
# - npm ci
# - npm run build
# artifacts:
# paths: [dist/]
#
# build_app:
# extends: .build_template
Workflow Control
# Global workflow rules to control when pipelines run
# Place at the top of .gitlab-ci.yml
workflow:
rules:
# Run pipelines on merge requests
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
# Run on default branch commits
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# Run on tagged commits
- if: $CI_COMMIT_TAG
# Run on scheduled pipelines
- if: $CI_PIPELINE_SOURCE == "schedule"
# Skip pipelines for specific commit messages
- if: $CI_COMMIT_MESSAGE =~ /skip-ci/
when: never
# Skip draft merge requests
- if: $CI_MERGE_REQUEST_DRAFT
when: never
# Default: don't create pipelines for other cases
- when: never
# Auto-cancel redundant pipelines
# Cancels previous running pipelines on the same branch when a new commit is pushed
# Configure at project level: Settings > CI/CD > Auto-cancel redundant pipelines
Best Practices
After years of production experience with GitLab CI/CD, several patterns emerge that consistently deliver reliable, fast, and maintainable pipelines:
- Keep .gitlab-ci.yml DRY: Use
extends,!referencetags, and YAML anchors to avoid repeating configuration. Create job templates for common patterns and extend them. This makes maintenance easier when you need to change a shared behavior across dozens of jobs. - Cache intelligently: Use
cachefor dependencies that change infrequently (node_modules, pip packages, gems) but useartifactsfor build outputs that must be passed between stages. Caches are best-effort and not guaranteed; artifacts are reliable. Set appropriate cache keys using branch names or lock files. - Minimize pipeline duration: Use the
needskeyword to create DAG pipelines where jobs start as soon as their dependencies complete. Run independent jobs in parallel. Profile your pipeline using GitLab's pipeline duration charts to identify bottlenecks. - Pin Docker image versions: Never use
:latesttags for CI images. Pin exact versions likenode:18.17.1-alpineto ensure reproducible builds. Floating tags can introduce unexpected changes that break your pipeline silently. - Secure secrets properly: Store all credentials as masked CI/CD variables in the GitLab UI, never in
.gitlab-ci.yml. Use protected variables for production deployments that only run on protected branches. Rotate secrets regularly and audit variable access. - Implement fast-failure: Place fast, critical checks (linting, type checking) early in the pipeline. If these fail, subsequent expensive jobs won't run, saving runner minutes and providing faster feedback.
- Use environment-specific configurations: Leverage GitLab environments for tracking deployments. Use
environment.urlto link directly to deployed applications. Implementon_stopactions for review apps to clean up temporary infrastructure. - Test your pipeline configuration: Use
gitlab-ci-localor the GitLab CI Lint tool to validate your.gitlab-ci.ymlbefore committing. Invalid syntax causes silent pipeline failures that waste developer time. - Version your CI templates:
🚀 Need a reliable AI agent for your project?
Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.
Get Started — $23.99/mo