Skip to main content
CI/CD·8 min read

Monorepo CI/CD — Build Only What Changed (Nx, Turborepo, Bazel)

Optimize monorepo CI/CD pipelines with incremental builds. Compare Nx, Turborepo, and Bazel for change detection, affected-based testing, and cutting 45-minute builds to 5 minutes.

DT

DevOps Engineer & Technical Writer

TL;DR — Quick Fix

# With Nx — only build/test affected projects

npx nx affected --target=build --base=origin/main

npx nx affected --target=test --base=origin/main

# With Turborepo — filter to changed packages

npx turbo run build --filter='...[origin/main]'

npx turbo run test --filter='...[origin/main]'

These commands detect which packages changed since main and only build/test those (plus their dependents).

---

The Problem: Everything Builds on Every Commit

MONOREPO BUILD PROBLEM — BEFORE vs AFTER BEFORE: Build everything 1 file changed in service-auth 30 services built, 45 min CI time $$ in runner costs AFTER: Build only affected 1 file changed in service-auth 2 services built, 5 min CI time 90% cost reduction THE DEPENDENCY GRAPH INSIGHT: If service-auth changes, only rebuild service-auth + services that depend on it If shared-utils changes, rebuild everything that imports shared-utils

In a monorepo with 30 microservices, a change to one service shouldn't trigger builds for all 30. Without change detection, that's exactly what happens.

How Incremental Builds Work

Your codebase is a dependency graph. When code changes, you only need to rebuild the changed packages plus anything downstream that depends on them.

shared-utils (changed)

|

+-- service-auth (rebuild - depends on shared-utils)

| |

| +-- service-gateway (rebuild - depends on service-auth)

|

+-- service-billing (rebuild - depends on shared-utils)

|

+-- service-notifications (skip - no dependency)

+-- service-analytics (skip - no dependency)

Option 1: Nx (Best for JavaScript/TypeScript)

Setup

npx nx@latest init

Project Configuration

{

"targetDefaults": {

"build": {

"dependsOn": ["^build"],

"inputs": ["production", "^production"],

"cache": true

},

"test": {

"dependsOn": ["build"],

"inputs": ["default", "^production"],

"cache": true

},

"lint": {

"inputs": ["default", "{workspaceRoot}/.eslintrc.json"],

"cache": true

}

},

"namedInputs": {

"default": ["{projectRoot}/*/"],

"production": [

"default",

"!{projectRoot}/*/.spec.ts",

"!{projectRoot}/jest.config.ts"

]

},

"defaultBase": "origin/main"

}

GitHub Actions with Nx Affected

name: CI

on:

pull_request:

branches: [main]

jobs:

affected:

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

with:

fetch-depth: 0

- uses: actions/setup-node@v4

with:

node-version: 20

cache: 'npm'

- run: npm ci

- name: Lint affected

run: npx nx affected --target=lint --base=origin/main

- name: Test affected

run: npx nx affected --target=test --base=origin/main

- name: Build affected

run: npx nx affected --target=build --base=origin/main

- name: Get affected services

id: affected

run: |

AFFECTED=$(npx nx show projects --affected --base=origin/main --type=app | tr '\n' ',')

echo "services=$AFFECTED" >> $GITHUB_OUTPUT

- name: Deploy affected services

if: github.ref == 'refs/heads/main'

run: |

IFS=',' read -ra SERVICES <<< "${{ steps.affected.outputs.services }}"

for service in "${SERVICES[@]}"; do

npx nx run $service:deploy

done

Nx Remote Cache (Shared Build Cache)

{

"nxCloudAccessToken": "your-token",

"tasksRunnerOptions": {

"default": {

"runner": "nx-cloud",

"options": {

"cacheableOperations": ["build", "test", "lint"]

}

}

}

}

With remote cache, if a teammate already built the same code, your CI skips the build entirely and uses the cached artifact.

Option 2: Turborepo (Simpler, Vercel Ecosystem)

Setup

{

"$schema": "https://turbo.build/schema.json",

"globalDependencies": ["*/.env.local"],

"pipeline": {

"build": {

"dependsOn": ["^build"],

"outputs": ["dist/", ".next/", "build/**"],

"inputs": ["src/**", "package.json", "tsconfig.json"]

},

"test": {

"dependsOn": ["build"],

"inputs": ["src/", "test/", ".config."]

},

"lint": {

"inputs": ["src/*", ".eslintrc."]

},

"deploy": {

"dependsOn": ["build", "test"],

"cache": false

}

}

}

GitHub Actions with Turborepo

name: CI

on:

pull_request:

branches: [main]

jobs:

build:

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

with:

fetch-depth: 0

- uses: actions/setup-node@v4

with:

node-version: 20

cache: 'npm'

- run: npm ci

- name: Build changed

run: npx turbo run build --filter='...[origin/main]'

- name: Test changed

run: npx turbo run test --filter='...[origin/main]'

- name: Lint changed

run: npx turbo run lint --filter='...[origin/main]'

Turborepo Filter Syntax

# Everything that changed since main

turbo run build --filter='...[origin/main]'

