GitHub Actions CI/CD for Monorepo Projects
A CI/CD pipeline for a monorepo faces a fundamentally different challenge than a single-application pipeline: how do you run fast, reliable pipelines when only 1 of your 5 applications changed? Naively building everything on every commit turns a 2-minute pipeline into a 15-minute bottleneck that developers work around by batching commits — exactly what you're trying to prevent.
This guide covers a production GitHub Actions setup for a Turborepo monorepo: affected-only builds, parallel job execution, Turbo remote caching integration, environment-based deployment gates, and the security patterns that prevent secrets from leaking into logs or forked PR runs.
Key Takeaways
- Use
fetch-depth: 2in checkout to enable Turbo's affected package detection- Run lint, type-check, test, and build in parallel jobs — don't chain them sequentially
- Matrix jobs let you test across multiple Node.js versions or operating systems without duplicating YAML
- Store Turbo remote cache credentials as GitHub Secrets, not environment variables in YAML
- Use
environment:protection rules on production deploy jobs to require manual approvalpaths-filteraction lets you skip CI entirely when only docs or non-code files changed- Never print secrets to logs — use
::add-mask::for runtime-generated secrets- Reusable workflows (
workflow_call) eliminate duplication across multiple repository workflows
Repository Structure for CI
A good CI setup mirrors your development workflow. Here's the structure that works for a Turborepo monorepo:
.github/
workflows/
ci.yml — Runs on every PR and push to main
deploy.yml — Runs on push to main (after CI passes)
security.yml — Weekly security scans
cleanup.yml — Scheduled: delete stale preview deployments
actions/
setup-pnpm/
action.yml — Reusable: install Node + pnpm + cache
Core CI Workflow
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
# Cancel in-progress runs when a new run starts
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
NODE_VERSION: '22'
PNPM_VERSION: '10.28.0'
jobs:
# Fast path: skip if only docs/assets changed
changes:
runs-on: ubuntu-latest
outputs:
code: ${{ steps.filter.outputs.code }}
docs: ${{ steps.filter.outputs.docs }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
code:
- 'apps/**/*.ts'
- 'apps/**/*.tsx'
- 'packages/**/*.ts'
- 'turbo.json'
- 'package.json'
- 'pnpm-lock.yaml'
docs:
- 'apps/docs/**'
- '**/*.md'
lint:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2 # Required for turbo affected detection
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm turbo run lint
- name: Type check
run: pnpm turbo run type-check
test:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_DB: ecosire_test
POSTGRES_USER: ecosire
POSTGRES_PASSWORD: password
ports:
- 5433:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run unit tests
run: pnpm turbo run test
env:
DATABASE_URL: postgresql://ecosire:password@localhost:5433/ecosire_test
REDIS_URL: redis://localhost:6379
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./apps/api/coverage/lcov.info
build:
needs: [lint, test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build all apps
run: pnpm turbo run build
env:
NEXT_PUBLIC_API_URL: ${{ vars.STAGING_API_URL }}
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build-${{ github.sha }}
path: |
apps/web/.next/
apps/api/dist/
retention-days: 1 # Keep briefly for deploy job
E2E Test Job with Playwright
e2e:
needs: build
runs-on: ubuntu-latest
strategy:
matrix:
project: [chromium, firefox, mobile-chrome]
fail-fast: false # Don't cancel other browsers if one fails
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
run: cd apps/web && npx playwright install ${{ matrix.project }} --with-deps
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: build-${{ github.sha }}
- name: Start test servers
run: |
cd apps/api && node dist/main.js &
cd apps/web && npx next start &
npx wait-on http://localhost:3000 http://localhost:3001/health
env:
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
- name: Run E2E tests
run: cd apps/web && npx playwright test --project=${{ matrix.project }}
env:
BASE_URL: http://localhost:3000
- name: Upload test results
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report-${{ matrix.project }}
path: apps/web/playwright-report/
retention-days: 7
Security Scanning Workflow
# .github/workflows/security.yml
name: Security Scan
on:
schedule:
- cron: '0 2 * * 1' # Weekly Monday 2am UTC
push:
paths:
- '**/package.json'
- 'pnpm-lock.yaml'
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '10.28.0'
- name: Run pnpm audit
run: pnpm audit --audit-level moderate
continue-on-error: true # Don't fail CI, just report
- name: Run Snyk security scan
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
command: test
args: --severity-threshold=high
secret-scanning:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for secret scanning
- name: Run Gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Deployment Workflow
# .github/workflows/deploy.yml
name: Deploy to Production
on:
workflow_dispatch:
inputs:
environment:
description: 'Deploy environment'
required: true
default: 'production'
type: choice
options:
- production
- staging
# Only one production deploy at a time
concurrency:
group: deploy-${{ inputs.environment }}
cancel-in-progress: false # Don't cancel in-flight deploys
jobs:
quality-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '10.28.0'
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Run quality checks
run: |
pnpm turbo run lint type-check test
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
deploy:
needs: quality-gate
runs-on: ubuntu-latest
environment: ${{ inputs.environment }} # Requires approval for production
steps:
- uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
set -e
cd /opt/ecosire/app
echo "Pulling latest changes..."
git pull origin main
echo "Installing dependencies..."
pnpm install --frozen-lockfile
echo "Building..."
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }} \
TURBO_TEAM=${{ vars.TURBO_TEAM }} \
npx turbo run build
echo "Running migrations..."
pnpm --filter @ecosire/db db:migrate
echo "Restarting services..."
pm2 restart ecosystem.config.cjs --update-env
echo "Health check..."
sleep 10
curl -f https://ecosire.com/api/health || exit 1
curl -f https://api.ecosire.com/api/health || exit 1
echo "Deploy complete!"
- name: Notify on success
if: success()
uses: actions/github-script@v7
with:
script: |
github.rest.repos.createCommitComment({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.sha,
body: `Deployed to ${process.env.ENVIRONMENT} at ${new Date().toISOString()}`
})
env:
ENVIRONMENT: ${{ inputs.environment }}
- name: Notify on failure
if: failure()
uses: actions/github-script@v7
with:
script: |
github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `Deploy failed: ${process.env.ENVIRONMENT}`,
body: `Deploy to ${process.env.ENVIRONMENT} failed at ${new Date().toISOString()}\n\nRun: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
})
env:
ENVIRONMENT: ${{ inputs.environment }}
Caching Strategy
Effective caching is the difference between 2-minute and 8-minute CI runs:
# Reusable setup step (extract to composite action)
# .github/actions/setup-pnpm/action.yml
name: Setup pnpm
runs:
using: composite
steps:
- uses: pnpm/action-setup@v4
with:
version: '10.28.0'
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
# Turbo cache (separate from pnpm cache)
- uses: actions/cache@v4
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
shell: bash
Security Best Practices
Never echo secrets:
# Wrong — secret appears in logs
- run: echo "API key is ${{ secrets.API_KEY }}"
# If you must generate and use runtime secrets
- run: |
echo "::add-mask::$GENERATED_SECRET"
GENERATED_SECRET=$(generate-secret)
echo "GENERATED_SECRET=$GENERATED_SECRET" >> $GITHUB_ENV
Restrict permissions:
# Minimum permissions at workflow level
permissions:
contents: read
actions: read
checks: write # Add only what you need
jobs:
test:
permissions:
contents: read # Override at job level if needed
Prevent pull request injection:
# For PRs from forks — never expose secrets
on:
pull_request:
jobs:
test:
# Secrets not available in fork PRs by default
# Use pull_request_target only for trusted actions
runs-on: ubuntu-latest
Frequently Asked Questions
How do I trigger a workflow only when specific packages change?
Use dorny/paths-filter to detect which packages changed, then pass the filter outputs to job conditions. For Turbo's built-in affected detection, ensure fetch-depth: 2 in your checkout step so Turbo can compare against the previous commit. Use pnpm turbo run test --filter="...[HEAD^1]" to run tests only for changed packages and their dependents.
How should I handle database migrations in CI?
Run migrations in a separate job that runs after tests but before the final build artifact is created. Use a dedicated test database URL in CI (separate from dev and production). Never run migrations against production from CI automatically — use workflow_dispatch with manual approval via GitHub environment protection rules for production database changes.
What's the best way to manage secrets across multiple environments?
Use GitHub Environments (Settings > Environments) with protection rules. Create separate environments for staging and production. Store environment-specific secrets under each environment, not at the repository level. For shared secrets (like Turbo remote cache token), use repository-level secrets. The environment: key in job configuration enables environment-specific secrets and protection rules.
How do I speed up pnpm install in CI?
The actions/setup-node@v4 with cache: 'pnpm' caches the pnpm store. For large monorepos, also use pnpm install --prefer-offline after restoring the cache. The combination of pnpm store caching and Turbo remote caching typically reduces install + build time by 60-80% on cache hits.
How do I run different workflows for PRs vs. main branch pushes?
Use the on trigger conditions: on.push.branches for main and on.pull_request.branches for PRs. You can run a faster subset of tests for PRs (lint, unit tests) and the full suite on main. Use github.event_name == 'push' conditions to run deploy jobs only on push to main, not on PR events.
Next Steps
A well-designed CI/CD pipeline is the foundation of a high-velocity engineering team — it gives you confidence to ship frequently and catch regressions automatically. ECOSIRE runs GitHub Actions CI with lint, type-check, 1,301 unit tests, Playwright E2E, and zero-downtime SSH deployments on every push to main.
Whether you need CI/CD architecture consulting, Turborepo monorepo setup, or complete DevOps support, explore our engineering services.
Written by
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
ECOSIRE
Grow Your Business with ECOSIRE
Enterprise solutions across ERP, eCommerce, AI, analytics, and automation.
Related Articles
Accounting Automation: Eliminate Manual Bookkeeping in 2026
Automate bookkeeping with bank feed automation, receipt scanning, invoice matching, AP/AR automation, and month-end close acceleration in 2026.
AI Agents for Business: The Definitive Guide (2026)
Comprehensive guide to AI agents for business: how they work, use cases, implementation roadmap, cost analysis, governance, and future trends for 2026.
AI Agents vs RPA: Which Automation Technology is Right for Your Business?
Deep comparison of LLM-powered AI agents versus traditional RPA bots — capabilities, costs, use cases, and a decision matrix for choosing the right approach.