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: 0fetches 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. needsletsteststart as soon asbuildfinishes 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
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.
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.
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:rulesto 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 covers CI speed techniques across providers.
- Caching Turborepo Outputs in GitHub Actions Without a Remote Cache describes the equivalent GitHub setup.
- Filtering Packages Changed Since a Git Ref explains changed-package selection with pnpm.
- Splitting Monorepo Tests into Parallel CI Shards pairs well with
parallel:matrix.