How to Set Up CI/CD for a JavaScript Monorepo
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.
See the live comparison
View turborepo vs. nx on PkgPulse →