MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Monorepo CI/CD: Caching, Affected Builds, and Parallel Execution

Optimize monorepo CI/CD: dependency-aware builds, caching strategies, and parallel execution.

MonorepoCI/CDTurborepoDevOps

By MinhVo

Introduction

Monorepos have become the standard architecture for modern software development, enabling code sharing, atomic commits, and consistent tooling across multiple projects. However, as monorepos grow, CI/CD pipelines become increasingly expensive and slow. Running full builds and tests for every commit across all packages is unsustainable when you have dozens or hundreds of packages. The solution lies in intelligent CI/CD strategies that understand your monorepo's dependency graph, cache aggressively, and execute only what's necessary.

In this comprehensive guide, we'll explore advanced CI/CD techniques for monorepos using tools like Turborepo, Nx, and GitHub Actions. You'll learn how to implement dependency-aware builds, intelligent caching, affected package detection, and parallel execution to dramatically reduce build times while maintaining reliability. These patterns are essential for any team scaling a monorepo beyond a handful of packages.

Monorepo CI/CD Pipeline

Understanding Monorepo CI/CD: Core Concepts

The Monorepo CI/CD Challenge

Traditional CI/CD pipelines treat each repository independently. In a monorepo, you need a fundamentally different approach:

ChallengeTraditional RepoMonorepo
Build ScopeEntire projectOnly affected packages
CachingPer-projectCross-project with dependency awareness
ParallelizationLimitedExtensive across packages
Dependency TrackingSimpleComplex graph traversal
Artifact SharingRareCommon across packages

Dependency Graph

The foundation of monorepo CI/CD is understanding your dependency graph. Tools like Turborepo and Nx analyze package.json and import statements to build a complete dependency map:

// apps/web/package.json
{
  "dependencies": {
    "@myorg/ui": "workspace:*",
    "@myorg/utils": "workspace:*",
    "@myorg/api-client": "workspace:*"
  }
}
 
// packages/ui/package.json
{
  "dependencies": {
    "@myorg/utils": "workspace:*"
  }
}

This creates a dependency graph:

web → ui → utils
web → api-client → utils
web → utils

When utils changes, web, ui, and api-client all need to be rebuilt. When ui changes, only web and ui need rebuilding.

Dependency Graph Visualization

Architecture and Design Patterns

Pattern 1: Turborepo Configuration

Turborepo provides a turbo.json configuration that defines your build pipeline:

{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": [".env"],
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"],
      "env": ["NODE_ENV"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"],
      "inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts", "test/**/*.tsx"]
    },
    "lint": {
      "outputs": []
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "type-check": {
      "dependsOn": ["^build"],
      "outputs": []
    }
  }
}

Pattern 2: Nx Configuration

Nx uses a different approach with nx.json and project.json:

// nx.json
{
  "tasksRunnerOptions": {
    "default": {
      "runner": "nx-cloud",
      "options": {
        "cacheableOperations": ["build", "test", "lint", "typecheck"],
        "accessToken": "your-token"
      }
    }
  },
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["production", "^production"],
      "outputs": ["{projectRoot}/dist"]
    },
    "test": {
      "inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"]
    }
  }
}
 
// apps/web/project.json
{
  "targets": {
    "build": {
      "executor": "@nx/webpack:webpack",
      "outputs": ["{options.outputPath}"],
      "options": {
        "outputPath": "dist/apps/web",
        "main": "apps/web/src/main.ts",
        "tsConfig": "apps/web/tsconfig.app.json"
      }
    },
    "test": {
      "executor": "@nx/jest:jest",
      "outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
      "options": {
        "jestConfig": "apps/web/jest.config.ts"
      }
    }
  }
}

Pattern 3: GitHub Actions for Monorepos

Configure GitHub Actions to detect changes and run only affected tasks:

