DeployU
Interviews / DevOps & Cloud Infrastructure / Our monorepo builds everything on every change. Implement efficient path-based workflows.

Our monorepo builds everything on every change. Implement efficient path-based workflows.

practical Monorepo Workflows Interactive Quiz Code Examples

The Scenario

Your monorepo structure:

monorepo/
├── apps/
│   ├── web/           # React frontend
│   ├── api/           # Node.js backend
│   ├── admin/         # Admin dashboard
│   └── mobile/        # React Native app
├── packages/
│   ├── ui/            # Shared UI components
│   ├── utils/         # Shared utilities
│   └── config/        # Shared configurations
└── infrastructure/
    ├── terraform/
    └── k8s/

Current problem: Every commit triggers builds for ALL 7 packages, even when only one file changed. CI takes 45 minutes and wastes thousands of dollars monthly.

The Challenge

Implement efficient path-based workflows that only build affected packages while correctly handling dependencies between packages.

Wrong Approach

A junior engineer might just add path filters to each workflow without considering dependencies, create separate repos for each package, or skip CI for 'small changes'. These approaches miss changes that affect dependents, lose the benefits of monorepo, or risk breaking production.

Right Approach

A senior engineer implements smart change detection that understands package dependencies, uses matrix builds for affected packages, caches aggressively across the monorepo, and ensures shared package changes trigger dependent package builds.

Step 1: Detect Changed Packages

name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      web: ${{ steps.changes.outputs.web }}
      api: ${{ steps.changes.outputs.api }}
      admin: ${{ steps.changes.outputs.admin }}
      ui: ${{ steps.changes.outputs.ui }}
      utils: ${{ steps.changes.outputs.utils }}
      matrix: ${{ steps.matrix.outputs.packages }}

    steps:
      - uses: actions/checkout@v4

      - uses: dorny/paths-filter@v2
        id: changes
        with:
          filters: |
            web:
              - 'apps/web/**'
              - 'packages/ui/**'
              - 'packages/utils/**'
              - 'packages/config/**'
            api:
              - 'apps/api/**'
              - 'packages/utils/**'
              - 'packages/config/**'
            admin:
              - 'apps/admin/**'
              - 'packages/ui/**'
              - 'packages/utils/**'
            ui:
              - 'packages/ui/**'
            utils:
              - 'packages/utils/**'

      - name: Build matrix
        id: matrix
        run: |
          PACKAGES=()
          if [ "${{ steps.changes.outputs.web }}" == "true" ]; then
            PACKAGES+=("web")
          fi
          if [ "${{ steps.changes.outputs.api }}" == "true" ]; then
            PACKAGES+=("api")
          fi
          if [ "${{ steps.changes.outputs.admin }}" == "true" ]; then
            PACKAGES+=("admin")
          fi
          if [ "${{ steps.changes.outputs.ui }}" == "true" ]; then
            PACKAGES+=("ui")
          fi
          if [ "${{ steps.changes.outputs.utils }}" == "true" ]; then
            PACKAGES+=("utils")
          fi

          # Convert to JSON array
          JSON=$(printf '%s\n' "${PACKAGES[@]}" | jq -R . | jq -s -c .)
          echo "packages=$JSON" >> $GITHUB_OUTPUT

  build:
    needs: detect-changes
    if: needs.detect-changes.outputs.matrix != '[]'
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        package: ${{ fromJSON(needs.detect-changes.outputs.matrix) }}

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 18
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build package
        run: npm run build --workspace=${{ matrix.package }}

      - name: Test package
        run: npm run test --workspace=${{ matrix.package }}

Step 2: Using Turborepo for Smart Builds

name: CI with Turborepo

