TL;DR — Quick Summary

GitLab CI/CD automates build, test, and deploy workflows with .gitlab-ci.yml. Master pipeline stages, runners, artifacts, and deployment strategies.

GitLab CI/CD is a continuous integration and delivery platform built directly into GitLab. Unlike external CI tools that require separate configuration and webhooks, GitLab CI/CD reads a single .gitlab-ci.yml file from your repository and executes pipelines automatically on every push. This tight integration means your code, issues, merge requests, and deployment pipelines all live in one place.

This guide walks through pipeline configuration from scratch — stages, jobs, runners, caching, artifacts, variables, environments, and deployment strategies that work in production.

Prerequisites

  • A GitLab account (gitlab.com or self-hosted instance)
  • A Git repository hosted on GitLab
  • Basic YAML syntax knowledge
  • Docker installed (for Docker executor runners)
  • Familiarity with CI/CD concepts (build, test, deploy)

Understanding GitLab CI/CD Pipeline Structure

A pipeline consists of stages that run sequentially and jobs within each stage that run in parallel. Here is the fundamental structure:

# .gitlab-ci.yml
stages:
  - build
  - test
  - deploy

build-app:
  stage: build
  image: node:20
  script:
    - npm ci
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 hour

unit-tests:
  stage: test
  image: node:20
  script:
    - npm ci
    - npm run test:unit
  coverage: '/Lines\s+:\s+(\d+\.?\d*)%/'

integration-tests:
  stage: test
  image: node:20
  services:
    - postgres:15
  variables:
    POSTGRES_DB: test_db
    POSTGRES_USER: runner
    POSTGRES_PASSWORD: testing
    DATABASE_URL: "postgresql://runner:testing@postgres:5432/test_db"
  script:
    - npm ci
    - npm run test:integration

deploy-staging:
  stage: deploy
  script:
    - ./scripts/deploy.sh staging
  environment:
    name: staging
    url: https://staging.example.com
  rules:
    - if: $CI_COMMIT_BRANCH == "develop"

The build stage runs first. When it passes, both test jobs run in parallel (they are in the same stage). Only if all tests pass does deployment proceed.

Configuring GitLab Runners

Runners execute your pipeline jobs. You have two options:

Shared Runners (Quick Start)

GitLab.com provides shared runners for free. They are available immediately — no setup required. Navigate to Settings → CI/CD → Runners to verify they are enabled.

Self-Hosted Runners (Production)

For better performance and control, install your own runner:

# Install GitLab Runner on Ubuntu
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash
sudo apt-get install gitlab-runner

# Register the runner
sudo gitlab-runner register \
  --url "https://gitlab.com/" \
  --registration-token "YOUR_TOKEN" \
  --executor "docker" \
  --docker-image "alpine:latest" \
  --description "production-runner" \
  --tag-list "docker,linux" \
  --run-untagged="true"

Runner Executor Comparison

Executor Isolation Speed Use Case
Docker High (container per job) Fast Default choice for most projects
Shell None (runs on host) Fastest Simple scripts, legacy apps
Docker Machine High + autoscaling Variable Cloud-based autoscaling runners
Kubernetes High (pod per job) Medium Kubernetes-native deployments
VirtualBox Full VM Slow Complete OS isolation needed

Advanced Pipeline Configuration

Caching Dependencies

Caching prevents re-downloading dependencies on every pipeline run:

variables:
  NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm"

cache:
  key:
    files:
      - package-lock.json
  paths:
    - .npm/
    - node_modules/
  policy: pull-push

The key.files directive generates a cache key from package-lock.json. When dependencies change, the cache invalidates automatically.

Artifacts Between Stages

Pass build outputs from one stage to the next:

build:
  stage: build
  script:
    - npm run build
  artifacts:
    paths:
      - dist/
      - coverage/
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml
    expire_in: 1 week

Pipeline Rules and Conditional Execution

Control when jobs run with rules:

deploy-production:
  stage: deploy
  script:
    - ./deploy.sh production
  environment:
    name: production
    url: https://example.com
  rules:
    - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
      when: manual
    - when: never

This job only appears on version tags (v1.2.3) and requires manual approval.

Parallel Matrix Jobs

Test across multiple versions simultaneously:

test:
  stage: test
  image: node:${NODE_VERSION}
  parallel:
    matrix:
      - NODE_VERSION: ["18", "20", "22"]
        DATABASE: ["postgres", "mysql"]
  script:
    - npm ci
    - npm run test

This creates 6 parallel jobs (3 Node versions × 2 databases).

CI/CD Variables and Secrets

Store sensitive values securely:

