Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Speed up type-checking

Running Monorepo Pipelines on GitLab CI

Most monorepo CI guidance assumes GitHub Actions, but GitLab CI has its own strengths for monorepos: rules:changes to run jobs only when paths change, needs for a job graph that runs as soon as dependencies finish, parallel:matrix for fan-out, and parent-child pipelines that generate per-package jobs dynamically. It also has pitfalls — merge request pipelines that compare against the wrong base, caches keyed too broadly, and shallow clones that break affected detection. This guide builds a fast, correct pipeline for a pnpm and Turborepo monorepo on GitLab, from caching through affected runs to dynamic child pipelines. The same structure works with Nx or with plain pnpm filters; only the commands inside the jobs change.

A baseline pipeline

# .gitlab-ci.yml
default:
  image: node:22-slim
  before_script:
    - corepack enable
    - pnpm config set store-dir .pnpm-store
    - pnpm install --frozen-lockfile

variables:
  GIT_DEPTH: 0                      # full history for affected detection
  TURBO_CACHE_DIR: .turbo/cache

cache:
  - key:
      files: [pnpm-lock.yaml]
    paths: [.pnpm-store]
    policy: pull-push
  - key: turbo-$CI_COMMIT_REF_SLUG
    fallback_keys: [turbo-$CI_DEFAULT_BRANCH]
    paths: [.turbo/cache]

stages: [check, build, test]

lint:
  stage: check
  script: pnpm turbo run lint --filter="...[origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME]"
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

build:
  stage: build
  script: pnpm turbo run build --filter="...[origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME]"
  artifacts:
    paths: [apps/*/dist, packages/*/dist]
    expire_in: 1 day
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

test:
  stage: test
  needs: [build]
  script: pnpm turbo run test --filter="...[origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME]"
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

The key pieces for monorepos:

  • GIT_DEPTH: 0 fetches full history. GitLab's default shallow clone (depth 20 or 50, depending on configuration) often lacks the merge base, which silently breaks changed-package filtering.
  • Two caches: the pnpm store keyed on the lockfile, and Turborepo's local cache keyed by branch with a fallback to the default branch, so a new merge request starts from main's results.
  • needs lets test start as soon as build finishes rather than waiting for the whole stage.
  • Filters against the merge request target branch, using GitLab's predefined variable.

The general approach to affected runs is covered in Fixing Slow Monorepo CI with Affected Builds.

Merge request pipelines and the right base

GitLab can run branch pipelines, merge request pipelines and merged-results pipelines. For affected detection, merge request pipelines are the right trigger, because CI_MERGE_REQUEST_TARGET_BRANCH_NAME and CI_MERGE_REQUEST_DIFF_BASE_SHA identify the base. Using the diff base SHA avoids attributing changes that landed on the target branch after you branched:

  script:
    - git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
    - pnpm turbo run test --filter="...[$CI_MERGE_REQUEST_DIFF_BASE_SHA]"

Avoid running the same jobs in both branch and merge request pipelines — duplicate pipelines double runner usage. A workflow:rules block at the top of the file picks one:

workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - if: $CI_COMMIT_TAG
Choosing the base for affected detection on GitLab Merge request pipelines use the MR diff base SHA; default-branch pipelines use the previous successful commit; tag pipelines run everything. Which pipeline is running? CI_PIPELINE_SOURCE and branch MR diff base SHA CI_MERGE_REQUEST_DIFF_BA SE_SHA merge request Last green commit stored SHA or CI_COMMIT_BEFORE_SHA default branch Run everything no filter tag / release
Pick the base per pipeline type; the wrong base either skips affected packages or runs everything.

Default-branch pipelines

Merge request pipelines have a natural base; pipelines on main do not. CI_COMMIT_BEFORE_SHA gives the previous tip of the branch for a push, which works for single-commit pushes but misses changes when several commits land at once, and is all zeros for the first push of a branch. A more reliable base is the last commit on main whose pipeline succeeded. GitLab's API can return it (the pipelines endpoint filtered by ref=main&status=success), or the pipeline can move a lightweight tag such as ci/last-green forward at the end of every successful run. Filtering against that commit means a failed pipeline's changes are retested on the next run instead of being skipped.

Some teams simply run everything on main and use affected filtering only for merge requests. With a warm Turborepo cache that is often fast enough, and it removes one source of subtle skips. Decide based on how long a full run takes with the cache.

Artifacts versus caches

GitLab distinguishes caches (best-effort, shared between pipelines, may be missing) from artifacts (guaranteed, passed between jobs in one pipeline). Use each for what it is good at. Build outputs that later jobs in the same pipeline need — dist/ folders for tests, bundles for deploys — belong in artifacts, declared on the producing job and consumed through needs. The pnpm store and Turborepo's cache directory belong in caches, because a missing cache only makes a job slower, never wrong. Putting build outputs only in the cache is a common bug: when the cache misses on a different runner, the test job runs against missing files and fails in confusing ways.

