GitLab CI Tutorial: Complete Guide to CI/CD Pipelines (2026)

GitLab CI Tutorial: Complete Guide to CI/CD Pipelines (2026)

GitLab CI/CD automates your build, test, and deploy pipeline using a .gitlab-ci.yml file in your repository root. Define stages (build, test, deploy), write jobs that run shell commands inside Docker containers, and GitLab Runners execute them on every push. This guide covers everything from your first pipeline to advanced features like caching, artifacts, and parallel testing.

Key Takeaways

Everything lives in .gitlab-ci.yml. Your entire pipeline is code — checked into the repo, versioned, reviewed in MRs. There is no separate UI configuration to manage.

Stages run sequentially; jobs within a stage run in parallel. Define stages: [build, test, deploy] and GitLab runs all jobs in build first, then all test jobs simultaneously, then deploy.

Runners execute your jobs. GitLab.com provides shared runners (free up to limits). For private repos or custom environments, register your own runners with gitlab-runner register.

Use cache for dependencies, artifacts for outputs. Cache speeds up installs (node_modules, pip packages). Artifacts pass files between jobs (test results, build outputs).

only/except and rules control when jobs run. Gate deployments behind branch conditions — deploy to staging on develop, to production on main only.

What Is GitLab CI/CD?

GitLab CI/CD is a built-in continuous integration and delivery system. Every time you push code, GitLab automatically runs your pipeline — a series of jobs defined in .gitlab-ci.yml that build, test, and optionally deploy your application.

Unlike external CI tools (Jenkins, CircleCI), GitLab CI is integrated directly into GitLab. No webhooks to configure, no separate accounts, no plugins to install.

Your First Pipeline

Create .gitlab-ci.yml in your repository root:

# .gitlab-ci.yml — a minimal working pipeline

stages:
  - test

run-tests:
  stage: test
  image: node:20
  script:
    - npm install
    - npm test

Push this file and GitLab immediately starts a pipeline. Navigate to CI/CD → Pipelines in your project to see it run.

What this does:

  • Defines one stage: test
  • Creates one job: run-tests
  • Runs inside a Node.js 20 Docker container
  • Executes npm install then npm test

Core Concepts

Stages

Stages define the order of execution. Jobs in the same stage run in parallel; stages run sequentially.

stages:
  - build      # runs first
  - test       # runs after build
  - deploy     # runs after all tests pass

Jobs

A job is the basic unit of work. Each job must belong to a stage.

build-app:
  stage: build
  script:
    - echo "Building the application"
    - npm run build

run-unit-tests:
  stage: test
  script:
    - npm run test:unit

run-integration-tests:
  stage: test          # same stage — runs in parallel with unit tests
  script:
    - npm run test:integration

Image

image specifies the Docker image to run your job inside. Without it, GitLab uses its default image.

job-name:
  image: python:3.11-slim   # Use Python 3.11
  script:
    - python --version
    - pip install -r requirements.txt
    - pytest

Common images:

  • node:20 — Node.js
  • python:3.11 — Python
  • ruby:3.2 — Ruby
  • golang:1.21 — Go
  • openjdk:17 — Java
  • docker:24 — Docker CLI

A Real-World Pipeline Example

# .gitlab-ci.yml — Node.js app with build, test, and deploy

stages:
  - install
  - test
  - build
  - deploy

variables:
  NODE_ENV: test

# Cache node_modules between jobs and pipelines
cache:
  key:
    files:
      - package-lock.json
  paths:
    - node_modules/

install-dependencies:
  stage: install
  image: node:20
  script:
    - npm ci
  artifacts:
    paths:
      - node_modules/
    expire_in: 1 hour

lint:
  stage: test
  image: node:20
  script:
    - npm run lint
  needs: [install-dependencies]

unit-tests:
  stage: test
  image: node:20
  script:
    - npm run test:unit -- --coverage
  coverage: '/Lines\s*:\s*(\d+\.?\d*)%/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml
    when: always
  needs: [install-dependencies]

build-production:
  stage: build
  image: node:20
  script:
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 day
  only:
    - main
    - develop

deploy-staging:
  stage: deploy
  image: alpine:latest
  script:
    - echo "Deploying to staging..."
    - ./deploy.sh staging
  environment:
    name: staging
    url: https://staging.example.com
  only:
    - develop

