Skip to content
Codeloom
CI/CD

CI/CD for Monorepos: Turborepo and Nx Pipelines

Build efficient CI/CD pipelines for monorepos using Turborepo and Nx with smart caching, affected-only builds, and parallel execution.

·7 min read · By Codeloom
Intermediate 13 min read

What you'll learn

  • Structure CI/CD pipelines for monorepo projects
  • Configure Turborepo remote caching in CI
  • Set up Nx affected commands to only build what changed
  • Optimize pipeline speed with parallelism and caching

Prerequisites

  • Basic monorepo concepts
  • GitHub Actions experience
  • Node.js and npm/pnpm knowledge

Monorepos hold multiple packages and applications in a single repository. This structure simplifies dependency management and code sharing, but it creates a CI/CD challenge: you do not want to rebuild and redeploy every package when only one file changed. Without smart tooling, monorepo pipelines become painfully slow.

Turborepo and Nx solve this problem with dependency-aware task execution, intelligent caching, and affected-only filtering. This guide shows you how to set up efficient CI/CD pipelines using both tools.

Why Monorepo CI/CD Is Different

In a polyrepo setup, each repository has its own pipeline. A change triggers exactly one pipeline. In a monorepo, a single commit might touch code across multiple packages, and those packages depend on each other. You need a pipeline that understands the dependency graph so it can build packages in the right order, skip packages that have not changed, and cache results to avoid redundant work.

Project Structure

Both examples in this guide use a similar monorepo structure:

my-monorepo/
  apps/
    web/           # Next.js frontend
    api/           # Express API
    admin/         # Admin dashboard
  packages/
    ui/            # Shared React components
    config/        # Shared configuration
    utils/         # Shared utilities
    tsconfig/      # Shared TypeScript configs
  package.json
  turbo.json       # or nx.json

Turborepo Pipeline Setup

Turborepo uses a turbo.json configuration file to define task dependencies and caching behavior.

Configuring turbo.json

{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["**/.env.*local"],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
    },
    "lint": {
      "dependsOn": ["^build"]
    },
    "test": {
      "dependsOn": ["^build"],
      "outputs": ["coverage/**"]
    },
    "typecheck": {
      "dependsOn": ["^build"]
    },
    "deploy": {
      "dependsOn": ["build", "test", "lint"],
      "cache": false
    }
  }
}

The ^build syntax means “run build in all dependencies first.” This ensures packages are built in topological order.

GitHub Actions with Turborepo

name: CI
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2  # Need history for change detection

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Build
        run: pnpm turbo build

      - name: Lint
        run: pnpm turbo lint

      - name: Test
        run: pnpm turbo test

      - name: Typecheck
        run: pnpm turbo typecheck

Turborepo Remote Caching

Remote caching is Turborepo’s killer feature for CI. When one developer or CI run builds a package, the result is cached remotely. The next pipeline run that needs the same build skips it entirely.

# Enable Vercel Remote Cache (easiest option)
npx turbo login
npx turbo link

# Or use a self-hosted remote cache
# Set these in your CI environment
export TURBO_API="https://turbo-cache.example.com"
export TURBO_TOKEN="your-cache-token"
export TURBO_TEAM="your-team"

Filtered Builds with Turborepo

When you only want to build and deploy a specific app:

jobs:
  deploy-web:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Check for changes
        id: changes
        run: |
          CHANGED=$(pnpm turbo build --filter='web...[HEAD^]' --dry-run=json | jq '.packages | length')
          if [ "$CHANGED" -gt "0" ]; then
            echo "has_changes=true" >> "$GITHUB_OUTPUT"
          else
            echo "has_changes=false" >> "$GITHUB_OUTPUT"
          fi

      - name: Build web app
        if: steps.changes.outputs.has_changes == 'true'
        run: pnpm turbo build --filter=web...

      - name: Deploy web app
        if: steps.changes.outputs.has_changes == 'true'
        run: pnpm turbo deploy --filter=web

Nx Pipeline Setup

Nx takes a different approach with its project graph analysis and affected commands. It can determine exactly which projects are affected by a code change.

Configuring nx.json

{
  "$schema": "https://nx.dev/reference/nx-json",
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "sharedGlobals": [],
    "production": [
      "default",
      "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)",
      "!{projectRoot}/tsconfig.spec.json",
      "!{projectRoot}/jest.config.[jt]s"
    ]
  },
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["production", "^production"],
      "cache": true
    },
    "lint": {
      "inputs": ["default", "{workspaceRoot}/.eslintrc.json"],
      "cache": true
    },
    "test": {
      "inputs": ["default", "^production"],
      "cache": true
    }
  },
  "defaultBase": "main"
}

