GitLab CI/CD Pipeline Configuration
Comprehensive guide to configuring GitLab CI/CD pipelines with advanced patterns for Node.js projects including Docker builds, environments, and optimization
GitLab CI/CD Pipeline Configuration
GitLab CI/CD is a pipeline automation system built directly into GitLab that executes jobs defined in a .gitlab-ci.yml file at the root of your repository. Unlike external CI systems that require webhook integrations and separate dashboards, GitLab pipelines are a first-class citizen of the platform -- merge request widgets show pipeline status, environments track deployments, and the container registry sits next to your code. If you are building Node.js applications and want a single platform that handles source control, CI/CD, container registry, and deployment tracking, GitLab is the most integrated option available.
This article covers every major feature of GitLab CI/CD configuration, from basic syntax to advanced patterns like parent-child pipelines, Docker-in-Docker builds, and multi-project orchestration. The examples target Node.js projects, but the pipeline concepts apply to any language.
Prerequisites
- GitLab account (GitLab.com free tier or self-hosted GitLab 15+)
- A Node.js project with
package.json - Basic understanding of YAML syntax
- Docker fundamentals (for container build sections)
- Familiarity with Git branching and merge requests
- GitLab Runner available (shared runners on GitLab.com or self-hosted)
.gitlab-ci.yml Anatomy and Syntax
Every GitLab pipeline starts with a .gitlab-ci.yml file in the repository root. GitLab parses this file on every push and constructs a pipeline -- a directed acyclic graph of jobs organized into stages.
Here is the simplest possible pipeline:
# .gitlab-ci.yml
stages:
- test
run_tests:
stage: test
image: node:20-alpine
script:
- npm ci
- npm test
This defines one stage (test) with one job (run_tests). The image key specifies the Docker image the job runs inside. The script key is an array of shell commands executed sequentially. If any command exits with a non-zero status, the job fails and the pipeline fails.
Key structural rules:
- Reserved keywords like
stages,variables,default,include,workflowconfigure the pipeline itself. Everything else is a job name. - Job names can be any string except reserved keywords. They must contain at least one
script,trigger, orextendskey. - YAML anchors (
&anchorand*anchor) work, butextendsand!referenceare preferred for readability. - The file is validated before the pipeline runs. Syntax errors prevent pipeline creation entirely. Use the CI Lint tool at
https://gitlab.com/your-project/-/ci/lintto validate before pushing.
Pipeline Stages and Job Definitions
Stages define the execution order. Jobs within the same stage run in parallel (resource permitting). Jobs in the next stage only run after all jobs in the previous stage succeed.
stages:
- install
- lint
- test
- build
- deploy
install_dependencies:
stage: install
image: node:20-alpine
script:
- npm ci
artifacts:
paths:
- node_modules/
expire_in: 1 hour
lint_code:
stage: lint
image: node:20-alpine
script:
- npx eslint src/ --format compact
needs:
- install_dependencies
unit_tests:
stage: test
image: node:20-alpine
script:
- npm test -- --coverage
needs:
- install_dependencies
coverage: '/Lines\s*:\s*(\d+\.?\d*)%/'
build_app:
stage: build
image: node:20-alpine
script:
- npm run build
needs:
- lint_code
- unit_tests
artifacts:
paths:
- dist/
expire_in: 1 week
The needs keyword creates a directed acyclic graph (DAG) that allows jobs to start as soon as their specific dependencies finish, rather than waiting for the entire previous stage. In this example, lint_code and unit_tests both depend only on install_dependencies and will start in parallel once it completes. build_app waits for both lint_code and unit_tests.
The coverage keyword uses a regex to extract code coverage percentages from job output. GitLab displays this in the merge request widget.
Variables and Secrets Management
GitLab CI variables come from multiple sources, evaluated in this precedence order (highest to lowest):
- Job-level
variables - Pipeline trigger variables
- Project CI/CD variables (Settings > CI/CD > Variables)
- Group CI/CD variables
- Instance-level variables
.gitlab-ci.ymlglobalvariables- Predefined GitLab variables
variables:
NODE_ENV: production
NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm"
APP_VERSION: "1.0.${CI_PIPELINE_IID}"
build_app:
stage: build
variables:
NODE_ENV: production
SENTRY_RELEASE: "$APP_VERSION"
script:
- echo "Building version $APP_VERSION"
- npm run build
For secrets like API keys, database passwords, and Docker registry credentials, never put them in .gitlab-ci.yml. Use project-level CI/CD variables with the following settings:
- Masked: The value is hidden in job logs. Values must be at least 8 characters and contain no newlines.
- Protected: The variable is only available in pipelines running on protected branches or tags.
- Environment scope: Restrict the variable to specific environments (e.g.,
production,staging).
deploy_production:
stage: deploy
script:
# $DEPLOY_TOKEN is set in Settings > CI/CD > Variables
# marked as Masked + Protected + environment:production
- curl -H "Authorization: Bearer $DEPLOY_TOKEN" https://api.example.com/deploy
environment:
name: production
rules:
- if: $CI_COMMIT_BRANCH == "main"
Predefined variables you will use constantly:
| Variable | Value |
|---|---|
$CI_COMMIT_SHA |
Full commit hash |
$CI_COMMIT_SHORT_SHA |
First 8 characters of commit hash |
$CI_COMMIT_BRANCH |
Branch name (empty for tags) |
$CI_COMMIT_TAG |
Tag name (empty for branches) |
$CI_PIPELINE_IID |
Auto-incrementing pipeline ID within the project |
$CI_MERGE_REQUEST_IID |
Merge request number |
$CI_REGISTRY_IMAGE |
Full path to the project container registry |
$CI_PROJECT_DIR |
The directory where the repo is cloned |
Caching and Artifacts
Caching and artifacts are different concepts that people constantly confuse. Artifacts are files produced by a job that are passed to later jobs in the same pipeline and can be downloaded from the GitLab UI. Cache is a performance optimization that persists files between pipelines to avoid re-downloading dependencies.
variables:
NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm"
default:
cache:
key:
files:
- package-lock.json
paths:
- .npm/
policy: pull-push
install_dependencies:
stage: install
image: node:20-alpine
script:
- npm ci
artifacts:
paths:
- node_modules/
expire_in: 30 minutes
test:
stage: test
image: node:20-alpine
script:
- npm test
# Automatically receives node_modules/ artifact from install_dependencies
# Also benefits from .npm/ cache for any secondary installs
build:
stage: build
image: node:20-alpine
script:
- npm run build
cache:
key:
files:
- package-lock.json
paths:
- .npm/
policy: pull # Only pull cache, don't update it
artifacts:
paths:
- dist/
expire_in: 1 week
Cache key strategies:
key: files: Generates a hash from specified files. Whenpackage-lock.jsonchanges, the cache invalidates. This is the most reliable approach.key: "$CI_COMMIT_REF_SLUG": Per-branch cache. Branches share no cache, which prevents contamination but means the first pipeline on a new branch starts cold.key: "global-cache": Shared across all branches. Fast but risky if branches have different dependencies.policy: pull: Only download the cache, never upload. Use this on jobs that should not update the cache.policy: pull-push: Default. Download at start, upload at end.
A common pattern for Node.js is caching the npm download cache (.npm/) rather than node_modules/. The npm ci command always deletes and recreates node_modules/, but it reads from the local cache directory, cutting install times from 45 seconds to under 10 seconds on typical projects.
Docker-in-Docker Builds
Building Docker images inside a GitLab CI pipeline requires Docker-in-Docker (DinD) or Kaniko. DinD is simpler but requires privileged mode.
build_docker:
stage: build
image: docker:24
services:
- docker:24-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
DOCKER_BUILDKIT: "1"
before_script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
script:
- docker build
--cache-from "$CI_REGISTRY_IMAGE:latest"
--tag "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
--tag "$CI_REGISTRY_IMAGE:latest"
--build-arg BUILDKIT_INLINE_CACHE=1
.
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
- docker push "$CI_REGISTRY_IMAGE:latest"
The services key spins up a Docker daemon alongside the job container. $CI_REGISTRY_USER, $CI_REGISTRY_PASSWORD, and $CI_REGISTRY are predefined variables that authenticate to the project's built-in container registry.
For environments where privileged runners are not available, use Kaniko:
build_docker_kaniko:
stage: build
image:
name: gcr.io/kaniko-project/executor:v1.21.0-debug
entrypoint: [""]
script:
- /kaniko/executor
--context "$CI_PROJECT_DIR"
--dockerfile "$CI_PROJECT_DIR/Dockerfile"
--destination "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
--destination "$CI_REGISTRY_IMAGE:latest"
--cache=true
--cache-repo "$CI_REGISTRY_IMAGE/cache"
before_script:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"${CI_REGISTRY}\":{\"auth\":\"$(printf '%s:%s' "${CI_REGISTRY_USER}" "${CI_REGISTRY_PASSWORD}" | base64)\"}}}" > /kaniko/.docker/config.json
Kaniko builds images without a Docker daemon. It runs unprivileged and is the recommended approach for shared GitLab.com runners.
A production-grade Dockerfile for Node.js that pairs with these pipelines:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --production=false
COPY . .
RUN npm run build
RUN npm prune --production
FROM node:20-alpine
WORKDIR /app
RUN addgroup -g 1001 appgroup && adduser -u 1001 -G appgroup -s /bin/sh -D appuser
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]
Environment and Deployment Strategies
GitLab environments track deployments, show deployment history, and enable rollbacks. Each environment has a URL and a deployment lifecycle.
deploy_staging:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache curl
script:
- echo "Deploying $CI_COMMIT_SHORT_SHA to staging"
- |
curl -X POST \
-H "Authorization: Bearer $DEPLOY_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"image\": \"$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA\"}" \
https://api.example.com/deploy/staging
environment:
name: staging
url: https://staging.example.com
on_stop: stop_staging
rules:
- if: $CI_COMMIT_BRANCH == "develop"
stop_staging:
stage: deploy
image: alpine:latest
script:
- echo "Tearing down staging environment"
- curl -X DELETE https://api.example.com/deploy/staging
environment:
name: staging
action: stop
rules:
- if: $CI_COMMIT_BRANCH == "develop"
when: manual
allow_failure: true
deploy_production:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache curl
script:
- echo "Deploying $CI_COMMIT_SHORT_SHA to production"
- |
curl -X POST \
-H "Authorization: Bearer $DEPLOY_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"image\": \"$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA\"}" \
https://api.example.com/deploy/production
environment:
name: production
url: https://example.com
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
when: manual
allow_failure: false
The on_stop key links a deployment job to a teardown job. When a merge request is merged or the branch is deleted, GitLab can automatically trigger stop_staging to clean up the environment.
For production, the when: manual setting requires someone to click the "Deploy" button in the GitLab UI. This creates a manual gate that prevents accidental production deployments.
Merge Request Pipelines
By default, pipelines run on every push to every branch. This wastes runner minutes. Merge request pipelines run only when a merge request exists, and they provide richer context like access to $CI_MERGE_REQUEST_IID.
workflow:
rules:
- if: $CI_MERGE_REQUEST_IID
- if: $CI_COMMIT_TAG
- if: $CI_COMMIT_BRANCH == "main"
test:
stage: test
image: node:20-alpine
script:
- npm ci
- npm test
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
The workflow block defines when pipelines are created at all. This configuration creates pipelines for merge requests, tags, and pushes to main -- but not for feature branch pushes without a merge request. This typically cuts runner usage by 40-60%.
You can also run specific jobs only on merge requests:
danger_review:
stage: lint
image: node:20-alpine
script:
- npx danger ci
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
Include and Extends for DRY Configs
As pipelines grow, .gitlab-ci.yml files become unwieldy. The include keyword imports configuration from other files, and extends creates job templates.
# .gitlab/ci/templates.yml
.node_job:
image: node:20-alpine
variables:
NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm"
cache:
key:
files:
- package-lock.json
paths:
- .npm/
before_script:
- npm ci
.deploy_job:
image: alpine:latest
before_script:
- apk add --no-cache curl openssh-client
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | ssh-add -
# .gitlab-ci.yml
include:
- local: ".gitlab/ci/templates.yml"
- local: ".gitlab/ci/deploy.yml"
stages:
- test
- build
- deploy
unit_tests:
extends: .node_job
stage: test
script:
- npm test -- --coverage
integration_tests:
extends: .node_job
stage: test
script:
- npm run test:integration
services:
- postgres:15-alpine
variables:
POSTGRES_DB: test_db
POSTGRES_USER: runner
POSTGRES_PASSWORD: runner_pass
DATABASE_URL: "postgresql://runner:runner_pass@postgres:5432/test_db"
Include sources:
local: Files from the same repository.file: Files from another project in the same GitLab instance.remote: Files from any URL (raw YAML).template: GitLab-maintained templates (e.g.,Security/SAST.gitlab-ci.yml).
include:
# From another project
- project: "devops/pipeline-templates"
ref: main
file: "/templates/nodejs.yml"
# From a URL
- remote: "https://raw.githubusercontent.com/your-org/templates/main/deploy.yml"
# GitLab built-in template
- template: Security/SAST.gitlab-ci.yml
The !reference tag lets you cherry-pick specific keys from other jobs without using full extends:
.setup:
before_script:
- npm ci
- npm run db:migrate
test:
stage: test
image: node:20-alpine
before_script:
- !reference [.setup, before_script]
script:
- npm test
Rules vs Only/Except for Conditional Jobs
The only/except keywords are legacy. Use rules instead. Rules are evaluated top-to-bottom, and the first match wins.
# Legacy - DO NOT USE
deploy:
only:
- main
except:
- schedules
# Modern - USE THIS
deploy:
rules:
- if: $CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE != "schedule"
when: on_success
- when: never
Rules support complex conditions:
build_docker:
stage: build
script:
- docker build .
rules:
# Run on tags matching semver
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
when: on_success
# Run on main branch, but manually
- if: $CI_COMMIT_BRANCH == "main"
when: manual
allow_failure: true
# Run on merge requests when Dockerfile changed
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes:
- Dockerfile
- docker-compose.yml
- .dockerignore
when: on_success
# Default: do not run
- when: never
The changes condition checks if specific files were modified. This is powerful for monorepos where you only want to rebuild services that actually changed.
Parent-Child and Multi-Project Pipelines
Parent-child pipelines let you split a large .gitlab-ci.yml into separate pipelines that run as downstream jobs. This is essential for monorepos.
# .gitlab-ci.yml (parent)
stages:
- trigger
trigger_api:
stage: trigger
trigger:
include: packages/api/.gitlab-ci.yml
strategy: depend
rules:
- changes:
- packages/api/**/*
trigger_web:
stage: trigger
trigger:
include: packages/web/.gitlab-ci.yml
strategy: depend
rules:
- changes:
- packages/web/**/*
trigger_shared:
stage: trigger
trigger:
include: packages/shared/.gitlab-ci.yml
strategy: depend
rules:
- changes:
- packages/shared/**/*
# packages/api/.gitlab-ci.yml (child)
stages:
- test
- build
test_api:
stage: test
image: node:20-alpine
script:
- cd packages/api
- npm ci
- npm test
build_api:
stage: build
image: docker:24
services:
- docker:24-dind
script:
- cd packages/api
- docker build -t "$CI_REGISTRY_IMAGE/api:$CI_COMMIT_SHORT_SHA" .
- docker push "$CI_REGISTRY_IMAGE/api:$CI_COMMIT_SHORT_SHA"
The strategy: depend makes the parent pipeline wait for the child pipeline to complete. Without it, the parent job succeeds immediately after triggering the child.
Multi-project pipelines trigger pipelines in other GitLab projects:
deploy_infrastructure:
stage: deploy
trigger:
project: devops/infrastructure
branch: main
strategy: depend
variables:
APP_VERSION: "$CI_COMMIT_SHORT_SHA"
DEPLOY_ENV: production
This triggers a pipeline in the devops/infrastructure project, passing variables downstream. The infrastructure project can use those variables to deploy the correct application version.
GitLab Runner Setup and Configuration
GitLab Runners are agents that execute pipeline jobs. GitLab.com provides shared runners, but serious projects need dedicated runners for performance and security.
Install a runner on a Linux server:
# Download and install GitLab Runner
curl -L --output /usr/local/bin/gitlab-runner \
https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-amd64
chmod +x /usr/local/bin/gitlab-runner
# Create a dedicated user
useradd --comment 'GitLab Runner' --create-home gitlab-runner --shell /bin/bash
# Install and start the service
gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner
gitlab-runner start
# Register the runner
gitlab-runner register \
--non-interactive \
--url "https://gitlab.com/" \
--registration-token "PROJECT_REGISTRATION_TOKEN" \
--executor "docker" \
--docker-image "node:20-alpine" \
--description "nodejs-runner" \
--tag-list "nodejs,docker" \
--docker-volumes "/certs/client" \
--docker-privileged
Runner configuration lives in /etc/gitlab-runner/config.toml:
concurrent = 4
check_interval = 3
[[runners]]
name = "nodejs-runner"
url = "https://gitlab.com/"
token = "RUNNER_TOKEN"
executor = "docker"
[runners.docker]
tls_verify = false
image = "node:20-alpine"
privileged = true
disable_entrypoint_overwrite = false
oom_kill_disable = false
disable_cache = false
volumes = ["/certs/client", "/cache"]
shm_size = 0
[runners.cache]
Type = "s3"
Shared = true
[runners.cache.s3]
ServerAddress = "s3.amazonaws.com"
BucketName = "gitlab-runner-cache"
BucketLocation = "us-east-1"
Key configuration decisions:
concurrent: Number of jobs that can run simultaneously on this runner. Set this based on CPU and memory. A 4-core server with 16GB RAM comfortably runs 4 Node.js jobs in parallel.privileged: Required for Docker-in-Docker. Only enable on trusted runners.- S3 cache: Distributed cache shared across multiple runner instances. Essential if you run auto-scaling runners.
- Tags: Use tags like
nodejs,docker,gputo direct specific jobs to runners with the right capabilities.
Target specific runners using tags:
gpu_training:
stage: build
tags:
- gpu
- linux
script:
- python train_model.py
Pipeline Efficiency and Optimization
A slow pipeline is a pipeline people ignore. Here are concrete optimizations that cut pipeline duration in real Node.js projects.
1. Use needs to create a DAG instead of linear stages.
Linear stages force all jobs in stage N to complete before stage N+1 starts. With needs, a job starts as soon as its specific dependencies finish.
# Before: 12 minutes (sequential stages)
# After: 7 minutes (DAG with parallel paths)
lint:
stage: test
script: npx eslint src/
needs: [] # No dependencies, starts immediately
unit_tests:
stage: test
script: npm test
needs: []
integration_tests:
stage: test
script: npm run test:integration
needs: []
build:
stage: build
script: npm run build
needs: [lint, unit_tests] # Starts when lint + unit pass
# Does NOT wait for integration_tests
2. Use interruptible to cancel redundant pipelines.
When you push multiple commits to a branch in quick succession, each push triggers a pipeline. Mark jobs as interruptible so old pipelines auto-cancel when a new one starts.
workflow:
auto_cancel:
on_new_commit: interruptible
default:
interruptible: true
deploy_production:
interruptible: false # Never cancel production deploys
3. Split test suites with parallel.
The parallel keyword runs multiple instances of a job. Combined with test splitting, this distributes tests across runners.
test:
stage: test
image: node:20-alpine
parallel: 4
script:
- npm ci
- npx jest --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
With parallel: 4, GitLab creates four job instances. Jest's --shard flag splits the test suite automatically. A test suite that takes 8 minutes on one runner finishes in about 2.5 minutes with 4 parallel shards.
4. Minimize artifact sizes.
Large artifacts slow down every subsequent job that downloads them. Be specific about what you pass forward.
build:
artifacts:
paths:
- dist/ # Only the build output
exclude:
- dist/**/*.map # Exclude source maps (often 3-5x larger than bundles)
expire_in: 1 hour # Short expiry for intermediate artifacts
5. Use resource_group to prevent concurrent deployments.
deploy_production:
stage: deploy
resource_group: production
script:
- ./deploy.sh
The resource_group ensures only one deployment to production runs at a time, even if multiple pipelines trigger simultaneously.
Complete Working Example
Here is a full .gitlab-ci.yml for a Node.js monorepo with an API service and a web frontend. It covers linting, testing, Docker builds, and environment-specific deployments.
# .gitlab-ci.yml - Node.js Monorepo Pipeline
# Supports: lint, test, build, Docker push, deploy to staging/production
variables:
NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm"
DOCKER_TLS_CERTDIR: "/certs"
DOCKER_BUILDKIT: "1"
NODE_IMAGE: "node:20-alpine"
stages:
- install
- quality
- test
- build
- deploy
workflow:
rules:
- if: $CI_MERGE_REQUEST_IID
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_BRANCH == "develop"
auto_cancel:
on_new_commit: interruptible
default:
interruptible: true
retry:
max: 1
when:
- runner_system_failure
- stuck_or_timeout_failure
# ─── Templates ───────────────────────────────────────────
.node_base:
image: $NODE_IMAGE
cache:
key:
files:
- package-lock.json
paths:
- .npm/
policy: pull
# ─── Install ─────────────────────────────────────────────
install_dependencies:
extends: .node_base
stage: install
cache:
key:
files:
- package-lock.json
paths:
- .npm/
policy: pull-push
script:
- npm ci --include=dev
artifacts:
paths:
- node_modules/
- packages/api/node_modules/
- packages/web/node_modules/
expire_in: 30 minutes
# ─── Quality ─────────────────────────────────────────────
lint:
extends: .node_base
stage: quality
needs:
- install_dependencies
script:
- npx eslint packages/ --format compact --max-warnings 0
allow_failure: false
type_check:
extends: .node_base
stage: quality
needs:
- install_dependencies
script:
- npx tsc --noEmit --project packages/api/tsconfig.json
- npx tsc --noEmit --project packages/web/tsconfig.json
audit:
extends: .node_base
stage: quality
needs:
- install_dependencies
script:
- npm audit --audit-level=high --production
allow_failure: true
# ─── Test ────────────────────────────────────────────────
test_api:
extends: .node_base
stage: test
needs:
- lint
parallel: 2
services:
- name: postgres:15-alpine
alias: postgres
- name: redis:7-alpine
alias: redis
variables:
POSTGRES_DB: test_db
POSTGRES_USER: runner
POSTGRES_PASSWORD: runner_pass
DATABASE_URL: "postgresql://runner:runner_pass@postgres:5432/test_db"
REDIS_URL: "redis://redis:6379"
script:
- cd packages/api
- npx jest --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL --coverage --forceExit
coverage: '/Lines\s*:\s*(\d+\.?\d*)%/'
artifacts:
reports:
junit: packages/api/junit.xml
coverage_report:
coverage_format: cobertura
path: packages/api/coverage/cobertura-coverage.xml
when: always
test_web:
extends: .node_base
stage: test
needs:
- lint
script:
- cd packages/web
- npx jest --coverage --forceExit
coverage: '/Lines\s*:\s*(\d+\.?\d*)%/'
artifacts:
reports:
junit: packages/web/junit.xml
when: always
# ─── Build ───────────────────────────────────────────────
build_api_image:
stage: build
image: docker:24
services:
- docker:24-dind
needs:
- test_api
variables:
IMAGE_TAG: "$CI_REGISTRY_IMAGE/api"
before_script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
script:
- docker build
--cache-from "$IMAGE_TAG:latest"
--tag "$IMAGE_TAG:$CI_COMMIT_SHORT_SHA"
--tag "$IMAGE_TAG:latest"
--build-arg BUILDKIT_INLINE_CACHE=1
--file packages/api/Dockerfile
.
- docker push "$IMAGE_TAG:$CI_COMMIT_SHORT_SHA"
- docker push "$IMAGE_TAG:latest"
rules:
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_BRANCH == "develop"
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
build_web_image:
stage: build
image: docker:24
services:
- docker:24-dind
needs:
- test_web
variables:
IMAGE_TAG: "$CI_REGISTRY_IMAGE/web"
before_script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
script:
- docker build
--cache-from "$IMAGE_TAG:latest"
--tag "$IMAGE_TAG:$CI_COMMIT_SHORT_SHA"
--tag "$IMAGE_TAG:latest"
--build-arg BUILDKIT_INLINE_CACHE=1
--file packages/web/Dockerfile
.
- docker push "$IMAGE_TAG:$CI_COMMIT_SHORT_SHA"
- docker push "$IMAGE_TAG:latest"
rules:
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_BRANCH == "develop"
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
# ─── Deploy ──────────────────────────────────────────────
deploy_staging:
stage: deploy
image: bitnami/kubectl:latest
needs:
- build_api_image
- build_web_image
interruptible: false
resource_group: staging
script:
- kubectl config use-context $KUBE_CONTEXT_STAGING
- kubectl set image deployment/api api="$CI_REGISTRY_IMAGE/api:$CI_COMMIT_SHORT_SHA" -n staging
- kubectl set image deployment/web web="$CI_REGISTRY_IMAGE/web:$CI_COMMIT_SHORT_SHA" -n staging
- kubectl rollout status deployment/api -n staging --timeout=300s
- kubectl rollout status deployment/web -n staging --timeout=300s
environment:
name: staging
url: https://staging.example.com
rules:
- if: $CI_COMMIT_BRANCH == "develop"
deploy_production:
stage: deploy
image: bitnami/kubectl:latest
needs:
- build_api_image
- build_web_image
interruptible: false
resource_group: production
script:
- kubectl config use-context $KUBE_CONTEXT_PRODUCTION
- kubectl set image deployment/api api="$CI_REGISTRY_IMAGE/api:$CI_COMMIT_SHORT_SHA" -n production
- kubectl set image deployment/web web="$CI_REGISTRY_IMAGE/web:$CI_COMMIT_SHORT_SHA" -n production
- kubectl rollout status deployment/api -n production --timeout=600s
- kubectl rollout status deployment/web -n production --timeout=600s
environment:
name: production
url: https://example.com
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
when: manual
allow_failure: false
This pipeline creates the following execution flow:
install_dependencies (30s)
├── lint (15s)
│ ├── test_api [2 shards] (90s)
│ │ └── build_api_image (60s)
│ └── test_web (45s)
│ └── build_web_image (55s)
├── type_check (20s)
└── audit (10s)
└── deploy_staging or deploy_production (30s)
Total wall-clock time: ~4 minutes (vs ~8 minutes linear)
Validating the Pipeline Locally
Before pushing and waiting for a pipeline to run, validate your .gitlab-ci.yml locally. GitLab provides a CLI tool and an API endpoint.
Using the API:
# Validate .gitlab-ci.yml against your project
curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
--header "Content-Type: application/json" \
--data '{"content": "'"$(cat .gitlab-ci.yml | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))')"'"}' \
"https://gitlab.com/api/v4/projects/$PROJECT_ID/ci/lint"
A successful response:
{
"valid": true,
"merged_yaml": "---\nstages:\n- install\n...",
"errors": [],
"warnings": []
}
You can also write a simple validation script in Node.js:
// scripts/validate-ci.js
var https = require("https");
var fs = require("fs");
var path = require("path");
var ciFile = fs.readFileSync(
path.join(__dirname, "..", ".gitlab-ci.yml"),
"utf8"
);
var projectId = process.env.GITLAB_PROJECT_ID;
var token = process.env.GITLAB_TOKEN;
var postData = JSON.stringify({ content: ciFile });
var options = {
hostname: "gitlab.com",
port: 443,
path: "/api/v4/projects/" + projectId + "/ci/lint",
method: "POST",
headers: {
"Content-Type": "application/json",
"PRIVATE-TOKEN": token,
"Content-Length": Buffer.byteLength(postData),
},
};
var req = https.request(options, function (res) {
var body = "";
res.on("data", function (chunk) {
body += chunk;
});
res.on("end", function () {
var result = JSON.parse(body);
if (result.valid) {
console.log("Pipeline configuration is valid.");
process.exit(0);
} else {
console.error("Pipeline configuration errors:");
result.errors.forEach(function (err) {
console.error(" - " + err);
});
process.exit(1);
}
});
});
req.on("error", function (err) {
console.error("Request failed:", err.message);
process.exit(1);
});
req.write(postData);
req.end();
GITLAB_PROJECT_ID=12345 GITLAB_TOKEN=glpat-xxxxx node scripts/validate-ci.js
# Pipeline configuration is valid.
Common Issues and Troubleshooting
1. ERROR: Job failed: exit code 137
This means the container was killed by the OOM (Out of Memory) killer. Node.js defaults to a 1.5GB heap limit, and if your build or test suite exceeds the runner's memory allocation, the kernel kills the process.
Running with gitlab-runner 16.8.0 (abc123)
...
ERROR: Job failed: exit code 137
Fix: Increase the runner's memory limit or constrain Node.js heap size:
test:
variables:
NODE_OPTIONS: "--max-old-space-size=512"
script:
- npm test
If you control the runner, increase the Docker memory limit in config.toml:
[runners.docker]
memory = "4g"
memory_swap = "4g"
2. fatal: git fetch-pack: expected shallow list (Shallow Clone Issues)
GitLab runners perform shallow clones by default (GIT_DEPTH=20). Some operations like git describe, git log --all, or commit-count-based versioning fail because the full history is not available.
fatal: git fetch-pack: expected shallow list
ERROR: Job failed: exit status 128
Fix: Set GIT_DEPTH to 0 for a full clone, or increase it:
variables:
GIT_DEPTH: 0 # Full clone
# Or per-job:
build:
variables:
GIT_DEPTH: 100
script:
- VERSION=$(git describe --tags --always)
- echo "Building $VERSION"
3. ERROR: Cannot connect to the Docker daemon at tcp://docker:2376
This happens when using Docker-in-Docker but the DinD service has not started yet, or TLS certificates are not configured correctly.
Cannot connect to the Docker daemon at tcp://docker:2376. Is the docker daemon running?
ERROR: Job failed: exit code 1
Fix: Ensure you have the correct DOCKER_TLS_CERTDIR setting and the DinD service version matches the client:
build:
image: docker:24
services:
- docker:24-dind # Must match client version
variables:
DOCKER_TLS_CERTDIR: "/certs"
DOCKER_HOST: "tcp://docker:2376"
DOCKER_CERT_PATH: "/certs/client"
script:
- sleep 5 # Give DinD a moment to start
- docker info
- docker build .
If using a runner without TLS support, disable it:
variables:
DOCKER_TLS_CERTDIR: ""
DOCKER_HOST: "tcp://docker:2375"
4. This job is stuck because the project doesn't have any runners online assigned to it
This means no runner is available that matches the job's tag requirements, or all runners are busy.
This job is stuck because the project doesn't have any runners online assigned to it.
Go to project CI settings to enable shared runners or assign a specific runner to this project.
Fix: Check your job tags match available runners. Remove tags if you want shared runners to pick up the job:
# This only runs on runners tagged with "gpu"
gpu_job:
tags:
- gpu
script:
- echo "needs a GPU runner"
# This runs on any available runner (including shared)
general_job:
# No tags specified
script:
- echo "runs anywhere"
Verify runner status:
gitlab-runner list
gitlab-runner verify --delete # Remove stale runners
5. yaml invalid: (.gitlab-ci.yml): did not find expected key while parsing a block mapping
YAML indentation errors. YAML is whitespace-sensitive and mixing tabs with spaces or incorrect nesting causes parsing failures.
yaml invalid
(.gitlab-ci.yml): did not find expected key while parsing a block mapping at line 45 column 3
Fix: Use a YAML linter before committing. Ensure consistent 2-space indentation. Never use tabs in YAML.
# Install yamllint
pip install yamllint
# Validate
yamllint .gitlab-ci.yml
Common YAML traps:
- Colons in values must be quoted:
script: echo "key: value"notscript: echo key: value - Multi-line strings need
|or>block scalars - Anchor names cannot contain special characters
Best Practices
Pin image versions explicitly. Use
node:20.11-alpinenotnode:latest. Alatesttag that changes between pipeline runs causes non-reproducible builds that are extremely difficult to debug. The 10 seconds you save by not looking up the current version costs hours when a build breaks unexpectedly on a Monday morning.Use
rulesexclusively, neveronly/except. Theonly/exceptsyntax is limited, unintuitive, and officially discouraged. Rules give you boolean logic, variable comparisons, file change detection, and explicitwhencontrol in a single consistent syntax.Set
expire_inon every artifact. Without expiration, artifacts accumulate and consume disk space on your GitLab instance. Intermediate artifacts (likenode_modules/) should expire in minutes. Final build artifacts should expire in days or weeks. Only release artifacts should persist indefinitely.Run
npm ciinstead ofnpm install. Thecicommand is purpose-built for CI environments. It deletesnode_modules/and installs exactly what is inpackage-lock.json. It is faster, deterministic, and catches lockfile inconsistencies thatnpm installsilently ignores.Keep jobs under 10 minutes. If a job takes longer, split it. Use
parallelfor test suites, separate build jobs for separate services, andneedsto run independent jobs concurrently. A 15-minute pipeline kills developer flow. A 4-minute pipeline gets waited on.Use
resource_groupfor deployment jobs. Concurrent deployments to the same environment cause race conditions, partial deployments, and rollback confusion. A resource group serializes deployments so only one runs at a time.Make production deploys manual and tag-based. Production should never deploy automatically from a branch push. Require a Git tag (preferably semver) and a manual approval click. This creates an audit trail and a deliberate deployment decision.
Store pipeline templates in a shared project. When you have more than three repositories with similar pipelines, extract common patterns into a
devops/pipeline-templatesproject and useinclude: projectto reference them. Updating one template updates all consuming projects on their next pipeline run.Test your pipeline changes in merge requests. The GitLab CI lint tool catches syntax errors, but it does not catch logic errors. Create a branch, modify the pipeline, open a merge request, and verify the pipeline runs correctly before merging to main.
Use
interruptibleandauto_cancelaggressively. Developers push multiple commits during active development. Without auto-cancellation, you are paying for pipelines that nobody will ever look at. Mark every job except deployment as interruptible.
References
- GitLab CI/CD Pipeline Configuration Reference -- The definitive keyword reference. Bookmark this.
- GitLab CI/CD Variables -- Complete list of predefined variables and how to define custom ones.
- GitLab Runner Documentation -- Installation, configuration, and executor types.
- Docker-in-Docker and Kaniko -- Building Docker images in CI.
- Parent-Child Pipelines -- Multi-pipeline orchestration patterns.
- Pipeline Efficiency Guide -- Official optimization recommendations.
- GitLab CI/CD Examples -- Language-specific pipeline templates.
- Environments and Deployments -- Tracking deployments, rollbacks, and environment scopes.