.gitlab-ci.yml Explained
Stages
Jobs
Scripts
Rules
Variables
A complete guide to .gitlab-ci.yml configuration. Learn stages, jobs, scripts, rules, variables, and pipeline structure with detailed explanations and practical examples.
.gitlab-ci.yml
Pipeline Config
Advanced Features
What is .gitlab-ci.yml?
The .gitlab-ci.yml file is the heart of GitLab CI/CD. It's a YAML file placed at the root of your repository that defines the entire CI/CD pipeline — what jobs to run, when to run them, and how they should execute. Every time you push code to GitLab, the GitLab server reads this file and creates a pipeline based on its contents. This file acts as a blueprint for your continuous integration and continuous delivery process, turning a simple code push into an automated sequence of build, test, and deploy operations.
What makes .gitlab-ci.yml special is that it lives alongside your code in version control. This means your pipeline configuration is versioned, reviewable, and changes go through the same merge request process as your application code. This "configuration as code" approach eliminates the "it works on my machine" problem for pipelines — the exact same pipeline definition runs for everyone, every time, in every environment.
GitLab automatically detects this file in your repository root and executes it. If the file is missing or invalid, no pipeline runs. Understanding its structure and capabilities is essential for getting the most out of GitLab CI/CD. The file is written in YAML, a human-readable data serialization format that uses indentation to define structure — which makes it readable but also means indentation errors are a common source of problems for beginners.
Key Concept: The .gitlab-ci.yml file is the single source of truth for your CI/CD pipeline. It defines everything from simple echo commands to complex multi-stage deployment workflows. Every aspect of your automation — build tools, test frameworks, deployment targets, environment variables, and conditional logic — is expressed in this one file.
YAML Syntax Fundamentals
Before diving into GitLab-specific keywords, it's important to understand YAML syntax. YAML (YAML Ain't Markup Language) is a human-readable data format that relies on indentation to define structure. Unlike JSON or XML, YAML uses whitespace and simple punctuation to represent complex data structures, which makes it easy to read but unforgiving of formatting mistakes.
Every YAML file consists of key-value pairs, lists, and nested structures. Keys are separated from values by a colon and a space, list items are denoted by a hyphen and a space, and nesting is achieved through indentation (spaces, never tabs). Understanding these fundamentals is crucial because a single misplaced space or an accidental tab character can cause an entire pipeline to fail before it even starts.
# YAML Syntax Basics
# 1. Key-value pairs
key: value
name: my-project
enabled: true
# 2. Lists (arrays)
stages:
- build
- test
- deploy
# 3. Nested structures (dictionaries/maps)
job:
stage: build
script:
- echo "Building"
# 4. Multi-line strings
script:
- |
echo "Line 1"
echo "Line 2"
# 5. Anchors and aliases (reuse configuration)
.default_config: &default_config
image: node:18
before_script:
- npm install
build:
<<: *default_config
script:
- npm run build
# 6. Comments start with #
# This is a comment
# Common YAML mistakes:
# - Using tabs instead of spaces (YAML doesn't allow tabs)
# - Incorrect indentation
# - Missing space after colon
# - Unquoted special characters
YAML Gotchas:
- Never use tabs: YAML only accepts spaces for indentation
- Consistent indentation: Use 2 spaces consistently
- Quote special characters: Strings with colons, hashes, or brackets need quotes
- Case sensitivity: YAML keywords are case-sensitive
- Comments: Use # for comments, can be inline or on separate lines
Pipeline Structure
A GitLab CI/CD pipeline consists of stages and jobs organized in a hierarchy. Understanding how these components relate to each other is fundamental to writing effective pipelines. When you push code, GitLab parses your .gitlab-ci.yml file and constructs a pipeline — a directed graph of jobs and stages that will execute in a specific order based on your configuration.
The pipeline structure determines the flow of execution. Stages run sequentially — all jobs in one stage must complete before the next stage begins. Within a stage, jobs run in parallel by default, which means faster feedback but also requires careful thought about shared resources. This two-level hierarchy (stages containing jobs) gives you precise control over the order of operations while maximizing parallelism where it makes sense.
# Complete Pipeline Structure
# This example shows all major pipeline components
# 1. Pipeline-level definitions
stages:
- build
- test
- deploy
# 2. Global variables
variables:
NODE_VERSION: "18"
DOCKER_REGISTRY: $CI_REGISTRY
# 3. Default settings (applied to all jobs)
default:
image: node:$NODE_VERSION
before_script:
- echo "Starting job $CI_JOB_NAME"
# 4. Jobs
build:
stage: build
script:
- npm install
- npm run build
artifacts:
paths:
- dist/
test:
stage: test
script:
- npm test
deploy:
stage: deploy
script:
- echo "Deploying..."
when: manual
# 5. Include other files
include:
- local: '/templates/.gitlab-ci-template.yml'
# 6. Workflow rules (control pipeline creation)
workflow:
rules:
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_TAG
Stages
Top-level grouping of jobs. Stages run sequentially: build → test → deploy.
Jobs
Individual units of work within a stage. Jobs in the same stage run in parallel.
Scripts
Shell commands executed by each job. The core of what a job actually does.
Rules
Conditional logic that determines when a job runs. Replaces only/except.
Variables
Configuration values available to all jobs. Can be defined at pipeline, stage, or job level.
Artifacts
Files generated by jobs that are passed to subsequent jobs or stored for download.
Stages: Organizing Your Pipeline
Stages are the top-level organizational unit in a GitLab pipeline. They define the order in which groups of jobs execute. When you define stages, you're creating a sequence — GitLab will run all jobs in the first stage, wait for them all to complete successfully, then move to the next stage, and so on. This sequential execution model is essential for workflows where later steps depend on earlier ones — you can't deploy an application before you've built and tested it.
The power of stages comes from combining sequential stage execution with parallel job execution within each stage. For example, you might have three test jobs in a "test" stage — unit tests, integration tests, and linting — all running simultaneously for faster feedback, while the "deploy" stage only begins after all three tests pass. This balance between order and parallelism is what makes GitLab CI/CD pipelines efficient and reliable.
If you don't explicitly define stages, GitLab uses a default set: .pre, build, test, deploy, and .post. The special .pre and .post stages always run first and last respectively, regardless of where they appear in your stages list.
# Defining stages
stages:
- .pre # Runs first (special stage)
- build
- test
- security
- deploy
- .post # Runs last (special stage)
# Jobs are assigned to stages
build_app:
stage: build
script:
- npm run build
run_tests:
stage: test
script:
- npm test
security_scan:
stage: security
script:
- trivy scan
deploy_app:
stage: deploy
script:
- kubectl apply -f deployment.yaml
# Default stages (if not defined)
# .pre, build, test, deploy, .post
# Custom stage order
stages:
- validate
- build
- unit-test
- integration-test
- security
- staging
- production
# Stage execution rules:
# 1. Stages run in the order defined
# 2. All jobs in a stage must complete before next stage
# 3. If any job fails, subsequent stages don't run (unless allow_failure)
# 4. .pre and .post always run first and last
# Example with allow_failure
experimental_test:
stage: test
script:
- npm run test:experimental
allow_failure: true # Pipeline continues even if this fails
# Example with manual job
deploy_production:
stage: production
script:
- deploy.sh production
when: manual # Requires manual trigger
allow_failure: false # Must succeed if triggered
Stage Best Practices:
- Use 5-7 stages maximum for clarity
- Name stages descriptively (build, test, deploy, etc.)
- Group related jobs in the same stage for parallel execution
- Use .pre for setup tasks and .post for cleanup
- Order stages logically: validate → build → test → deploy
- Consider using needs (DAG) for more complex dependencies
Jobs: The Building Blocks
Jobs are the fundamental building blocks of a GitLab CI/CD pipeline. Each job represents a specific task — compiling code, running tests, building a container image, or deploying to an environment. Every job has a name (the YAML key), a stage it belongs to, and a script that defines what commands to run. Jobs are where the actual work happens; everything else in the .gitlab-ci.yml file is configuration that shapes how and when those jobs execute.
What makes GitLab jobs powerful is their independence. Each job runs in its own isolated environment — by default, a fresh container — meaning one job can't accidentally interfere with another. This isolation guarantees reproducibility: the same job will behave the same way every time it runs, regardless of what other jobs are doing. Jobs in the same stage run in parallel, which means a well-designed pipeline can complete a large amount of work in a fraction of the time it would take to run everything sequentially.
Jobs also have rich configuration options. You can specify which Docker image to use, define environment variables, declare dependencies on other jobs, set timeouts, configure caching, and much more. Understanding these options is key to writing efficient, reliable pipelines.
# Basic job structure
job_name:
stage: build
image: node:18
script:
- echo "This is a job"
- npm install
- npm run build
# Job with before_script and after_script
build_app:
stage: build
image: node:18
before_script:
- echo "Setting up environment"
- npm ci
script:
- echo "Building application"
- npm run build
after_script:
- echo "Job completed"
- cleanup.sh
# Job with dependencies
test:
stage: test
image: node:18
dependencies:
- build_app # Download artifacts from build_app
script:
- npm test
# Job with artifacts
build:
stage: build
script:
- npm run build
artifacts:
paths:
- dist/
- build/
expire_in: 1 week
when: on_success
# Job with environment
deploy_staging:
stage: deploy
script:
- deploy.sh staging
environment:
name: staging
url: https://staging.example.com
# Job with timeout
long_running_job:
stage: test
timeout: 30m
script:
- ./long-test.sh
# Job with allow_failure
experimental:
stage: test
script:
- npm run test:experimental
allow_failure: true
# Job with retry
flaky_test:
stage: test
retry:
max: 2
when:
- runner_system_failure
- stuck_or_timeout_failure
script:
- npm run test:flaky
# Job with parallel execution
parallel_test:
stage: test
parallel: 5
script:
- echo "Running test $CI_NODE_INDEX of $CI_NODE_TOTAL"
- npm run test:part-$CI_NODE_INDEX
# Job with matrix
matrix_test:
stage: test
parallel:
matrix:
- OS: [ubuntu, alpine]
NODE: [16, 18, 20]
script:
- echo "Testing on $OS with Node $NODE"
- npm test
# Job with only/except (deprecated, use rules)
legacy_job:
stage: deploy
script:
- deploy.sh
only:
- main
except:
- schedules
# Job with rules (modern approach)
modern_job:
stage: deploy
script:
- deploy.sh
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
- if: $CI_COMMIT_TAG
when: always
- when: never
script
The commands to execute. Required for every job. Can be a single command or a list.
image
Docker image to run the job in. Determines the environment and available tools.
before_script
Commands to run before the main script. Good for setup and dependency installation.
after_script
Commands to run after the main script, even if the job fails. Good for cleanup.
artifacts
Files to save after the job completes. Can be passed to subsequent jobs or downloaded.
dependencies
Which jobs' artifacts to download. Controls artifact flow between jobs.
rules
Conditions for when the job runs. Modern replacement for only/except.
retry
Automatically retry failed jobs. Good for flaky tests and transient failures.
parallel
Run multiple instances of the job in parallel. Speeds up test suites.
timeout
Maximum job execution time. Prevents jobs from hanging indefinitely.
Scripts: What Jobs Actually Do
The script keyword is where all the action happens. It contains the shell commands that a job executes, and it's the only required keyword for any job. Everything else in a job — the image, the variables, the artifacts — exists to support and shape what the script does. When a runner picks up your job, it starts a container, runs your before_script commands, then executes each line of your script sequentially, and finally runs your after_script commands.
Understanding how scripts execute is crucial for writing reliable pipelines. Each line in the script list runs as a separate shell command, but they all share the same shell session within a job. This means environment variables and directory changes persist across lines within a single job, but not across jobs. If any command in the script returns a non-zero exit code, the job fails immediately and subsequent commands don't run. This "fail fast" behavior is what makes pipelines reliable — problems surface at the earliest possible point.
Scripts can be simple one-liners or complex multi-line bash programs. For anything beyond trivial commands, it's often better to put the logic into a script file in your repository and call it from .gitlab-ci.yml. This keeps your pipeline configuration clean and makes the actual logic testable and version-controlled independently.
# Single-line script
simple_job:
script: echo "Hello, World!"
# Multi-line script
multi_line_job:
script:
- echo "Step 1"
- echo "Step 2"
- echo "Step 3"
# Multi-line block (YAML block scalar)
block_job:
script:
- |
echo "Step 1"
echo "Step 2"
if [ "$CI_COMMIT_BRANCH" == "main" ]; then
echo "On main branch"
fi
# Using before_script for setup
build_job:
before_script:
- echo "Setting up..."
- apt-get update && apt-get install -y build-essential
- npm ci
script:
- npm run build
# Using after_script for cleanup
test_job:
script:
- npm test
after_script:
- echo "Cleaning up..."
- rm -rf temp/
# Calling external scripts
complex_job:
script:
- chmod +x ./scripts/build.sh
- ./scripts/build.sh
# Handling errors in scripts
error_handling_job:
script:
- |
set -e # Exit on any error
echo "Starting..."
npm run build
echo "Build succeeded"
# Continuing on error (explicit)
continue_on_error_job:
script:
- |
npm run lint || true # Continue even if lint fails
npm test # This will still run
# Multi-line commands with heredoc
heredoc_job:
script:
- |
cat > config.yaml <
Script Best Practices:
- Keep scripts short and focused
- Move complex logic to external script files
- Use
set -e for fail-fast behavior
- Quote variables:
"$VAR" not $VAR
- Use
|| true only when you want to ignore failures
- Add comments for complex logic
- Test scripts locally before committing
Rules: Conditional Job Execution
The rules keyword is the modern way to control when jobs run. It replaced the older only and except keywords and provides much more flexibility. Rules let you define conditions based on branch names, commit messages, file changes, variables, and even the pipeline source. Each rule consists of an if condition and an optional when clause that determines what happens when the condition matches — run the job, run it manually, or skip it entirely.
Rules are evaluated in order, and the first matching rule wins. If no rule matches, the job is not added to the pipeline (equivalent to when: never). This "first match wins" behavior means you should order your rules from most specific to least specific. A common pattern is to have specific rules for different branches or tags first, followed by a catch-all rule at the end that handles everything else.
What makes rules particularly powerful is their ability to inspect the change set. Using changes:, you can run jobs only when specific files are modified — for example, only running frontend tests when frontend code changes. Combined with variables and branch conditions, this enables pipelines that are both efficient and precise, running exactly the work that's needed for each change.
# Basic rule: run only on main branch
deploy_job:
script:
- deploy.sh
rules:
- if: $CI_COMMIT_BRANCH == "main"
# Rule with multiple conditions
deploy_job:
script:
- deploy.sh
rules:
- if: $CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"
- if: $CI_COMMIT_TAG
# Rule with when: manual
manual_deploy:
script:
- deploy.sh
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
- when: never
# Rule with when: always
always_run:
script:
- echo "Always runs"
rules:
- when: always
# Rule with when: never
never_run:
script:
- echo "Never runs"
rules:
- when: never
# Rule with when: delayed
delayed_job:
script:
- echo "Delayed job"
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: delayed
start_in: 30 minutes
# Rule with changes (file-based)
frontend_tests:
script:
- npm run test:frontend
rules:
- changes:
- frontend/**/*
- package.json
# Rule with changes and branch
backend_deploy:
script:
- deploy-backend.sh
rules:
- if: $CI_COMMIT_BRANCH == "main"
changes:
- backend/**/*
# Multiple rules (first match wins)
complex_job:
script:
- echo "Running complex job"
rules:
# Rule 1: Manual on tags
- if: $CI_COMMIT_TAG
when: manual
# Rule 2: Auto on main
- if: $CI_COMMIT_BRANCH == "main"
when: always
# Rule 3: Manual on develop
- if: $CI_COMMIT_BRANCH == "develop"
when: manual
# Rule 4: Never on other branches
- when: never
# Rule with exists (file exists)
conditional_job:
script:
- echo "Dockerfile exists"
rules:
- exists:
- Dockerfile
# Rule with variables
variable_job:
script:
- echo "Variable-based job"
rules:
- if: $DEPLOY_ENV == "production"
# Rule with pipeline source
source_job:
script:
- echo "Pipeline source job"
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_PIPELINE_SOURCE == "schedule"
- if: $CI_PIPELINE_SOURCE == "web"
# Workflow rules (pipeline-level)
workflow:
rules:
# Run pipeline for merge requests
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
# Run pipeline for main branch
- if: $CI_COMMIT_BRANCH == "main"
# Run pipeline for tags
- if: $CI_COMMIT_TAG
# Don't run for other branches
- when: never
# Workflow with auto-cancel
workflow:
auto_cancel:
on_new_commit: interruptible
rules:
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_TAG
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
Rules Best Practices:
- Always end with a catch-all rule (
when: never or when: always)
- Order rules from most specific to least specific
- Use
workflow rules to control when pipelines are created
- Combine
if, changes, and exists for precision
- Use variables to make rules configurable
- Test rules with different branch and file scenarios
Variables: Configuration and Secrets
Variables are the mechanism for passing configuration data into your pipeline. They can hold anything from simple strings like branch names to sensitive credentials like API keys and database passwords. GitLab provides three types of variables: predefined variables that GitLab sets automatically (like CI_COMMIT_SHA), custom variables you define in the .gitlab-ci.yml file, and protected/masked variables stored securely in GitLab's settings.
One of the most important things to understand about variables is their scope and priority. Variables defined in the GitLab UI take precedence over variables defined in .gitlab-ci.yml, and job-level variables override pipeline-level ones. This layered approach lets you set sensible defaults in your code while allowing environment-specific overrides from the UI without modifying the pipeline configuration.
Variables are also critical for security. When you store sensitive data as a masked variable in GitLab, its value is hidden in job logs — it appears as [MASKED] even if your script accidentally prints it. Protected variables are even more secure: they're only available on protected branches and tags, which means a compromised feature branch can't exfiltrate production secrets. Together, these features form the foundation of secure CI/CD pipelines.
# Global variables (available to all jobs)
variables:
NODE_VERSION: "18"
DOCKER_REGISTRY: "registry.gitlab.com"
APP_NAME: "my-app"
# Job-level variables (override global)
build:
stage: build
variables:
BUILD_ENV: "production"
OPTIMIZE: "true"
script:
- echo "Building for $BUILD_ENV"
# Using predefined variables
print_variables:
script:
# Predefined CI/CD variables
- echo "Commit SHA: $CI_COMMIT_SHA"
- echo "Short SHA: $CI_COMMIT_SHORT_SHA"
- echo "Branch: $CI_COMMIT_BRANCH"
- echo "Tag: $CI_COMMIT_TAG"
- echo "Pipeline ID: $CI_PIPELINE_ID"
- echo "Job ID: $CI_JOB_ID"
- echo "Project: $CI_PROJECT_NAME"
- echo "Namespace: $CI_PROJECT_NAMESPACE"
- echo "Registry: $CI_REGISTRY"
- echo "Registry Image: $CI_REGISTRY_IMAGE"
- echo "Registry User: $CI_REGISTRY_USER"
- echo "Default Branch: $CI_DEFAULT_BRANCH"
- echo "Commit Ref: $CI_COMMIT_REF_NAME"
- echo "Commit Ref Slug: $CI_COMMIT_REF_SLUG"
# Using variables with defaults
default_variable_job:
script:
- echo "${MY_VAR:-default_value}"
# Using variables in scripts
build_image:
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
# File variables (from GitLab UI)
use_file_variable:
script:
- cat $MY_FILE_VARIABLE
# Variable expansion
expansion_job:
variables:
BASE_URL: "https://example.com"
API_URL: "${BASE_URL}/api"
script:
- echo "API URL: $API_URL"
# Variables in rules
conditional_deploy:
script:
- deploy.sh
rules:
- if: $DEPLOY_ENV == "production"
- if: $DEPLOY_ENV == "staging"
# Protected variables (only on protected branches)
# Set in GitLab UI: Settings → CI/CD → Variables
# - Key: DB_PASSWORD
# - Value: supersecret
# - Protected: ✓ (only available on protected branches)
# - Masked: ✓ (hidden in logs)
deploy_production:
script:
- echo "Deploying to production"
- ./deploy.sh --password $DB_PASSWORD # Masked in logs
# Variables from external sources
# Set in GitLab UI or via API
# CI/CD Variables in GitLab UI:
# Project → Settings → CI/CD → Variables
# - Add Variable: Key=Value
# - Types: Variable, File
# - Options: Protected, Masked, Expanded
# Variable types:
# - Variable: Simple key-value pair
# - File: Stored as a file, path in $VAR
# Variable options:
# - Protected: Only on protected branches/tags
# - Masked: Hidden in job logs
# - Expanded: Expand variables in value
Variable Security:
- Never commit secrets to the repository
- Use masked variables for all sensitive data
- Use protected variables for production secrets
- Rotate secrets regularly
- Limit variable scope to what's needed
- Audit variable access in job logs
- Use file variables for certificates and keys
Artifacts and Cache
Artifacts and cache are two related but distinct mechanisms for handling files across jobs and pipelines. Artifacts are files that a job produces and that later jobs need — like a compiled binary, a built container image, or test results. Cache is for files that speed up jobs but aren't strictly necessary — like downloaded dependencies or build caches. Understanding the difference is important because misusing one for the other can lead to subtle bugs and wasted storage.
Artifacts are uploaded to GitLab at the end of a job and downloaded by dependent jobs. They're also available for download from the GitLab UI, which makes them useful for reviewing build outputs and test reports. By default, artifacts are kept for 30 days and are passed to all subsequent jobs, though you can control both behaviors with expire_in and dependencies.
Cache, on the other hand, is stored on the runner (not GitLab) and is reused across pipeline runs. It's perfect for things like node_modules or Maven's .m2 directory — large, unchanging files that take a long time to download. Cache is keyed, typically by branch or by a hash of your lock file, so different branches don't pollute each other's caches. Unlike artifacts, cache may or may not be available (if the runner is new, for example), so your jobs must be able to work with or without it.
# Artifacts: Files passed between jobs
build_job:
stage: build
script:
- npm run build
artifacts:
paths:
- dist/ # Directories
- build/*.js # Specific files
- test-results.xml # Reports
exclude:
- dist/**/*.map # Exclude source maps
expire_in: 1 week # Artifact retention
when: on_success # Upload only on success
name: "build-$CI_COMMIT_SHORT_SHA"
# Artifacts with reports
test_job:
stage: test
script:
- npm test
artifacts:
when: always # Upload even on failure
reports:
junit: test-results.xml
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
paths:
- test-results.xml
# Cache: Speed up subsequent runs
cache_job:
stage: build
cache:
key: "$CI_COMMIT_REF_SLUG" # Cache per branch
paths:
- node_modules/
- .npm/
script:
- npm ci
- npm run build
# Cache with key based on lock file
cache_lock_job:
stage: build
cache:
key:
files:
- package-lock.json # Invalidate on lock change
paths:
- node_modules/
script:
- npm ci
# Cache with policy
cache_policy_job:
stage: build
cache:
key: dependencies
paths:
- node_modules/
policy: pull # Only pull, don't push
script:
- npm test
# Cache pull-push (default)
# policy: pull-push - Download and upload cache
# Cache push only
# policy: push - Only upload cache
# Global cache
cache:
key: global
paths:
- .cache/
- vendor/
# Job-specific cache override
job:
cache:
- key: job-cache
paths:
- .job-cache/
- key: shared-cache
paths:
- shared/
# Dependencies: Control artifact download
job1:
stage: build
script: echo "Job 1"
artifacts:
paths:
- output1/
job2:
stage: build
script: echo "Job 2"
artifacts:
paths:
- output2/
job3:
stage: test
dependencies:
- job1 # Only download artifacts from job1
script:
- ls output1/ # Available
# - ls output2/ # NOT available (not in dependencies)
# Artifact vs Cache comparison:
#
# Artifacts:
# - Stored in GitLab
# - Passed to downstream jobs
# - Available for download
# - Guaranteed to be available
# - Controlled by dependencies
# - Used for build outputs, reports
#
# Cache:
# - Stored on runner
# - Reused across pipelines
# - Not available for download
# - May or may not be available
# - Controlled by cache:key
# - Used for dependencies, build caches
Artifacts vs Cache:
- Artifacts: Use for files needed by later jobs or for review
- Cache: Use for dependencies that speed up jobs
- Don't cache files that should be artifacts
- Don't artifact files that should be cache
- Use
expire_in for artifacts to save storage
- Key caches by lock file hash for correctness
Complete .gitlab-ci.yml Example
Here's a complete, production-ready example that combines all the concepts we've covered. This pipeline demonstrates stages, jobs, scripts, rules, variables, artifacts, and caching in a realistic Node.js application workflow.
# Complete .gitlab-ci.yml for a Node.js Application
# This example demonstrates all major GitLab CI/CD concepts
# Pipeline Stages (run sequentially)
stages:
- .pre
- build
- test
- security
- deploy
- .post
# Global Variables
variables:
NODE_VERSION: "18"
DOCKER_REGISTRY: $CI_REGISTRY
DOCKER_IMAGE: $CI_REGISTRY_IMAGE
KUBE_NAMESPACE: production
HELM_CHART: ./charts/my-app
# Default settings (applied to all jobs unless overridden)
default:
image: node:$NODE_VERSION
before_script:
- echo "Starting job $CI_JOB_NAME"
- echo "Pipeline: $CI_PIPELINE_ID"
- echo "Commit: $CI_COMMIT_SHORT_SHA"
- echo "Branch: $CI_COMMIT_BRANCH"
retry:
max: 2
when:
- runner_system_failure
- stuck_or_timeout_failure
tags:
- docker
# Workflow Rules (control pipeline creation)
workflow:
auto_cancel:
on_new_commit: interruptible
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_TAG
- when: never
# Stage: .pre - Setup
setup:
stage: .pre
script:
- echo "Setting up environment"
- mkdir -p .cache
cache:
key: setup-$CI_COMMIT_REF_SLUG
paths:
- .cache/
# Stage: build
build:
stage: build
script:
- echo "Installing dependencies..."
- npm ci
- echo "Building application..."
- npm run build
artifacts:
paths:
- dist/
- build/
expire_in: 1 day
name: "build-$CI_COMMIT_SHORT_SHA"
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
policy: pull-push
only:
- main
- merge_requests
# Stage: test
test_unit:
stage: test
script:
- echo "Running unit tests..."
- npm ci
- npm run test:unit
artifacts:
when: always
reports:
junit: test-results/unit.xml
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
policy: pull
only:
- main
- merge_requests
test_integration:
stage: test
script:
- echo "Running integration tests..."
- npm ci
- npm run test:integration
artifacts:
when: always
reports:
junit: test-results/integration.xml
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
policy: pull
only:
- main
- merge_requests
# Stage: security
sast:
stage: security
image: registry.gitlab.com/security-products/sast:latest
script:
- /analyzer run
artifacts:
reports:
sast: gl-sast-report.json
rules:
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
dependency_scanning:
stage: security
image: registry.gitlab.com/security-products/dependency-scanning:latest
script:
- /analyzer run
artifacts:
reports:
dependency_scanning: gl-dependency-scanning-report.json
rules:
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
# Stage: deploy
build_image:
stage: deploy
image: docker:latest
services:
- docker:dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
- echo "Building Docker image..."
- docker build -t $DOCKER_IMAGE:$CI_COMMIT_SHORT_SHA .
- docker push $DOCKER_IMAGE:$CI_COMMIT_SHORT_SHA
only:
- main
- tags
deploy_staging:
stage: deploy
image: alpine/helm:latest
before_script:
- echo "Deploying to staging..."
script:
- helm upgrade --install my-app $HELM_CHART
--namespace staging
--create-namespace
--set image.tag=$CI_COMMIT_SHORT_SHA
--wait --timeout 5m
environment:
name: staging
url: https://staging.example.com
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
allow_failure: false
deploy_production:
stage: deploy
image: alpine/helm:latest
before_script:
- echo "Deploying to production..."
script:
- helm upgrade --install my-app $HELM_CHART
--namespace production
--set image.tag=$CI_COMMIT_SHORT_SHA
--atomic --wait --timeout 10m
environment:
name: production
url: https://example.com
rules:
- if: $CI_COMMIT_TAG
when: manual
- if: $CI_COMMIT_BRANCH == "main"
when: manual
allow_failure: false
# Stage: .post - Cleanup
cleanup:
stage: .post
script:
- echo "Cleaning up temporary files..."
- rm -rf tmp/
when: always
allow_failure: true
# Include external templates
include:
- local: '/templates/.gitlab-ci-template.yml'
- template: Security/SAST.gitlab-ci.yml
- template: Security/Dependency-Scanning.gitlab-ci.yml
What This Pipeline Does:
- Setup (.pre): Prepares the environment
- Build: Installs dependencies and builds the application
- Test: Runs unit and integration tests in parallel
- Security: Runs SAST and dependency scanning
- Deploy: Builds Docker image and deploys to staging/production
- Cleanup (.post): Cleans up temporary files
Frequently Asked Questions
What is the minimum required in a .gitlab-ci.yml file?
The absolute minimum is a single job with a script. Example: job: { script: echo "Hello" }. Everything else — stages, images, variables — is optional configuration.
What's the difference between script, before_script, and after_script?
before_script runs first (setup), script runs second (main work), and after_script runs last (cleanup, always runs even on failure).
How do I run jobs only on certain branches?
Use rules with if conditions: rules: - if: $CI_COMMIT_BRANCH == "main". Or use the older only keyword.
What's the difference between only/except and rules?
only/except is the legacy syntax. rules is the modern, more flexible approach. Use rules for new pipelines and migrate existing ones when convenient.
How do I pass files between jobs?
Use artifacts to declare output files. Later jobs automatically receive them unless you restrict with dependencies. Use cache for files that speed up jobs but aren't essential.
How do I use secrets in my pipeline?
Store secrets as masked CI/CD variables in GitLab UI (Settings → CI/CD → Variables). Mark them as protected for production. Never commit secrets to the repository.
How do I debug a .gitlab-ci.yml file?
Use the CI Lint tool in GitLab (CI/CD → Editor → Validate). Run pipelines in a feature branch first. Check job logs for error messages.
What are the most common YAML mistakes in .gitlab-ci.yml?
Common mistakes include: using tabs instead of spaces, incorrect indentation, missing space after colon, unquoted special characters, and duplicate keys. Always validate with CI Lint.
The .gitlab-ci.yml file is the heart of your CI/CD pipeline. Master its syntax and structure to build efficient, reliable, and maintainable pipelines.