# .github/workflows/ci.yml
name: Monorepo CI
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      web: ${{ steps.changes.outputs.web }}
      api: ${{ steps.changes.outputs.api }}
      ui: ${{ steps.changes.outputs.ui }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v2
        id: changes
        with:
          filters: |
            web:
              - 'apps/web/**'
              - 'packages/ui/**'
              - 'packages/utils/**'
            api:
              - 'apps/api/**'
              - 'packages/utils/**'
            ui:
              - 'packages/ui/**'
 
  build-web:
    needs: detect-changes
    if: needs.detect-changes.outputs.web == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm turbo build --filter=web
      - run: pnpm turbo test --filter=web

CI/CD Pipeline Architecture

Step-by-Step Implementation

Setting Up Turborepo with Remote Caching

Remote caching allows your entire team and CI system to share build artifacts:

# Install Turborepo
pnpm add turbo -Dw
 
# Login to Vercel for remote caching
npx turbo login
npx turbo link
 
# turbo.json with remote caching
{
  "$schema": "https://turbo.build/schema.json",
  "remoteCache": {
    "team": "your-team-name"
  }
}

Implementing Affected Package Detection

Only build and test packages that have changed or depend on changed packages:

# Turborepo: Run only affected packages
pnpm turbo build --filter='...[origin/main]'
 
# Nx: Run only affected packages
npx nx affected --target=test --base=origin/main --head=HEAD
 
# Custom script for detecting changes
#!/bin/bash
# scripts/get-affected.sh
CHANGED_FILES=$(git diff --name-only origin/main...HEAD)
AFFECTED_PACKAGES=""
 
for file in $CHANGED_FILES; do
  # Extract package name from file path
  if [[ $file =~ ^packages/([^/]+)/ ]]; then
    pkg="${BASH_REMATCH[1]}"
    if [[ ! $AFFECTED_PACKAGES =~ $pkg ]]; then
      AFFECTED_PACKAGES="$AFFECTED_PACKAGES @$pkg"
    fi
  elif [[ $file =~ ^apps/([^/]+)/ ]]; then
    app="${BASH_REMATCH[1]}"
    if [[ ! $AFFECTED_PACKAGES =~ $app ]]; then
      AFFECTED_PACKAGES="$AFFECTED_PACKAGES @$app"
    fi
  fi
done
 
echo $AFFECTED_PACKAGES

Parallel Execution with Turborepo

Configure Turborepo to run tasks in parallel with concurrency limits:

# Run with 10 parallel tasks
pnpm turbo build --concurrency=10
 
# Run with 50% of available CPUs
pnpm turbo build --concurrency=50%
 
# Run without parallelism (for debugging)
pnpm turbo build --concurrency=1
 
# turbo.json with parallel configuration
{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"],
      "parallel": true
    },
    "test": {
      "dependsOn": ["build"],
      "parallel": true
    }
  }
}

GitHub Actions with Matrix Strategy

Run multiple packages in parallel using GitHub Actions matrix:

# .github/workflows/parallel-build.yml
name: Parallel Build
on:
  pull_request:
    branches: [main]
 
jobs:
  get-packages:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - id: set-matrix
        run: |
          PACKAGES=$(pnpm turbo run build --filter='...[origin/main]' --dry-run=json | jq -r '.tasks[].package' | jq -R -s -c 'split("\n") | map(select(. != ""))')
          echo "matrix={\"package\":$PACKAGES}" >> $GITHUB_OUTPUT
 
  build:
    needs: get-packages
    runs-on: ubuntu-latest
    strategy:
      matrix: ${{ fromJson(needs.get-packages.outputs.matrix) }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm turbo build --filter=${{ matrix.package }}
      - run: pnpm turbo test --filter=${{ matrix.package }}

Real-World Use Cases

Use Case 1: Large-Scale SaaS Platform

A SaaS platform with 50+ packages, multiple applications, and shared libraries:

# .github/workflows/saas-ci.yml
name: SaaS Platform CI
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
 
jobs:
  affected:
    runs-on: ubuntu-latest
    outputs:
      web: ${{ steps.affected.outputs.web }}
      api: ${{ steps.affected.outputs.api }}
      mobile: ${{ steps.affected.outputs.mobile }}
      shared: ${{ steps.affected.outputs.shared }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: pnpm/action-setup@v2
        with:
          version: 8
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - id: affected
        run: |
          echo "web=$(pnpm turbo run build --filter='...[origin/main]' --filter='./apps/web' --dry-run | grep -c 'web')" >> $GITHUB_OUTPUT
          echo "api=$(pnpm turbo run build --filter='...[origin/main]' --filter='./apps/api' --dry-run | grep -c 'api')" >> $GITHUB_OUTPUT
 
  deploy-web:
    needs: affected
    if: needs.affected.outputs.web != '0'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm turbo build --filter=web
      - run: pnpm turbo test --filter=web
      - uses: amondnet/vercel-action@v20
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID_WEB }}
 
  deploy-api:
    needs: affected
    if: needs.affected.outputs.api != '0'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm turbo build --filter=api
      - run: pnpm turbo test --filter=api
      - uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
      - run: aws ecs update-service --cluster production --service api --force-new-deployment

Use Case 2: Micro-Frontend Architecture

Managing multiple frontend applications sharing common components:

