How to Set Up CI/CD for a Monorepo (2026)
Monorepos are great for code sharing but painful for CI/CD. Without proper setup, every PR triggers builds for every package. Here's how to set up fast, efficient CI/CD for monorepos in 2026.
The Problem
monorepo/
├── apps/web/ # Next.js frontend
├── apps/api/ # Hono API
├── packages/ui/ # Shared components
├── packages/utils/ # Shared utilities
└── packages/db/ # Database schema
Change one line in packages/utils/ → rebuild everything? No. You should only rebuild what's affected.
Step 1: Turborepo for Task Orchestration
npx create-turbo@latest
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**"]
},
"lint": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["^build"]
},
"typecheck": {
"dependsOn": ["^build"]
}
}
}
Turborepo understands your dependency graph. Change packages/ui → only rebuild apps/web (which depends on it), not apps/api.
Step 2: GitHub Actions Workflow
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
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: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Turborepo Remote Cache
- name: Build
run: pnpm turbo build --filter=...[HEAD^1]
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
- name: Lint
run: pnpm turbo lint --filter=...[HEAD^1]
- name: Type Check
run: pnpm turbo typecheck --filter=...[HEAD^1]
- name: Test
run: pnpm turbo test --filter=...[HEAD^1]
Key: --filter=...[HEAD^1]
This tells Turborepo: "Only run tasks for packages that changed since the last commit." On PRs, this means only affected packages are built, linted, and tested.
Step 3: Remote Caching
Turborepo's remote cache shares build artifacts between CI runs and developers.
With Vercel (Easiest)
npx turbo login
npx turbo link
Add TURBO_TOKEN and TURBO_TEAM to GitHub Actions secrets.
Self-Hosted (Free)
# Use turbo-remote-cache-server
docker run -p 3000:3000 ducktors/turborepo-remote-cache
Impact: First CI run: 3 minutes. Subsequent (cached): 30 seconds.
Step 4: Affected-Only Deployment
Deploy Only Changed Apps
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
web: ${{ steps.filter.outputs.web }}
api: ${{ steps.filter.outputs.api }}
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/db/**'
- 'packages/utils/**'
deploy-web:
needs: detect-changes
if: needs.detect-changes.outputs.web == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npx vercel --prod --yes
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
deploy-api:
needs: detect-changes
if: needs.detect-changes.outputs.api == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: flyctl deploy --app my-api
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
Change apps/web → only deploy web. Change packages/utils → deploy both (it's a shared dependency).
Step 5: PR Checks with Status Badges
ci:
runs-on: ubuntu-latest
strategy:
matrix:
task: [build, lint, typecheck, test]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm turbo ${{ matrix.task }} --filter=...[origin/main]
Each task (build, lint, typecheck, test) runs as a separate job. PRs show individual status checks.
Common Patterns
Shared Environment Variables
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
# Only inject where needed
Database Migrations on Deploy
deploy-api:
steps:
- run: pnpm --filter @repo/db migrate
- run: flyctl deploy
Preview Deployments per App
preview-web:
if: github.event_name == 'pull_request'
steps:
- run: npx vercel --yes # Creates preview URL
Performance Tips
- pnpm over npm/yarn — faster installs, better monorepo support
- Remote cache — don't rebuild what hasn't changed
- Parallel jobs — run build, lint, test in parallel
--filter— only process affected packages- Dependency caching — cache
node_modulesbetween runs
Typical CI Times
| Without Optimization | With Optimization |
|---|---|
| Install: 2 min | Install: 30s (cached) |
| Build: 5 min | Build: 45s (affected + remote cache) |
| Lint: 2 min | Lint: 15s (affected) |
| Test: 3 min | Test: 30s (affected) |
| Total: 12 min | Total: 2 min |
FAQ
Turborepo vs Nx for CI?
Both work well. Turborepo is simpler (fewer concepts). Nx has more features (distributed task execution, affected graph). For most teams: Turborepo.
Do I need remote caching?
For teams of 2+, absolutely. It prevents rebuilding packages that another developer (or CI run) already built.
How do I handle database migrations?
Run migrations as a separate step before deployment. Use the packages/db package to centralize migration logic.
Bottom Line
Monorepo CI/CD in 2026: Turborepo for task orchestration, GitHub Actions for CI, remote caching for speed, path filters for affected-only deployment. Setup takes 1-2 hours. CI times drop from 12 minutes to 2 minutes. The key insight: only build, test, and deploy what changed.