Keep artifacts small with explicit paths and short expire_in values, and avoid uploading node_modules as an artifact — reinstalling from the cached store is faster and avoids platform mismatches between runners.

Path-based job rules

rules:changes runs a job only when certain paths change, which suits jobs that belong to one application — deploying it, building its image, running its end-to-end tests:

deploy-web:
  stage: deploy
  script: ./scripts/deploy-web.sh
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      changes:
        paths: [apps/web/**/*, packages/ui/**/*, packages/utils/**/*, pnpm-lock.yaml]
        compare_to: refs/heads/main

The limitation is visible in the path list: you must enumerate the application's internal dependencies by hand, and the list drifts as the graph changes. Use rules:changes for coarse routing and the task runner's graph-aware filter for anything that must follow dependencies. If you do rely on path lists, generate them from the workspace graph in a script rather than maintaining them by hand, and fail the pipeline when the generated list differs from the committed one.

Dynamic child pipelines per package

For large repositories, a parent job can compute the affected packages and generate a child pipeline with one job per package, so GitLab shows each package's result separately and runs them in parallel:

generate-pipeline:
  stage: check
  script:
    - node scripts/generate-child-pipeline.mjs "$CI_MERGE_REQUEST_DIFF_BASE_SHA" > child.yml
  artifacts:
    paths: [child.yml]

run-packages:
  stage: build
  trigger:
    include:
      - artifact: child.yml
        job: generate-pipeline
    strategy: depend

The generator lists affected packages (for example, from turbo run build --filter=...[$BASE] --dry=json) and writes a job per package that runs its tasks with --filter=<package>. strategy: depend makes the parent pipeline's status follow the child's.

Parent and child pipelines for a monorepo The parent computes affected packages, generates a child pipeline YAML with one job per package, and triggers it; child jobs run in parallel with shared caches. compute affected turbo --dry=json against MR base generate child.yml one job per package trigger child strategy: depend parallel jobs per-package results in the MR
Dynamic child pipelines turn the affected set into separate, parallel jobs with individual results.

Remote caching and runners

GitLab's cache is per runner or per shared cache storage, depending on configuration. Self-managed runners with a shared S3 or GCS cache backend share caches across runners; without one, each runner has its own and hit rates drop. For Turborepo, a remote cache avoids the question entirely, and the credentials go into masked, protected CI/CD variables:

variables:
  TURBO_API: https://turbo-cache.internal.example.com
  TURBO_TEAM: acme
  # TURBO_TOKEN is set as a masked, protected variable in project settings

Protected variables are only exposed to pipelines on protected branches, which gives you the right trust boundary for free: merge request pipelines from untrusted branches can be configured with a read-only token, and only main writes to the cache. Cache security is discussed further in Remote Caching Setup.

GitLab CI features for monorepos Compares rules:changes, needs, parallel:matrix and dynamic child pipelines on what they are good for and their limitations. good for limitation rules:changes routing app-specific jobs paths must list dependencies needs start jobs as soon as inputs are ready artifacts must be declared parallel:matrix fan out across versions or shards static list of values child pipelines per-package jobs from the graph extra generator script
Combine path rules for coarse routing with graph-aware filters and needs for speed.

Worked example: a 30-minute pipeline down to 8

A team on self-managed GitLab runners had one job running pnpm -r run build test for every merge request. They set GIT_DEPTH: 0, added the pnpm store and Turborepo caches with a default-branch fallback, switched commands to turbo run with a filter against CI_MERGE_REQUEST_DIFF_BASE_SHA, and split lint, build and test into jobs connected with needs. Median merge request pipelines dropped from 30 to 8 minutes. A later move to a shared S3 cache backend for runners raised Turborepo cache hits from roughly 40% to 75%, because jobs were no longer tied to whichever runner happened to hold a warm cache.

Prevention and guardrails

  • Always set GIT_DEPTH: 0 (or fetch the merge base explicitly) for affected detection.
  • Use workflow:rules to avoid duplicate branch and merge request pipelines.
  • Key caches by lockfile and branch with default-branch fallbacks.
  • Keep cache write tokens in protected variables.

Frequently Asked Questions

Is rules:changes enough for affected detection? For routing a few application-specific jobs, yes. For running tests of every package affected by a change, no — it does not know the dependency graph. Use the task runner's filters for that.

Why do merge request pipelines select no packages? Usually a shallow clone without the merge base, or filtering against a branch name that has not been fetched. Set GIT_DEPTH: 0 and fetch the target branch before computing the filter.

Can GitLab's cache replace a Turborepo remote cache? Partially. With a shared cache backend it works like the GitHub Actions approach — archive-based and per job. A remote cache shares results per task and with developers.

Should I use Nx or Turborepo on GitLab? Either works; both read the same git history and support remote caches. Nx's nx affected accepts --base and --head, which map directly to CI_MERGE_REQUEST_DIFF_BASE_SHA and CI_COMMIT_SHA.

Related

CI/CD Pipeline Optimization for Monorepos