// turbo.json
{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {
      "outputs": []
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"]
    },
    "e2e": {
      "dependsOn": ["build"],
      "outputs": []
    },
    "deploy": {
      "dependsOn": ["build", "test", "e2e"],
      "cache": false
    }
  }
}

Use Case 3: Multi-Language Monorepo

Supporting multiple languages with appropriate build tools:

# .github/workflows/multi-language.yml
name: Multi-Language Monorepo
on:
  pull_request:
 
jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      frontend: ${{ steps.changes.outputs.frontend }}
      backend: ${{ steps.changes.outputs.backend }}
      infrastructure: ${{ steps.changes.outputs.infrastructure }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v2
        id: changes
        with:
          filters: |
            frontend:
              - 'apps/frontend/**'
              - 'packages/ui/**'
            backend:
              - 'apps/backend/**'
              - 'packages/shared/**'
            infrastructure:
              - 'infrastructure/**'
 
  frontend:
    needs: detect-changes
    if: needs.detect-changes.outputs.frontend == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v2
      - run: pnpm install --frozen-lockfile
      - run: pnpm turbo build --filter=frontend
      - run: pnpm turbo test --filter=frontend
 
  backend:
    needs: detect-changes
    if: needs.detect-changes.outputs.backend == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v3
        with:
          go-version: '1.21'
      - run: cd apps/backend && go build ./...
      - run: cd apps/backend && go test ./...
 
  infrastructure:
    needs: detect-changes
    if: needs.detect-changes.outputs.infrastructure == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v2
      - run: cd infrastructure && terraform init
      - run: cd infrastructure && terraform plan

Best Practices for Production

  1. Cache everything possible: Configure remote caching for all build artifacts, test results, and lint outputs. This dramatically reduces CI time for unchanged packages.

  2. Use affected detection: Never run full builds on PRs. Use dependency-aware affected detection to only build and test what changed.

  3. Parallelize aggressively: Run independent packages in parallel. Use matrix strategies in CI to fan out work across multiple runners.

  4. Optimize cache keys: Include lock files, source code hashes, and configuration in cache keys. Exclude volatile files like node_modules from cache.

  5. Use workspace-aware tools: Tools like Turborepo and Nx understand workspace dependencies natively. Avoid custom scripts that reimplement this logic.

  6. Monitor CI performance: Track build times, cache hit rates, and parallelization efficiency. Use metrics to identify bottlenecks.

  7. Implement CI cost controls: Set budgets for CI minutes and alert when approaching limits. Use spot instances for non-critical builds.

  8. Document CI configuration: Maintain clear documentation of your CI pipeline, caching strategy, and affected detection logic.

Common Pitfalls and Solutions

PitfallImpactSolution
Running full builds on every PRSlow CI, wasted resourcesImplement affected detection
No remote cachingRedundant builds across teamSet up Turborepo or Nx Cloud
Incorrect dependency graphMissing builds for affected packagesVerify package.json dependencies
Too many parallel jobsResource exhaustion, timeoutsSet concurrency limits
Stale cacheIncorrect build artifactsInvalidate cache on config changes
Missing CI for shared packagesBreaking changes go undetectedAlways test affected dependents

Performance Optimization

Optimize your monorepo CI/CD pipeline for maximum efficiency:

# Turborepo: Run with verbose logging for debugging
pnpm turbo build --filter='...[origin/main]' --verbose
 
# Turborepo: Prune unused packages from build
pnpm turbo prune --scope=web
 
# Nx: Run with cloud distribution
npx nx affected --target=test --base=origin/main --head=HEAD --parallel=3
 
# Custom: Measure build time
time pnpm turbo build --filter='...[origin/main]'
 
# Custom: Check cache hit rate
pnpm turbo build --filter='...[origin/main]' --dry-run=json | jq '.tasks | map(select(.cache == "HIT")) | length'

GitHub Actions Cache Optimization

- uses: actions/cache@v3
  with:
    path: |
      node_modules
      .turbo
      packages/*/node_modules
    key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
    restore-keys: |
      ${{ runner.os }}-pnpm-

Comparison with Alternatives

FeatureTurborepoNxBazelPants
Language SupportJS/TSJS/TS (extensible)Multi-languageMulti-language
Remote CachingVercelNx CloudRemote CachePants Remote
Affected DetectionBuilt-inBuilt-inBuilt-inBuilt-in
ParallelizationBuilt-inBuilt-inBuilt-inBuilt-in
Learning CurveLowMediumHighHigh
CommunityLargeLargeLargeSmall
Configurationturbo.jsonnx.jsonBUILD filesBUILD files

Advanced Patterns and Techniques

Dynamic Pipeline Generation

Generate pipeline configuration based on workspace structure:

// scripts/generate-turbo-config.js
const fs = require('fs');
const path = require('path');
const glob = require('glob');
 
const packages = glob.sync('packages/*/package.json').map(pkg => {
  const content = JSON.parse(fs.readFileSync(pkg, 'utf-8'));
  return { name: content.name, path: path.dirname(pkg) };
});
 
