GitLab CI/CD Tutorial: Complete Setup Guide (2026)

GitLab CI/CD Tutorial: Complete Setup Guide (2026)

GitLab CI/CD uses a .gitlab-ci.yml file in your repo root to define pipelines. Each pipeline has stages (build, test, deploy), and each stage has jobs that run scripts. GitLab runners execute the jobs. Push to your repo and GitLab automatically runs the pipeline.

Key Takeaways

The .gitlab-ci.yml file is everything. All pipeline logic lives in this file at your repo root. No external configuration needed. GitLab reads it on every push.

Stages run sequentially, jobs within a stage run in parallel. Define stages: [build, test, deploy] and jobs inherit the stage. All test jobs run at the same time, but deploy only runs after all tests pass.

Use only/except or rules to control when jobs run. Don't run expensive jobs on every branch. Deploy only on main. Run smoke tests on feature branches, full suite on main.

Cache dependencies between jobs. Use cache: with the correct key to avoid reinstalling npm/pip/bundler on every job. This dramatically speeds up pipelines.

Artifacts pass files between stages. Build output (compiled app, test reports) won't exist in later stages unless you declare them as artifacts. Use artifacts: reports: junit: for test results in the UI.

What is GitLab CI/CD?

GitLab CI/CD is a built-in continuous integration and delivery system in GitLab. You define a pipeline in .gitlab-ci.yml, and GitLab automatically runs it whenever you push code.

A pipeline is a sequence of stages. Each stage contains jobs — scripts that run in isolated containers (called runners). The pipeline:

  1. Builds your application
  2. Tests it automatically
  3. Deploys it if all tests pass

No external CI tools (Jenkins, CircleCI) required when you're already on GitLab.

Your First .gitlab-ci.yml

Create a .gitlab-ci.yml file in your repo root:

# .gitlab-ci.yml
stages:
  - test

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

Push this to GitLab. Under Build > Pipelines, you'll see a pipeline start automatically.

Pipeline Structure

A real pipeline with multiple stages:

stages:
  - build
  - test
  - deploy

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

# Test stage (runs after build, jobs run in parallel)
unit-tests:
  stage: test
  image: node:20
  script:
    - npm ci
    - npm run test:unit

lint:
  stage: test
  image: node:20
  script:
    - npm ci
    - npm run lint

# Deploy stage (runs after ALL test jobs pass)
deploy-staging:
  stage: deploy
  script:
    - echo "Deploying to staging..."
    - ./scripts/deploy.sh staging
  only:
    - main

Key concepts:

  • stages: Defines order. Later stages wait for earlier ones.
  • image: Docker image to run the job in.
  • script: Shell commands to execute.
  • artifacts: Files to pass between stages.
  • only: When to run the job.

Caching Dependencies

Without caching, every job reinstalls all dependencies from scratch:

# Without cache: each job runs `npm ci` from scratch
# With cache: node_modules is reused across jobs

variables:
  npm_config_cache: "$CI_PROJECT_DIR/.npm"

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

unit-tests:
  stage: test
  image: node:20
  <<: *node-cache
  script:
    - npm ci --cache .npm --prefer-offline
    - npm test

The cache key is based on package-lock.json. When dependencies don't change, the cache is reused. Pipeline time drops from minutes to seconds.

Running Tests in GitLab CI

Node.js / Jest

test:
  stage: test
  image: node:20
  cache:
    key: ${CI_COMMIT_REF_SLUG}
    paths:
      - node_modules/
  script:
    - npm ci
    - npm test -- --coverage
  coverage: '/Lines\s*:\s*(\d+\.\d+)%/'
  artifacts:
    reports:
      junit: junit.xml
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml

Python / pytest

test:
  stage: test
  image: python:3.12
  before_script:
    - pip install -r requirements.txt
  script:
    - pytest --junitxml=report.xml --cov=src --cov-report=xml
  artifacts:
    reports:
      junit: report.xml
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml

Go

test:
  stage: test
  image: golang:1.22
  script:
    - go test ./... -v -coverprofile=coverage.out
    - go tool cover -func=coverage.out

Environment Variables

Set secrets in Settings > CI/CD > Variables, then use them in jobs:

deploy:
  stage: deploy
  script:
    - echo "Deploying with API key: $DEPLOY_API_KEY"  # Set in GitLab UI
    - curl -X POST "$DEPLOY_URL" -H "Authorization: Bearer $DEPLOY_API_KEY"

Never commit secrets to .gitlab-ci.yml. Use GitLab CI/CD variables with Masked and Protected flags.

Predefined variables GitLab provides automatically:

test:
  script:
    - echo "Branch: $CI_COMMIT_BRANCH"
    - echo "Commit SHA: $CI_COMMIT_SHA"
    - echo "Pipeline ID: $CI_PIPELINE_ID"
    - echo "Job: $CI_JOB_NAME"
    - echo "Registry: $CI_REGISTRY_IMAGE"

Controlling When Jobs Run

Run Only on Main Branch

deploy-production:
  stage: deploy
  script:
    - ./deploy.sh production
  only:
    - main

Using Rules (More Flexible)

deploy-staging:
  stage: deploy
  script:
    - ./deploy.sh staging
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
    - if: '$CI_COMMIT_TAG'
      when: never

run-heavy-tests:
  stage: test
  script:
    - npm run test:e2e
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
      when: always
    - if: '$CI_MERGE_REQUEST_IID'
      when: manual
    - when: never

Skip CI for Specific Commits

git commit -m "Update README [skip ci]"

Add [skip ci] or [ci skip] to the commit message to skip the pipeline.

Docker Build and Push

stages:
  - build
  - test
  - push

variables:
  IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