Project Configuration

Each project in an Nx monorepo has its own project.json:

{
  "name": "web",
  "sourceRoot": "apps/web/src",
  "projectType": "application",
  "targets": {
    "build": {
      "executor": "@nx/next:build",
      "outputs": ["{options.outputPath}"],
      "options": {
        "outputPath": "dist/apps/web"
      }
    },
    "serve": {
      "executor": "@nx/next:server",
      "options": {
        "buildTarget": "web:build"
      }
    },
    "lint": {
      "executor": "@nx/eslint:lint",
      "options": {
        "lintFilePatterns": ["apps/web/**/*.{ts,tsx}"]
      }
    },
    "test": {
      "executor": "@nx/jest:jest",
      "options": {
        "jestConfig": "apps/web/jest.config.ts"
      }
    }
  }
}

GitHub Actions with Nx Affected

The nx affected command is the key to efficient Nx pipelines. It compares the current code against a base branch and only runs tasks for projects that have been affected by the changes:

name: CI
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Full history for affected detection

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - uses: nrwl/nx-set-shas@v4

      - name: Lint affected projects
        run: npx nx affected -t lint --parallel=3

      - name: Test affected projects
        run: npx nx affected -t test --parallel=3 --ci --code-coverage

      - name: Build affected projects
        run: npx nx affected -t build --parallel=3

Nx Cloud for Distributed Caching

Nx Cloud provides remote caching and distributed task execution:

# Connect your workspace to Nx Cloud
npx nx connect
# Add to your CI workflow
- name: Build with Nx Cloud
  run: npx nx affected -t build --parallel=3
  env:
    NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}

Distributed Task Execution with Nx

For large monorepos, Nx can distribute tasks across multiple CI agents:

name: CI
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  main:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      - uses: nrwl/nx-set-shas@v4

      - name: Start Nx Agents
        run: npx nx-cloud start-ci-run --distribute-on="3 linux-medium-js"

      - name: Run all tasks
        run: |
          npx nx affected -t lint test build --parallel=3

      - name: Stop Nx Agents
        if: always()
        run: npx nx-cloud stop-all-agents

Deploying Individual Apps

In a monorepo, you often need to deploy individual applications independently. Here is a pattern that detects which apps changed and deploys only those:

name: Deploy
on:
  push:
    branches: [main]

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      web: ${{ steps.filter.outputs.web }}
      api: ${{ steps.filter.outputs.api }}
      admin: ${{ steps.filter.outputs.admin }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            web:
              - 'apps/web/**'
              - 'packages/ui/**'
              - 'packages/utils/**'
            api:
              - 'apps/api/**'
              - 'packages/utils/**'
            admin:
              - 'apps/admin/**'
              - 'packages/ui/**'
              - 'packages/utils/**'

  deploy-web:
    needs: detect-changes
    if: needs.detect-changes.outputs.web == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "Deploying web app..."

  deploy-api:
    needs: detect-changes
    if: needs.detect-changes.outputs.api == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "Deploying API..."

  deploy-admin:
    needs: detect-changes
    if: needs.detect-changes.outputs.admin == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "Deploying admin dashboard..."

Turborepo vs Nx: Which to Choose

Both tools solve the same fundamental problem but differ in philosophy:

Turborepo is focused on being a fast, simple build system. It has fewer concepts to learn, integrates easily with existing projects, and its remote caching through Vercel is straightforward. Choose Turborepo when you want minimal configuration and your monorepo is primarily JavaScript/TypeScript.

Nx is a full-featured monorepo toolkit with code generation, project graph visualization, and distributed task execution. It supports multiple languages and has deeper IDE integration. Choose Nx when you need advanced features like code generators, have a large team, or work with multiple programming languages.

Wrapping Up

Efficient monorepo CI/CD comes down to three capabilities: understanding the dependency graph so tasks run in the right order, caching build results so work is never repeated, and detecting affected projects so unchanged code is skipped entirely. Both Turborepo and Nx provide these capabilities with different tradeoffs. Start with whichever tool matches your team’s complexity needs, enable remote caching from day one, and structure your pipeline to deploy apps independently based on what actually changed.