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. Useextendskeyword 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, notlocalhost. This trips up many developers. -
only/exceptvsrules: The legacyonly/exceptsyntax conflicts withrules. Never mix them in the same job. Preferrules— 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: 2hexplicitly 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.ymlfrom 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