build-image:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build -t $IMAGE_TAG .
    - docker push $IMAGE_TAG

test-image:
  stage: test
  image: $IMAGE_TAG
  script:
    - /app/health-check.sh

push-latest:
  stage: push
  image: docker:24
  services:
    - docker:24-dind
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker pull $IMAGE_TAG
    - docker tag $IMAGE_TAG $CI_REGISTRY_IMAGE:latest
    - docker push $CI_REGISTRY_IMAGE:latest
  only:
    - main

Parallel Test Execution

Split tests across multiple jobs for faster pipelines:

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 test files
    - npx jest --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL

Or use matrix for different environments:

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

End-to-End Testing in GitLab CI

Running E2E tests in CI requires a browser. Two options:

Option 1: Playwright with Chromium

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

Option 2: Selenium with Chrome

e2e-tests:
  stage: test
  image: selenium/standalone-chrome:4.18.1
  variables:
    SELENIUM_HOST: localhost
    SELENIUM_PORT: 4444
  script:
    - pip install selenium
    - python -m pytest tests/e2e/

Option 3: HelpMeTest (Cloud Browser Testing)

Skip the browser infrastructure entirely. HelpMeTest runs tests in cloud browsers — no Docker setup, no browser version pinning, no flaky container issues:

e2e-tests:
  stage: test
  image: node:20
  script:
    - npm install -g helpmetest
    - helpmetest test --token $HELPMETEST_API_TOKEN
  only:
    - main

Tests are defined in plain English:

Go to https://myapp.com
Click "Sign In"
Type "user@example.com" in the email field
Type "password123" in the password field
Click the "Login" button
Verify the dashboard loads

No browser management, no Selenium setup, no flakiness from timing issues.

Merge Request Pipelines

Automatically run pipelines on merge requests:

test:
  stage: test
  script:
    - npm test
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == "main"'

# Show test results directly in the MR
test:
  artifacts:
    reports:
      junit: junit-report.xml

With JUnit artifact reports, test failures appear directly in the merge request UI — no need to dig through logs.

GitLab Runners

Shared runners: GitLab.com provides free shared runners (minutes limit on free tier).

Self-hosted runners: Install for unlimited, faster pipelines:

# On your server/machine
<span class="hljs-comment"># Install runner
curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh <span class="hljs-pipe">| <span class="hljs-built_in">sudo bash
<span class="hljs-built_in">sudo apt install gitlab-runner

<span class="hljs-comment"># Register with your GitLab instance
gitlab-runner register
<span class="hljs-comment"># Enter GitLab URL, token (from Settings > CI/CD > Runners), and tags

Use self-hosted runners for:

  • Jobs that need specific hardware
  • Pipelines with many minutes/month
  • Jobs that need access to internal services

Complete Example: Node.js App with CI/CD

# .gitlab-ci.yml - Complete Node.js pipeline

image: node:20

stages:
  - install
  - test
  - build
  - deploy

variables:
  npm_config_cache: "$CI_PROJECT_DIR/.npm"

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

install-deps:
  stage: install
  script:
    - npm ci --cache .npm --prefer-offline
  artifacts:
    paths:
      - node_modules/
    expire_in: 1 hour
  cache:
    policy: pull-push

unit-tests:
  stage: test
  script:
    - npm run test:unit -- --coverage
  coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
  artifacts:
    reports:
      junit: junit.xml
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml

lint:
  stage: test
  script:
    - npm run lint

type-check:
  stage: test
  script:
    - npm run typecheck

build:
  stage: build
  script:
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 day
  only:
    - main
    - merge_requests

deploy-staging:
  stage: deploy
  script:
    - npm install -g netlify-cli
    - netlify deploy --dir=dist --site=$NETLIFY_SITE_ID --auth=$NETLIFY_AUTH_TOKEN
  environment:
    name: staging
    url: https://staging.myapp.com
  only:
    - main

deploy-production:
  stage: deploy
  script:
    - netlify deploy --dir=dist --site=$NETLIFY_SITE_ID --auth=$NETLIFY_AUTH_TOKEN --prod
  environment:
    name: production
    url: https://myapp.com
  when: manual
  only:
    - main

Debugging Failed Pipelines

Check job logs: Click the failed job in Build > Pipelines to see full stdout/stderr.

Run locally with gitlab-runner:

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

Enable debug output:

build:
  variables:
    CI_DEBUG_TRACE: "true"
  script:
    - ./build.sh

Use before_script for setup debugging:

test:
  before_script:
    - echo "Node version: $(node --version)"
    - echo "npm version: $(npm --version)"
    - echo "Working directory: $(pwd)"
    - ls -la
  script:
    - npm test

GitLab CI vs GitHub Actions

Feature GitLab CI GitHub Actions
Config file .gitlab-ci.yml .github/workflows/*.yml
Triggers Push, MR, schedule, API Push, PR, schedule, events
Runners Shared + self-hosted GitHub-hosted + self-hosted
Free minutes 400/month (SaaS) 2,000/month (public repos)
Docker in CI Native with dind Available
Package registry Built-in GitHub Packages
Caching Built-in key/path actions/cache
Matrix builds parallel.matrix strategy.matrix

GitLab CI wins if you're already on GitLab and want everything in one place. GitHub Actions wins for open-source projects on GitHub with a large marketplace of pre-built actions.

Summary

GitLab CI/CD pipeline basics:

  1. Create .gitlab-ci.yml in your repo root
  2. Define stages — the execution order
  3. Create jobs with image, script, artifacts, cache
  4. Control triggers with only/except or rules
  5. View results in Build > Pipelines

Start simple with one stage and two jobs. Add stages, caching, and deployment rules as your pipeline matures.

Read more