deploy-production:
  stage: deploy
  image: alpine:latest
  script:
    - echo "Deploying to production..."
    - ./deploy.sh production
  environment:
    name: production
    url: https://example.com
  only:
    - main
  when: manual   # Require manual approval

Environment Variables

Built-in Variables

GitLab provides many pre-defined variables you can use in any job:

print-info:
  script:
    - echo "Branch: $CI_COMMIT_BRANCH"
    - echo "Commit: $CI_COMMIT_SHA"
    - echo "Commit short: $CI_COMMIT_SHORT_SHA"
    - echo "Pipeline: $CI_PIPELINE_ID"
    - echo "Job: $CI_JOB_ID"
    - echo "Project: $CI_PROJECT_NAME"
    - echo "Registry: $CI_REGISTRY_IMAGE"
    - echo "Default branch: $CI_DEFAULT_BRANCH"

Commonly used:

  • $CI_COMMIT_BRANCH — current branch name
  • $CI_COMMIT_SHA — full commit hash
  • $CI_PIPELINE_ID — unique pipeline ID
  • $CI_PROJECT_URL — project URL
  • $CI_REGISTRY_IMAGE — Docker registry path for the project

Custom Variables

Set custom variables in Settings → CI/CD → Variables. Mark sensitive values as Masked (hidden in logs) and Protected (only available on protected branches).

deploy:
  script:
    - curl -X POST $DEPLOY_WEBHOOK_URL   # $DEPLOY_WEBHOOK_URL set in Settings

Or define non-secret variables directly in .gitlab-ci.yml:

variables:
  APP_VERSION: "2.1.0"
  BUILD_ENV: "production"

build:
  script:
    - echo "Building version $APP_VERSION for $BUILD_ENV"

Caching and Artifacts

Cache

Cache persists files between pipeline runs to speed up repeated installs.

# Cache Python packages
cache:
  key: "$CI_COMMIT_REF_SLUG"
  paths:
    - .venv/
    - pip-cache/

install:
  stage: install
  script:
    - python -m venv .venv
    - source .venv/bin/activate
    - pip install --cache-dir pip-cache -r requirements.txt

Cache key strategies:

  • $CI_COMMIT_REF_SLUG — per branch (different cache for each branch)
  • $CI_PROJECT_ID — shared across all branches
  • Key based on lock file — invalidates when dependencies change:
cache:
  key:
    files:
      - requirements.txt    # Cache invalidates when requirements.txt changes
  paths:
    - .venv/

Artifacts

Artifacts pass files from one job to a later job, and are downloadable from the GitLab UI.

build:
  stage: build
  script:
    - npm run build
  artifacts:
    paths:
      - dist/           # Available to all downstream jobs
    expire_in: 7 days   # Clean up after 7 days

deploy:
  stage: deploy
  script:
    - ls dist/          # dist/ folder from build job is here
    - ./deploy.sh dist/

Artifact reports — special artifacts that GitLab renders in the UI:

tests:
  script:
    - pytest --junitxml=report.xml
  artifacts:
    reports:
      junit: report.xml    # Shows test results in MR widget
    when: always           # Upload even if tests fail

Controlling When Jobs Run

only and except (Legacy)

deploy-production:
  script: ./deploy.sh production
  only:
    - main              # Only run on main branch

run-tests:
  script: npm test
  except:
    - tags              # Don't run tests on git tags

rules is more powerful and replaces only/except:

deploy-production:
  script: ./deploy.sh
  rules:
    - if: $CI_COMMIT_BRANCH == "main"        # Run on main
    - if: $CI_MERGE_REQUEST_ID               # Run in MR pipelines
      when: never                            # But skip in MRs

run-on-schedule:
  script: npm run full-test-suite
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"  # Only on scheduled pipelines

Common rules conditions:

  • $CI_COMMIT_BRANCH == "main" — specific branch
  • $CI_MERGE_REQUEST_ID — only in merge request pipelines
  • $CI_PIPELINE_SOURCE == "schedule" — scheduled pipeline
  • $CI_PIPELINE_SOURCE == "push" — triggered by git push
  • changes: ["src/**/*"] — only when specific files changed

needs (DAG Pipelines)