const turboConfig = {
  $schema: 'https://turbo.build/schema.json',
  pipeline: {
    build: {
      dependsOn: ['^build'],
      outputs: ['dist/**', '.next/**']
    },
    test: {
      dependsOn: ['build'],
      outputs: ['coverage/**']
    }
  }
};
 
// Add package-specific overrides
packages.forEach(pkg => {
  if (pkg.name.includes('api')) {
    turboConfig.pipeline[`${pkg.name}:deploy`] = {
      dependsOn: ['build', 'test'],
      cache: false
    };
  }
});
 
fs.writeFileSync('turbo.json', JSON.stringify(turboConfig, null, 2));

CI/CD with Deployment Preview

# .github/workflows/preview.yml
name: Preview Deployment
on:
  pull_request:
    branches: [main]
 
jobs:
  preview:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm turbo build --filter='...[origin/main]'
      - run: pnpm turbo test --filter='...[origin/main]'
      - uses: amondnet/vercel-action@v20
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          working-directory: apps/web
          github-comment: true

Testing Strategies

// Test CI configuration locally
describe('Monorepo CI Configuration', () => {
  test('turbo.json is valid', () => {
    const turbo = JSON.parse(fs.readFileSync('turbo.json', 'utf-8'));
    expect(turbo.pipeline).toBeDefined();
    expect(turbo.pipeline.build).toBeDefined();
  });
 
  test('all packages have correct dependencies', () => {
    const packages = glob.sync('packages/*/package.json');
    packages.forEach(pkg => {
      const content = JSON.parse(fs.readFileSync(pkg, 'utf-8'));
      expect(content.name).toBeDefined();
      expect(content.version).toBeDefined();
    });
  });
 
  test('affected detection works correctly', async () => {
    const affected = await getAffectedPackages('origin/main', 'HEAD');
    expect(Array.isArray(affected)).toBe(true);
  });
});

Future Outlook

Monorepo CI/CD tooling continues to evolve rapidly. Turborepo is adding features like incremental builds and better caching. Nx is expanding beyond JavaScript with plugins for Go, Rust, and Java. The trend toward remote caching and distributed execution will continue, making monorepo CI/CD more efficient and cost-effective.

The integration of AI for optimizing build order and predicting affected packages is an emerging trend. Tools will become smarter about which packages to build and test, further reducing CI times.

Cache Invalidation Strategies

Effective cache invalidation is critical for monorepo CI/CD performance. Use content-based hashing to generate cache keys that change only when the source code or dependencies change. Scope caches to specific projects and their dependencies to avoid false cache hits from unrelated changes. Implement a distributed cache (like Turborepo's remote cache or Nx Cloud) to share build artifacts across CI runners and developer machines. Monitor cache hit rates and adjust granularity to balance cache freshness against build speed.

Monorepo Dependency Graph Analysis

Visualize and analyze your monorepo's dependency graph to optimize CI/CD pipelines. Tools like Nx, Turborepo, and Bazel provide dependency graph visualization that shows which projects depend on which others. Use this graph to determine the minimum set of projects that need rebuilding when a file changes. Identify circular dependencies that prevent effective caching and parallelization. Export the dependency graph as a CI artifact for debugging pipeline decisions. Use graph analysis to plan project boundaries and identify refactoring opportunities that would improve build parallelism.

Conclusion

Effective CI/CD for monorepos requires a combination of intelligent caching, affected detection, and parallel execution. By leveraging tools like Turborepo and Nx, you can build CI/CD pipelines that scale with your monorepo while keeping build times and costs manageable.

Key takeaways:

  1. Cache aggressively - Remote caching can reduce build times by 90%+
  2. Build only what changed - Affected detection prevents unnecessary work
  3. Parallelize everywhere - Run independent tasks concurrently
  4. Monitor and optimize - Track CI metrics and continuously improve
  5. Use purpose-built tools - Turborepo and Nx understand monorepo dependencies
  6. Plan for scale - Design your CI/CD to handle growing numbers of packages

Start with Turborepo or Nx, configure remote caching, and implement affected detection. As your monorepo grows, add parallel execution and optimize based on your specific bottlenecks.