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
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
| Feature | Nx | Turborepo | Bazel |
|---|---|---|---|
| Languages | JS/TS (primary) | JS/TS only | Any language |
| Setup complexity | Medium | Low | High |
| Change detection | Built-in | Built-in | Query-based |
| Remote cache | Nx Cloud | Vercel | Custom / BuildBuddy |
| Distributed execution | Yes (Nx Agents) | No | Yes (Remote Execution) |
| Best for | Medium JS/TS monorepos | Simple npm workspaces | Large polyglot repos |
| Learning curve | Moderate | Low | Steep |
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.
---
Related Resources
- GitHub Actions CI/CD Complete Guide — Workflow configuration fundamentals
- GitHub Actions Cache Optimization — Speed up workflows with caching
- Docker Multi-Stage Build Guide — Optimized container image builds