Version Control

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, workflow configure 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, or extends key.
  • YAML anchors (&anchor and *anchor) work, but extends and !reference are 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/lint to 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):

  1. Job-level variables
  2. Pipeline trigger variables
  3. Project CI/CD variables (Settings > CI/CD > Variables)
  4. Group CI/CD variables
  5. Instance-level variables
  6. .gitlab-ci.yml global variables
  7. 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. When package-lock.json changes, 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, gpu to 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" not script: 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-alpine not node:latest. A latest tag 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 rules exclusively, never only/except. The only/except syntax is limited, unintuitive, and officially discouraged. Rules give you boolean logic, variable comparisons, file change detection, and explicit when control in a single consistent syntax.

  • Set expire_in on every artifact. Without expiration, artifacts accumulate and consume disk space on your GitLab instance. Intermediate artifacts (like node_modules/) should expire in minutes. Final build artifacts should expire in days or weeks. Only release artifacts should persist indefinitely.

  • Run npm ci instead of npm install. The ci command is purpose-built for CI environments. It deletes node_modules/ and installs exactly what is in package-lock.json. It is faster, deterministic, and catches lockfile inconsistencies that npm install silently ignores.

  • Keep jobs under 10 minutes. If a job takes longer, split it. Use parallel for test suites, separate build jobs for separate services, and needs to run independent jobs concurrently. A 15-minute pipeline kills developer flow. A 4-minute pipeline gets waited on.

  • Use resource_group for 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-templates project and use include: project to 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 interruptible and auto_cancel aggressively. 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

Powered by Contentful