GitHub Actions Caching Strategy for Monorepos: Skipping Dependency Installation When Only Docs or Astro Marketing Changes
I've watched too many CI pipelines sit idle for 3-5 minutes while GitHub Actions reinstalls 40,000 npm packages because someone updated a Markdown file in the docs folder. If you're running a monorepo and haven't implemented workspace-level caching with path-based job skipping, you're hemorrhaging developer time and CI minutes.
This isn't a Nx or Turborepo post. Those tools are powerful, but they add complexity and vendor lock-in. I prefer a lean GitHub Actions setup that caches per workspace and detects which packages actually changed. For CitizenApp, this cut our CI time from ~12 minutes to 2-3 minutes for doc-only changes.
The Problem with Generic Monorepo Caching
Most teams reach for a single pnpm-lock.yaml cache key for the entire monorepo:
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"
This approach has a fatal flaw: any change to any lock file invalidates the cache for the entire monorepo. Update a dependency in the marketing site? Cache miss. Update docs? Cache miss. The lockfile regenerates, and you're installing everything again.
Even worse, if you're not using a workspace manager, you're probably not caching at all-just running npm install on every commit.
I burned weeks debugging this before realizing the issue. I'd optimize our CI steps, trim our test suite, and wonder why builds weren't getting faster. The problem was hiding in plain sight: we were installing dependencies 50+ times a day when we only needed to do it 5.
Smart Caching: Cache per Workspace
The fix is to create separate cache keys for each workspace. Here's what I use:
name: CI
on:
push:
branches: [main, develop]
pull_request:
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
api: ${{ steps.changes.outputs.api }}
web: ${{ steps.changes.outputs.web }}
docs: ${{ steps.changes.outputs.docs }}
marketing: ${{ steps.changes.outputs.marketing }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: dorny/paths-filter@v2
id: changes
with:
filters: |
api:
- 'packages/api/**'
- 'pnpm-lock.yaml'
web:
- 'packages/web/**'
- 'pnpm-lock.yaml'
docs:
- 'packages/docs/**'
- 'pnpm-lock.yaml'
marketing:
- 'packages/marketing/**'
- 'pnpm-lock.yaml'
install-dependencies:
runs-on: ubuntu-latest
needs: detect-changes
strategy:
matrix:
workspace: [api, web, docs, marketing]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"
cache-dependency-path: "packages/${{ matrix.workspace }}/pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install --frozen-lockfile
working-directory: packages/${{ matrix.workspace }}
test-api:
runs-on: ubuntu-latest
needs: [detect-changes, install-dependencies]
if: needs.detect-changes.outputs.api == 'true'
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"
cache-dependency-path: "packages/api/pnpm-lock.yaml"
- name: Run tests
run: pnpm test
working-directory: packages/api
build-web:
runs-on: ubuntu-latest
needs: [detect-changes, install-dependencies]
if: needs.detect-changes.outputs.web == 'true'
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"
cache-dependency-path: "packages/web/pnpm-lock.yaml"
- name: Build
run: pnpm build
working-directory: packages/web
build-marketing:
runs-on: ubuntu-latest
needs: [detect-changes, install-dependencies]
if: needs.detect-changes.outputs.marketing == 'true'
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"
cache-dependency-path: "packages/marketing/pnpm-lock.yaml"
- name: Build
run: pnpm build
working-directory: packages/marketing
Let me break down what's happening:
detect-changesjob: Usesdorny/paths-filterto determine which workspaces changed. This runs once and outputs flags for each workspace.install-dependenciesjob: Uses a matrix to install dependencies for only the changed workspaces. Each workspace gets its own cache key.- Workspace-specific jobs (
test-api,build-web, etc.): Only run if their corresponding workspace changed (if: needs.detect-changes.outputs.WORKSPACE == 'true').
The magic happens in the cache key: cache-dependency-path: "packages/${{ matrix.workspace }}/pnpm-lock.yaml". Each workspace gets its own cache entry. Change only packages/marketing/? Only that cache is used. No reinstall for the API or web app.
Why This Matters: Real Numbers
For CitizenApp, we have four main workspaces:
- api: FastAPI backend
- web: React 19 frontend
- docs: Astro documentation
- marketing: Astro marketing site
Before this setup, every commit triggered a full install (~200 seconds). With smart caching and path-based skipping:
- Doc-only change: 0 seconds (job skipped entirely)
- Marketing change: 15 seconds (cache hit, no reinstall)
- API change: 45 seconds (fresh install only for API)
On a typical day with 20 commits, we save ~40 minutes of CI time. Scale that across a team, and it's real productivity.
Gotcha: Shared Dependencies and Hoisted Packages
This approach assumes each workspace has its own pnpm-lock.yaml. If you're using pnpm's workspace root lockfile strategy (monorepo mode), you'll need to adjust:
cache-dependency-path: "pnpm-lock.yaml"
But then you lose the per-workspace granularity. My preference: use separate lockfiles per workspace. It's more explicit and avoids the monorepo lockfile merge hell I experienced in early CitizenApp development.
If you must use a root lockfile, condition the cache invalidation more carefully:
- name: Determine cache key
id: cache-key
run: |
HASH=$(sha256sum pnpm-lock.yaml | cut -d' ' -f1)
echo "hash=$HASH" >> $GITHUB_OUTPUT
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"
cache-dependency-path: "${{ steps.cache-key.outputs.hash }}"
This is hacky, though. I avoid it.
One More Thing: Matrix Optimization
You might wonder why I'm running install-dependencies as a matrix job instead of letting each workspace job handle its own install. The answer: job deduplication. If both test-api and lint-api run, they'll both try to install. With a separate job, you install once and both jobs reuse the cache.
Summary
Monorepo caching isn't glamorous, but it's one of the highest-ROI DevOps changes you can make. Stop reinstalling packages for unrelated changes. Use path-based detection and per-workspace cache keys. Your CI time-and your team's sanity-will thank you.
Comments
No comments yet. Start the discussion.