← Back to DevBytes

GitLab CI/CD: Complete Configuration Guide

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:

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: