.gitlab-ci.yml Explained

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.
Previous: Setting Up GitLab CI/CD Next: Pipeline Stages & Jobs

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.