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

ExecutorIsolationSpeedUse Case
DockerHigh (container per job)FastDefault choice for most projects
ShellNone (runs on host)FastestSimple scripts, legacy apps
Docker MachineHigh + autoscalingVariableCloud-based autoscaling runners
KubernetesHigh (pod per job)MediumKubernetes-native deployments
VirtualBoxFull VMSlowComplete 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

ScopeSet InAvailable To
ProjectSettings → CI/CD → VariablesAll pipelines in the project
GroupGroup → Settings → CI/CDAll projects in the group
InstanceAdmin → CI/CD → VariablesAll projects on the instance
EnvironmentSettings → CI/CD → Variables (env filter)Jobs targeting that environment
Jobvariables: in .gitlab-ci.ymlThat 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

FeatureGitLab CI/CDGitHub ActionsJenkinsCircleCI
Config File.gitlab-ci.yml.github/workflows/*.ymlJenkinsfile.circleci/config.yml
Built-in RegistryYes (container + package)Yes (container)NoNo
Self-hosted OptionYes (full GitLab)Yes (runners only)YesNo
Review AppsNativeManual setupManualManual
DAG PipelinesYes (needs keyword)Yes (native)YesYes
Auto DevOpsYesNoNoNo
Free Tier CI Minutes400 min/month2,000 min/monthUnlimited (self-host)6,000 min/month
Learning CurveMediumLowHighLow

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

ProblemCauseSolution
Pipeline stuck on “Pending”No available runners with matching tagsCheck runner tags or set tags: [] to use any runner
”Cache not found” on first runCache has not been created yetFirst run downloads everything; cache populates for next run
Services not reachableWrong hostname in connection stringUse the service image name as hostname (e.g., postgres, not localhost)
“Variable is not set” in jobsVariable is protected, branch is notUncheck “Protected” or push to protected branch
Artifacts missing in next stageartifacts.paths does not match outputCheck 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