needs creates a directed acyclic graph — a job starts as soon as its dependencies finish, not waiting for the entire stage.

build-frontend:
  stage: build
  script: npm run build:frontend

build-backend:
  stage: build
  script: ./gradlew build

test-frontend:
  stage: test
  needs: [build-frontend]    # Starts immediately when build-frontend finishes
  script: npm run test:e2e

test-backend:
  stage: test
  needs: [build-backend]     # Starts immediately when build-backend finishes
  script: ./gradlew test

Running Tests in GitLab CI

Python (pytest)

stages:
  - test

test:
  stage: test
  image: python:3.11
  before_script:
    - pip install -r requirements.txt
  script:
    - pytest tests/ -v --junitxml=junit.xml --cov=app --cov-report=xml
  coverage: '/TOTAL.*\s+(\d+%)$/'
  artifacts:
    reports:
      junit: junit.xml
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml
    when: always

Node.js (Jest)

test:
  stage: test
  image: node:20
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - node_modules/
  before_script:
    - npm ci
  script:
    - npm test -- --ci --coverage
  artifacts:
    reports:
      junit: junit.xml
    when: always

End-to-End Tests with Playwright

e2e-tests:
  stage: test
  image: mcr.microsoft.com/playwright:v1.42.0-jammy
  script:
    - npm ci
    - npx playwright test
  artifacts:
    when: on_failure
    paths:
      - playwright-report/
    expire_in: 7 days

Parallel Testing

Split a slow test suite across multiple jobs:

test:
  stage: test
  image: node:20
  parallel: 4           # Creates 4 identical jobs
  script:
    - npm ci
    # Use $CI_NODE_INDEX and $CI_NODE_TOTAL to split tests
    - npx jest --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL

Docker in GitLab CI

Build and Push Docker Images

variables:
  DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA

build-image:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  variables:
    DOCKER_TLS_CERTDIR: "/certs"
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - docker build -t $DOCKER_IMAGE .
    - docker push $DOCKER_IMAGE
    - docker tag $DOCKER_IMAGE $CI_REGISTRY_IMAGE:latest
    - docker push $CI_REGISTRY_IMAGE:latest

Use Your Image in Later Jobs

deploy:
  stage: deploy
  image: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
  script:
    - ./start-app.sh

Runners

GitLab Runners execute your jobs. GitLab.com provides shared runners (Linux, macOS, Windows). For self-hosted GitLab or custom environments, register your own.

Register a Runner

On your server:

# Install gitlab-runner
curl -L --output /usr/local/bin/gitlab-runner \
  https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-amd64
<span class="hljs-built_in">chmod +x /usr/local/bin/gitlab-runner

<span class="hljs-comment"># Register (interactive — gets URL and token from GitLab Settings → CI/CD → Runners)
gitlab-runner register

Runner Tags

Tags route specific jobs to specific runners:

deploy-production:
  stage: deploy
  tags:
    - production-runner    # Only runs on runner with this tag
  script:
    - ./deploy.sh

Register a runner with tags: gitlab-runner register --tag-list production-runner

Include and Extend (DRY Pipelines)

include — Import Other YAML Files

include:
  - local: '.gitlab/ci/build.yml'        # From this repo
  - project: 'team/shared-ci'             # From another GitLab project
    file: '/templates/node.yml'
  - template: 'Security/SAST.gitlab-ci.yml'  # GitLab built-in template

extends — Reuse Job Configuration

.base-test:                    # Hidden job (prefix with .)
  image: node:20
  cache:
    paths:
      - node_modules/
  before_script:
    - npm ci

unit-tests:
  extends: .base-test          # Inherits image, cache, before_script
  script:
    - npm run test:unit

integration-tests:
  extends: .base-test
  script:
    - npm run test:integration

!reference — Reference Specific Keys

.common-cache:
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - node_modules/

test:
  cache: !reference [.common-cache, cache]
  script:
    - npm test

Environments and Deployments

deploy-staging:
  script:
    - ./deploy.sh staging
  environment:
    name: staging
    url: https://staging.example.com
    on_stop: stop-staging    # Optional teardown job

stop-staging:
  script:
    - ./teardown.sh staging
  environment:
    name: staging
    action: stop
  when: manual