# Only a specific package and its dependencies

turbo run build --filter='@myorg/web-app...'

# Only packages in a specific directory

turbo run build --filter='./packages/*'

# Exclude specific packages

turbo run build --filter='!@myorg/docs'

Option 3: Bazel (Best for Large Polyglot Monorepos)

When to Use Bazel

  • 100+ packages or mixed languages (Go, Java, Python, TypeScript)
  • Need hermetic builds (byte-for-byte reproducible)
  • Build correctness matters more than setup simplicity

Basic BUILD File

# services/auth/BUILD

load("@rules_go//go:def.bzl", "go_binary", "go_test")

go_binary(

name = "auth-service",

srcs = glob(["*.go"]),

deps = [

"//libs/shared-utils",

"//libs/database",

"@com_github_gin_gonic_gin//:gin",

],

visibility = ["//visibility:public"],

)

go_test(

name = "auth-service_test",

srcs = glob(["*_test.go"]),

embed = [":auth-service"],

)

CI with Bazel Query

- name: Test affected

run: |

AFFECTED=$(bazel query "rdeps(//..., set($(git diff --name-only origin/main)))" --output=label 2>/dev/null | grep "_test$")

if [ -n "$AFFECTED" ]; then

bazel test $AFFECTED

fi

Comparison: Nx vs Turborepo vs Bazel

FeatureNxTurborepoBazel
LanguagesJS/TS (primary)JS/TS onlyAny language
Setup complexityMediumLowHigh
Change detectionBuilt-inBuilt-inQuery-based
Remote cacheNx CloudVercelCustom / BuildBuddy
Distributed executionYes (Nx Agents)NoYes (Remote Execution)
Best forMedium JS/TS monoreposSimple npm workspacesLarge polyglot repos
Learning curveModerateLowSteep

Advanced: Custom Change Detection (No Framework)

If you don't want Nx/Turborepo, implement basic change detection with git:

name: Smart CI

on:

pull_request:

branches: [main]

jobs:

detect-changes:

runs-on: ubuntu-latest

outputs:

services: ${{ steps.changes.outputs.services }}

steps:

- uses: actions/checkout@v4

with:

fetch-depth: 0

- id: changes

run: |

CHANGED_FILES=$(git diff --name-only origin/main...HEAD)

SERVICES=""

if echo "$CHANGED_FILES" | grep -q "^services/auth/"; then

SERVICES="${SERVICES}auth,"

fi

if echo "$CHANGED_FILES" | grep -q "^services/billing/"; then

SERVICES="${SERVICES}billing,"

fi

# Shared libs change = rebuild everything

if echo "$CHANGED_FILES" | grep -q "^libs/shared/"; then

SERVICES="auth,billing,gateway,notifications,"

fi

echo "services=${SERVICES%,}" >> $GITHUB_OUTPUT

build:

needs: detect-changes

if: needs.detect-changes.outputs.services != ''

runs-on: ubuntu-latest

strategy:

matrix:

service: ${{ fromJSON(format('["{0}"]', needs.detect-changes.outputs.services)) }}

steps:

- uses: actions/checkout@v4

- name: Build ${{ matrix.service }}

run: |

cd services/${{ matrix.service }}

docker build -t ${{ matrix.service }}:${{ github.sha }} .

Caching Strategies

GitHub Actions Cache

- name: Cache Nx

uses: actions/cache@v4

with:

path: node_modules/.cache/nx

key: nx-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-${{ github.sha }}

restore-keys: |

nx-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-

nx-${{ runner.os }}-

Docker Layer Caching for Affected Services

- name: Build Docker images (affected only)

run: |

for service in $(npx nx show projects --affected --type=app); do

docker build \

--cache-from=type=gha \

--cache-to=type=gha,mode=max \

-t $service:${{ github.sha }} \

services/$service/

done

---

Frequently Asked Questions

How does change detection work in a monorepo?

Monorepo tools compare the current branch against a base (usually main) to find which files changed. They use the dependency graph to determine which packages are "affected" — meaning they either changed directly or depend on something that changed. Only affected packages get built and tested.

Should I use Nx or Turborepo?

Use Turborepo if you want minimal setup and your monorepo is straightforward npm workspaces with build/test/lint tasks. Use Nx if you need code generators, project graph visualization, distributed task execution, or framework-specific plugins. Nx has more features; Turborepo is simpler to adopt.

How much faster will my CI get?

Typical improvements: 60-90% reduction in CI time for PRs that touch one service. A 45-minute full build becomes 3-8 minutes for most PRs. The exception is changes to shared libraries, which still trigger builds for all dependent packages.

What about shared libraries that everything depends on?

Changes to widely-used shared libraries will still trigger builds for many packages. Strategies: keep shared libraries stable and rarely changed, split large shared libs into focused smaller packages, and use interface boundaries to limit downstream impact.

Can I use this with Docker-based microservices?

Yes. After determining affected services, build only those Docker images. Use Docker layer caching (BuildKit cache mounts or GitHub Actions cache) to speed up individual image builds. Deploy only the affected containers to your orchestrator.

---