GitLab CI: Basic Pipeline
GitLab CI reference: basic pipeline
TL;DR
- Bottom line: GitLab CI/CD pipelines are configured in
.gitlab-ci.ymlat the repo root, defining stages with jobs that run in parallel within each stage and sequentially across stages. - Key tool/command:
.gitlab-ci.ymlwithstages:,script:,rules:, andimage:keywords. - Watch out for: Using deprecated
only:/except:instead ofrules:-- removed in GitLab 17.0. - Works with: Any language, Docker-based runners, Kubernetes executors.
Constraints
- Pipeline config MUST be in
.gitlab-ci.ymlat repo root (or custom path in settings). - Use
rules:instead ofonly:/except:-- deprecated since GitLab 14.x, removed in 17.0. - Never store secrets in
.gitlab-ci.yml-- use CI/CD Variables with masking. - Jobs in same stage run in parallel -- use
needs:for DAG ordering. - Shared runners have 3-hour default timeout -- configure
timeout:for long jobs.
Quick Reference
| Keyword | Purpose | Example |
|---|---|---|
stages | Define pipeline stages | stages: [build, test, deploy] |
image | Docker image for job | image: node:20-alpine |
script | Commands to execute | script: [npm ci, npm test] |
stage | Assign job to stage | stage: test |
rules | Conditional execution | rules: - if: $CI_COMMIT_BRANCH == "main" |
needs | DAG dependencies | needs: [build_job] |
artifacts | Files between jobs | artifacts: paths: [dist/] |
cache | Persist between runs | cache: paths: [node_modules/] |
variables | Env variables | variables: NODE_ENV: production |
default | Default settings | default: image: node:20 |
include | Import external YAML | include: - template: ... |
extends | Inherit from job | extends: .base_test |
environment | Deploy environment | environment: name: production |
when | Execution condition | when: manual |
parallel | Run N times | parallel: 5 |
services | Linked containers | services: [postgres:16] |
Decision Tree
START
├── Simple app (single language)?
│ ├── YES → Three stages: build → test → deploy with shared image
│ └── NO ↓
├── Multiple services/microservices?
│ ├── YES → Use include: to split config, needs: for DAG
│ └── NO ↓
├── Need deployment approval?
│ ├── YES → when: manual + environment: protection
│ └── NO ↓
├── Need matrix testing?
│ ├── YES → parallel:matrix: with version variables
│ └── NO ↓
└── DEFAULT → stages [build, test, deploy] with rules:
Step-by-Step Guide
1. Create .gitlab-ci.yml
Create the file at repo root. GitLab auto-detects it. [src2]
stages:
- build
- test
- deploy
default:
image: node:20-alpine
build:
stage: build
script:
- npm ci
- npm run build
artifacts:
paths:
- dist/
expire_in: 1 hour
test:
stage: test
script:
- npm ci
- npm test
deploy:
stage: deploy
script:
- echo "Deploy to production"
rules:
- if: $CI_COMMIT_BRANCH == "main"
environment:
name: production
Verify: Push → CI/CD → Pipelines → see 3-stage pipeline.
2. Add caching
Cache dependencies for faster pipelines. [src1]
default:
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
Verify: Second run shows "Restoring cache" and faster install.
3. Add rules for conditional execution
Control when jobs run. [src1]
test:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
Verify: Create MR → test runs. Push to non-MR branch → test skipped.
4. Add parallel matrix testing
Test across versions. [src1]
test:
parallel:
matrix:
- NODE_VERSION: ['18', '20', '22']
image: node:${NODE_VERSION}-alpine
script:
- npm ci
- npm test
Verify: Pipeline shows 3 parallel test jobs.
5. Add manual deployment
Deploy with approval gate. [src5]
deploy_production:
stage: deploy
script:
- echo "Deploying..."
environment:
name: production
url: https://example.com
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
Verify: Merge to main → deploy shows "play" button.
Code Examples
Complete Node.js pipeline
# .gitlab-ci.yml
stages:
- lint
- test
- build
- deploy
default:
image: node:20-alpine
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
lint:
stage: lint
script:
- npm ci
- npm run lint
test:
stage: test
script:
- npm ci
- npm test -- --coverage
artifacts:
reports:
junit: report.xml
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
coverage: '/Lines\s*:\s*(\d+\.?\d*)%/'
build:
stage: build
script:
- npm ci
- npm run build
artifacts:
paths:
- dist/
expire_in: 1 week
deploy_production:
stage: deploy
script:
- echo "Deploy dist/"
environment:
name: production
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
needs: [build]
Docker build and push pipeline
# .gitlab-ci.yml
stages:
- build
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 $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
- docker build -t $CI_REGISTRY_IMAGE:latest .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
- docker push $CI_REGISTRY_IMAGE:latest
rules:
- if: $CI_COMMIT_BRANCH == "main"
Anti-Patterns
Wrong: Using only/except
# ❌ BAD — only/except is deprecated, removed in GitLab 17.0
deploy:
script: echo "deploy"
only:
- main
Correct: Using rules
# ✅ GOOD — rules is current standard
deploy:
script: echo "deploy"
rules:
- if: $CI_COMMIT_BRANCH == "main"
Wrong: Cache without file-based key
# ❌ BAD — cache never invalidates
cache:
paths:
- node_modules/
Correct: Cache keyed to lock file
# ✅ GOOD — invalidates when deps change
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
Wrong: Secrets in YAML
# ❌ BAD — visible to anyone with repo access
variables:
API_KEY: "sk-12345-secret"
Correct: Using CI/CD Variables
# ✅ GOOD — stored in Settings → CI/CD → Variables
deploy:
script:
- curl -H "Authorization: Bearer $API_KEY" https://api.example.com
Common Pitfalls
- Pipeline not triggering: .gitlab-ci.yml must be on default branch first. Fix: merge to main, then branch. [src2]
- Job timeout: 3-hour default on shared runners. Fix: set
timeout: 6h. [src1] - Artifacts not available: They expire by default. Fix: set
expire_inor useneeds:. [src1] - Cache inconsistency: Cache is per-runner. Fix: use
key:files:or distributed cache. [src6] - Services not connecting: Use image name as hostname, not localhost. [src1]
- YAML syntax errors: Fix: use Pipeline Editor for validation. [src7]
Diagnostic Commands
# Validate .gitlab-ci.yml locally
gitlab-ci-lint .gitlab-ci.yml
# Check pipeline status via API
curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.com/api/v4/projects/$PROJECT_ID/pipelines?per_page=5"
# List CI variables available in pipeline
env | grep CI_
# Debug job locally
gitlab-runner exec docker test_job
Version History & Compatibility
| Version | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| GitLab 17.x | Current | Removed only/except | Use rules: keyword |
| GitLab 16.x | Previous | CI/CD Components GA | Adopt include: component |
| GitLab 15.x | EOL | Removed CI_BUILD_* vars | Use CI_JOB_* equivalents |
| Shared runners (SaaS) | Active | — | 400 min/month Free, 10K Premium |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Project on GitLab | Project on GitHub | GitHub Actions |
| Need built-in container registry | Complex multi-repo orchestration | Jenkins or Tekton |
| Want integrated DevSecOps | 100+ parallel jobs on free tier | Self-hosted runners |
| Need environment dashboards | Vendor-agnostic CI required | Jenkins or Dagger |
Important Caveats
- CI/CD minutes on SaaS: 400/month (Free), 10K (Premium), 50K (Ultimate). Self-managed has no limits.
cache:is per-runner, not global. Use S3/GCS distributed cache for consistency.artifacts:count against storage quotas. Setexpire_in:to manage.services:only work with Docker or Kubernetes executors, not shell executor.needs:creates DAG pipeline, running jobs out of stage order -- visualize in UI.