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:
- Builds your application
- Tests it automatically
- 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:
- Create
.gitlab-ci.ymlin your repo root - Define
stages— the execution order - Create jobs with
image,script,artifacts,cache - Control triggers with
only/exceptorrules - View results in Build > Pipelines
Start simple with one stage and two jobs. Add stages, caching, and deployment rules as your pipeline matures.