deploy:
  stage: deploy
  script:
    - echo "$DEPLOY_KEY" > key.pem
    - chmod 600 key.pem
    - scp -i key.pem dist/* user@server:/var/www/
  after_script:
    - rm -f key.pem

Real-world scenario: Your team deploys a Node.js application to three environments — development, staging, and production. Each environment has different database credentials, API keys, and server addresses. You store these as GitLab CI/CD variables scoped to their respective environments. The same pipeline YAML handles all deployments; only the variables differ. When a developer pushes to develop, the pipeline auto-deploys to staging. Production deployment triggers only on tags with manual approval from a team lead.

Variable Types and Scoping

Scope Set In Available To
Project Settings → CI/CD → Variables All pipelines in the project
Group Group → Settings → CI/CD All projects in the group
Instance Admin → CI/CD → Variables All projects on the instance
Environment Settings → CI/CD → Variables (env filter) Jobs targeting that environment
Job variables: in .gitlab-ci.yml That specific job only

Mark variables as Protected (only available on protected branches/tags) and Masked (hidden in job logs) for credentials.

Deployment Environments and Strategies

Review Apps (Dynamic Environments)

Create a temporary environment for every merge request:

review:
  stage: deploy
  script:
    - deploy_review_app "$CI_MERGE_REQUEST_IID"
  environment:
    name: review/$CI_MERGE_REQUEST_IID
    url: https://$CI_MERGE_REQUEST_IID.review.example.com
    on_stop: stop-review
  rules:
    - if: $CI_MERGE_REQUEST_IID

stop-review:
  stage: deploy
  script:
    - teardown_review_app "$CI_MERGE_REQUEST_IID"
  environment:
    name: review/$CI_MERGE_REQUEST_IID
    action: stop
  rules:
    - if: $CI_MERGE_REQUEST_IID
      when: manual

Production Deployment with Rollback

deploy-production:
  stage: deploy
  script:
    - ./deploy.sh production
  environment:
    name: production
    url: https://example.com
    auto_stop_in: never
  rules:
    - if: $CI_COMMIT_TAG
      when: manual
  allow_failure: false

GitLab CI/CD vs Other CI Platforms

Feature GitLab CI/CD GitHub Actions Jenkins CircleCI
Config File .gitlab-ci.yml .github/workflows/*.yml Jenkinsfile .circleci/config.yml
Built-in Registry Yes (container + package) Yes (container) No No
Self-hosted Option Yes (full GitLab) Yes (runners only) Yes No
Review Apps Native Manual setup Manual Manual
DAG Pipelines Yes (needs keyword) Yes (native) Yes Yes
Auto DevOps Yes No No No
Free Tier CI Minutes 400 min/month 2,000 min/month Unlimited (self-host) 6,000 min/month
Learning Curve Medium Low High Low

GitLab CI/CD shines when you want everything integrated — code, CI, container registry, environments, and monitoring in one platform. GitHub Actions wins on ecosystem breadth with its marketplace.

Gotchas and Edge Cases

  • YAML anchors do not work with include: If you use YAML anchors (&anchor/*anchor) in included files, they will not resolve across file boundaries. Use extends keyword instead.

  • Cache is not guaranteed: GitLab caches are best-effort. Runners may not share caches. Always design pipelines that work without cache (just slower).

  • Services networking: Service containers (like postgres:15) are accessible via their image name as hostname. postgres, not localhost. This trips up many developers.

  • only/except vs rules: The legacy only/except syntax conflicts with rules. Never mix them in the same job. Prefer rules — it is more powerful and the recommended approach.

  • Job timeout defaults to 1 hour: Long-running jobs like E2E tests or large builds silently fail at the timeout. Set timeout: 2h explicitly for known long jobs.

  • Protected branch variables: Variables marked “Protected” are not available on feature branches. If a job needs credentials on non-protected branches, use a separate variable without the protected flag.

Troubleshooting

Problem Cause Solution
Pipeline stuck on “Pending” No available runners with matching tags Check runner tags or set tags: [] to use any runner
“Cache not found” on first run Cache has not been created yet First run downloads everything; cache populates for next run
Services not reachable Wrong hostname in connection string Use the service image name as hostname (e.g., postgres, not localhost)
“Variable is not set” in jobs Variable is protected, branch is not Uncheck “Protected” or push to protected branch
Artifacts missing in next stage artifacts.paths does not match output Check exact paths; use ls -la in script to debug

Summary

  • GitLab CI/CD reads .gitlab-ci.yml from your repository and runs pipelines automatically — no external tool configuration needed
  • Stages run sequentially while jobs within a stage run in parallel, giving you both ordering and speed
  • Self-hosted runners with Docker executor provide the best performance and control for production teams
  • Caching dependencies and passing artifacts between stages dramatically reduces pipeline execution time
  • Environment variables with masking, protection, and environment scoping keep secrets secure across deployments
  • Review apps create temporary environments per merge request, enabling visual review before merge