on:
  push:
    branches: [main]
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2  # Need for turbo diff

      - uses: actions/setup-node@v4
        with:
          node-version: 18
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      # Turborepo remote cache
      - name: Setup Turborepo cache
        uses: actions/cache@v4
        with:
          path: .turbo
          key: turbo-${{ runner.os }}-${{ github.sha }}
          restore-keys: |
            turbo-${{ runner.os }}-

      # Build only affected packages
      - name: Build
        run: npx turbo run build --filter='...[origin/main]'
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ vars.TURBO_TEAM }}

      - name: Test
        run: npx turbo run test --filter='...[origin/main]'
// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["**/.env.*local"],
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": []
    },
    "lint": {
      "outputs": []
    },
    "deploy": {
      "dependsOn": ["build", "test"],
      "outputs": []
    }
  }
}

Step 3: Using Nx for Affected Detection

name: CI with Nx

on:
  push:
    branches: [main]
  pull_request:

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

      - uses: actions/setup-node@v4
        with:
          node-version: 18
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      # Nx cloud for distributed caching
      - name: Setup Nx
        uses: nrwl/nx-set-shas@v4

      # Only run for affected projects
      - name: Build affected
        run: npx nx affected --target=build --base=$NX_BASE --head=$NX_HEAD

      - name: Test affected
        run: npx nx affected --target=test --base=$NX_BASE --head=$NX_HEAD

      - name: Lint affected
        run: npx nx affected --target=lint --base=$NX_BASE --head=$NX_HEAD

Step 4: Deploy Only Affected Apps

name: Deploy

on:
  push:
    branches: [main]

jobs:
  detect:
    runs-on: ubuntu-latest
    outputs:
      apps: ${{ steps.detect.outputs.apps }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2

      - name: Detect affected apps
        id: detect
        run: |
          # Get changed files
          CHANGED=$(git diff --name-only HEAD~1)

          APPS=()

          # Check each app
          if echo "$CHANGED" | grep -qE "^apps/web/|^packages/"; then
            APPS+=("web")
          fi
          if echo "$CHANGED" | grep -qE "^apps/api/|^packages/utils/"; then
            APPS+=("api")
          fi
          if echo "$CHANGED" | grep -qE "^apps/admin/|^packages/"; then
            APPS+=("admin")
          fi

          JSON=$(printf '%s\n' "${APPS[@]}" | jq -R . | jq -s -c .)
          echo "apps=$JSON" >> $GITHUB_OUTPUT

  deploy:
    needs: detect
    if: needs.detect.outputs.apps != '[]'
    runs-on: ubuntu-latest
    strategy:
      matrix:
        app: ${{ fromJSON(needs.detect.outputs.apps) }}
    environment: production-${{ matrix.app }}

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 18
          cache: 'npm'

      - run: npm ci

      - name: Build ${{ matrix.app }}
        run: npm run build --workspace=apps/${{ matrix.app }}

      - name: Deploy ${{ matrix.app }}
        run: |
          case "${{ matrix.app }}" in
            web)
              npx vercel deploy --prod --token=${{ secrets.VERCEL_TOKEN }}
              ;;
            api)
              aws eks update-kubeconfig --name prod-cluster
              kubectl set image deployment/api api=ghcr.io/${{ github.repository }}/api:${{ github.sha }}
              ;;
            admin)
              aws s3 sync apps/admin/dist s3://admin-bucket --delete
              aws cloudfront create-invalidation --distribution-id ${{ secrets.CF_DIST_ID }} --paths "/*"
              ;;
          esac

Step 5: Efficient Caching for Monorepos

name: CI with Optimized Caching

on: [push, pull_request]

env:
  CACHE_VERSION: v1

