Skip to main content

How to Set Up CI/CD for a JavaScript Monorepo

·PkgPulse Team
0

TL;DR

Turborepo + GitHub Actions + remote cache = monorepo CI that runs in under 2 minutes. The key: only run what changed using --filter=[HEAD^1], share cache across PRs with Vercel Remote Cache, and parallelize with matrix builds. Without these, monorepos have CI times that scale with repo size; with them, CI time is flat regardless of how many packages you add.

Key Takeaways

  • --filter=[HEAD^1] — only build/test packages changed vs last commit
  • Remote cache — share build artifacts across CI runs (80%+ cache hit rate)
  • Matrix strategy — run tests per app in parallel
  • turbo prune — Docker optimization: only install deps for affected apps
  • Separate deploy workflows — deploy each app independently when it changes

The Monorepo CI Problem

Monorepos have a scaling problem: as you add more apps and packages, CI gets slower. A naive approach runs all tests for all packages on every commit — even when you only changed one component in one package. With 20 packages, this means running 19 unnecessary test suites on every PR.

The solution is affected-package detection: only run tasks for packages that changed (or depend on something that changed). Turborepo does this automatically with its --filter=[HEAD^1] flag, which computes the affected dependency graph and runs only what needs running.

Combined with remote caching (sharing build artifacts between CI runs), monorepo CI can achieve sub-2-minute times regardless of repository size. A cache hit on unchanged packages means zero work — just a cache restore that takes seconds.


Basic CI Workflow

# .github/workflows/ci.yml
name: CI

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

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true  # Cancel old runs when new commit pushed

jobs:
  ci:
    name: Build, Lint, Test
    runs-on: ubuntu-latest
    timeout-minutes: 15

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2   # Need 2 commits for --filter=[HEAD^1]

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

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

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

      - name: Lint
        run: pnpm turbo lint --filter=[HEAD^1]
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ vars.TURBO_TEAM }}

      - name: Type check
        run: pnpm turbo type-check --filter=[HEAD^1]
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ vars.TURBO_TEAM }}

      - name: Build
        run: pnpm turbo build --filter=[HEAD^1]
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ vars.TURBO_TEAM }}

      - name: Test
        run: pnpm turbo test --filter=[HEAD^1]
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ vars.TURBO_TEAM }}

The --filter=[HEAD^1] syntax tells Turborepo to only run tasks for packages that changed between the current commit (HEAD) and the previous commit (HEAD^1). The fetch-depth: 2 in the checkout step is required to have both commits available.


Remote Cache Setup

# Get Vercel Remote Cache credentials
npx turbo login
npx turbo link

# Add to GitHub repo secrets:
# TURBO_TOKEN: your Vercel token
# Add to GitHub repo variables:
# TURBO_TEAM: your Vercel team slug
# GitHub Actions with remote cache
- name: Build with remote cache
  run: pnpm turbo build --filter=[HEAD^1]
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM: ${{ vars.TURBO_TEAM }}

# CI run times with remote cache:
# PR with cache hit (common case): 15-45 seconds
# PR with cache miss (first run or changed package): 2-5 minutes

Remote cache stores build artifacts (compiled output, test results) in Vercel's infrastructure. When a PR's CI run reaches a build step for a package that was already built with the same inputs (same source files, same dependencies), Turborepo downloads the cached output instead of rebuilding. A cache hit on a build task typically takes 3-5 seconds regardless of how long the actual build would take.

For self-hosted remote cache, Turborepo supports ducktape and other open-source alternatives if you don't want to use Vercel's infrastructure.


Parallel Testing with Matrix Strategy

# Run tests for each app in parallel
jobs:
  test:
    strategy:
      matrix:
        app: [web, api, admin]
    name: Test ${{ matrix.app }}
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      - name: Test ${{ matrix.app }}
        run: pnpm turbo test --filter=@myapp/${{ matrix.app }}
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ vars.TURBO_TEAM }}

Matrix strategy runs multiple jobs in parallel. If each app's tests take 3 minutes, three parallel jobs take 3 minutes total instead of 9 minutes sequentially. GitHub Actions runs matrix jobs concurrently (up to 20 per workflow by default).


Deployment per App

# .github/workflows/deploy-web.yml
name: Deploy Web

on:
  push:
    branches: [main]
    paths:
      - 'apps/web/**'
      - 'packages/**'  # Shared packages also trigger deploy

jobs:
  deploy-web:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3

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

      - run: pnpm install --frozen-lockfile

      - name: Build web app
        run: pnpm turbo build --filter=@myapp/web
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ vars.TURBO_TEAM }}
          NEXT_PUBLIC_API_URL: ${{ vars.NEXT_PUBLIC_API_URL }}

      - name: Deploy to Vercel
        uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID_WEB }}
          working-directory: apps/web

The paths filter on the workflow trigger is important. Without it, every push to main deploys every app — including apps that didn't change. With paths, the web deploy only triggers when apps/web/** or packages/** change.


Secrets Management

# Environment-specific secrets in GitHub Actions
# Store per-environment secrets in GitHub Environment configs

jobs:
  deploy-production:
    environment: production  # Uses 'production' environment secrets
    steps:
      - name: Deploy
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}       # Scoped to 'production' environment
          API_KEY: ${{ secrets.API_KEY }}
# .github/workflows/ci.yml — use repository secrets for CI
env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  # Non-sensitive vars can be in repository variables (visible in logs)
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}

GitHub Environments let you scope secrets to deployment environments. secrets.DATABASE_URL in a production environment is different from the same secret in staging. This prevents accidentally deploying staging credentials to production.


Docker with turbo prune

# turbo prune: create a minimal workspace for a single app
# Only includes the app and its package dependencies
npx turbo prune --scope=@myapp/web --docker

# Creates:
# out/
# ├── full/      ← Full source with only web + its deps
# └── json/      ← package.json files only (for dep install layer)
# Dockerfile for apps/web
FROM node:20-alpine AS base
RUN npm install -g pnpm

# Prune: install only the packages needed for this app
FROM base AS pruner
WORKDIR /app
COPY . .
RUN npx turbo prune --scope=@myapp/web --docker

# Install: build dependency cache layer
FROM base AS installer
WORKDIR /app
COPY --from=pruner /app/out/json/ .
RUN pnpm install --frozen-lockfile

# Build
FROM base AS builder
WORKDIR /app
COPY --from=installer /app/node_modules ./node_modules
COPY --from=pruner /app/out/full/ .
RUN pnpm turbo build --filter=@myapp/web

# Production image
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/apps/web/.next ./.next
COPY --from=builder /app/apps/web/public ./public
COPY --from=builder /app/apps/web/package.json .

EXPOSE 3000
CMD ["node", "server.js"]

turbo prune creates a minimal workspace containing only the target app and its transitive package dependencies. Without pruning, a Docker build for apps/web would install all dependencies from all packages in the monorepo — most of which apps/web doesn't need. Pruning reduces Docker image size and build time.


Compare monorepo tooling on PkgPulse. Also see Vitest vs Jest for test configuration and Playwright vs Cypress for E2E tests in your CI pipeline.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.