deploy-production:
  script:
    - ./deploy.sh production
  environment:
    name: production
    url: https://example.com
  when: manual              # Require manual trigger
  only:
    - main

Navigate to Deployments → Environments to see the history of all deployments, with links to commits and the ability to roll back.

Adding End-to-End Tests with HelpMeTest

After deployment, run automated browser tests against your live environment using HelpMeTest:

stages:
  - build
  - test
  - deploy
  - verify

deploy-staging:
  stage: deploy
  script:
    - ./deploy.sh staging
  environment:
    name: staging
    url: https://staging.example.com
  only:
    - develop

e2e-verification:
  stage: verify
  image: node:20
  needs: [deploy-staging]
  script:
    - npx helpmetest run --url https://staging.example.com
  only:
    - develop

HelpMeTest runs AI-powered tests written in plain language:

*** Test Cases ***
User Can Log In and See Dashboard
    Go To       https://staging.example.com/login
    Fill In     Email     ${TEST_USER_EMAIL}
    Fill In     Password  ${TEST_USER_PASSWORD}
    Click       Sign In
    Should Be On    /dashboard
    Should See      Welcome

User Can Complete Checkout
    Go To       https://staging.example.com/products
    Click       Add to Cart
    Go To       /cart
    Click       Proceed to Checkout
    Should See  Order Summary

These tests run on every deployment, catch regressions before production, and self-heal when the UI changes — no selector maintenance required.

Common Pipeline Patterns

Fail Fast — Run Cheap Tests First

stages:
  - lint           # Fast — catches trivial errors first
  - unit-test      # Fast — in-memory, no I/O
  - integration    # Slower — needs services
  - e2e            # Slowest — needs deployed app

Deploy Only After All Tests Pass

deploy:
  stage: deploy
  needs:
    - job: unit-tests
      artifacts: false
    - job: integration-tests
      artifacts: false
    - job: e2e-tests
      artifacts: false
  script:
    - ./deploy.sh

Scheduled Pipelines (Nightly Builds)

nightly-full-test:
  script:
    - npm run test:full -- --slow-threshold 0
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"

Set the schedule in CI/CD → Schedules in GitLab.

Review Apps (Deploy MR Branches)

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

stop-review:
  stage: deploy
  script:
    - ./teardown-review.sh $CI_MERGE_REQUEST_IID
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    action: stop
  when: manual
  rules:
    - if: $CI_MERGE_REQUEST_ID

Debugging Pipeline Failures

Read Job Logs

Click any failed job in CI/CD → Pipelines to see the full output. Look for the first red line — that's usually the root cause.

Run a Job Locally

Test your pipeline job locally before pushing:

# Install gitlab-runner locally
gitlab-runner <span class="hljs-built_in">exec docker unit-tests

CI=true Environment Variable

GitLab sets CI=true in all jobs. If your scripts behave differently in CI, check for this variable.

debug-env:
  stage: test
  script:
    - env | sort | grep CI_    # Print all CI_ variables
  when: manual                 # Only runs when manually triggered

Quick Reference

Concept YAML Key
Define stages stages: [build, test, deploy]
Assign job to stage stage: test
Set Docker image image: node:20
Run commands script: [npm install, npm test]
Run before every job before_script: [...]
Cache files cache: {paths: [node_modules/]}
Pass files between jobs artifacts: {paths: [dist/]}
Run only on branch rules: - if: $CI_COMMIT_BRANCH == "main"
Skip on condition rules: - if: ... when: never
Require manual trigger when: manual
Run in parallel parallel: N
Start early (DAG) needs: [job-name]
Reuse config extends: .base-job

Conclusion

GitLab CI/CD turns your repository into a fully automated pipeline. Define stages, write jobs, and GitLab handles the rest — running builds, tests, and deployments on every push.

The key principles:

  • Stages are sequential; jobs within a stage are parallel — use this for speed
  • Cache for installs, artifacts for outputs — keep jobs fast and connected
  • Use rules over only/except — more flexible and composable
  • needs for DAG pipelines — don't wait for an entire stage when only one job matters

For complete CI/CD coverage, pair your GitLab pipeline with automated browser testing from HelpMeTest — write tests in plain language, run them on every deployment, and get self-healing tests that survive UI changes.

Read more