jobs:
  install:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 18

      # Cache entire node_modules with workspace awareness
      - name: Cache node_modules
        uses: actions/cache@v4
        id: cache
        with:
          path: |
            node_modules
            apps/*/node_modules
            packages/*/node_modules
          key: ${{ env.CACHE_VERSION }}-modules-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}

      - name: Install dependencies
        if: steps.cache.outputs.cache-hit != 'true'
        run: npm ci

  build:
    needs: install
    runs-on: ubuntu-latest
    strategy:
      matrix:
        package: [ui, utils, config]
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 18

      # Restore cached node_modules
      - uses: actions/cache@v4
        with:
          path: |
            node_modules
            apps/*/node_modules
            packages/*/node_modules
          key: ${{ env.CACHE_VERSION }}-modules-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}

      # Cache build outputs per package
      - uses: actions/cache@v4
        with:
          path: packages/${{ matrix.package }}/dist
          key: build-${{ matrix.package }}-${{ hashFiles(format('packages/{0}/**', matrix.package)) }}

      - name: Build ${{ matrix.package }}
        run: npm run build --workspace=packages/${{ matrix.package }}

  apps:
    needs: build
    runs-on: ubuntu-latest
    strategy:
      matrix:
        app: [web, api, admin]
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 18

      - uses: actions/cache@v4
        with:
          path: |
            node_modules
            apps/*/node_modules
            packages/*/node_modules
          key: ${{ env.CACHE_VERSION }}-modules-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}

      # Restore built packages
      - uses: actions/cache@v4
        with:
          path: packages/*/dist
          key: build-packages-${{ github.sha }}
          restore-keys: |
            build-packages-

      - name: Build ${{ matrix.app }}
        run: npm run build --workspace=apps/${{ matrix.app }}

      - name: Test ${{ matrix.app }}
        run: npm run test --workspace=apps/${{ matrix.app }}

Step 6: Complete Monorepo Workflow

name: Monorepo CI/CD

on:
  push:
    branches: [main, develop]
  pull_request:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  analyze:
    runs-on: ubuntu-latest
    outputs:
      packages: ${{ steps.affected.outputs.packages }}
      apps: ${{ steps.affected.outputs.apps }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: 18
          cache: 'npm'

      - run: npm ci

      - name: Get affected
        id: affected
        run: |
          # Using Nx to detect affected
          PACKAGES=$(npx nx show projects --affected --type=lib | jq -R -s -c 'split("\n")[:-1]')
          APPS=$(npx nx show projects --affected --type=app | jq -R -s -c 'split("\n")[:-1]')

          echo "packages=$PACKAGES" >> $GITHUB_OUTPUT
          echo "apps=$APPS" >> $GITHUB_OUTPUT

  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 18
          cache: 'npm'
      - run: npm ci
      - run: npx nx affected --target=lint

  test-packages:
    needs: analyze
    if: needs.analyze.outputs.packages != '[]'
    runs-on: ubuntu-latest
    strategy:
      matrix:
        package: ${{ fromJSON(needs.analyze.outputs.packages) }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 18
          cache: 'npm'
      - run: npm ci
      - run: npx nx test ${{ matrix.package }}

  build-apps:
    needs: [analyze, test-packages]
    if: always() && needs.analyze.outputs.apps != '[]'
    runs-on: ubuntu-latest
    strategy:
      matrix:
        app: ${{ fromJSON(needs.analyze.outputs.apps) }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 18
          cache: 'npm'
      - run: npm ci
      - run: npx nx build ${{ matrix.app }}
      - uses: actions/upload-artifact@v4
        with:
          name: ${{ matrix.app }}-build
          path: apps/${{ matrix.app }}/dist

  deploy:
    needs: build-apps
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    strategy:
      matrix:
        app: ${{ fromJSON(needs.analyze.outputs.apps) }}
    environment: production-${{ matrix.app }}
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: ${{ matrix.app }}-build
      - name: Deploy ${{ matrix.app }}
        run: echo "Deploying ${{ matrix.app }}"

Monorepo Tool Comparison

ToolAffected DetectionRemote CacheLearning Curve
TurborepoVia git diffVercel/customLow
NxBuilt-in graphNx Cloud/customMedium
LernaLimitedNoLow
Manualpaths-filterGitHub CacheLow

Practice Question

In a monorepo, why should path filters for an app include its dependent packages, not just the app directory?