GitHub Actions Tutorial — Monorepo Caching

2 minute read

Monorepo Caching

Monorepos with multiple packages need cache strategies that avoid cache thrashing and keep each package’s dependencies isolated. Here are patterns for the most common monorepo tools.


The Problem

In a monorepo:

my-monorepo/
├── packages/
│   ├── frontend/
│   │   └── package-lock.json
│   ├── backend/
│   │   └── package-lock.json
│   └── shared/
│       └── package-lock.json
└── package-lock.json          # root

A single cache key based on the root lockfile misses changes in sub-packages. A key based on all lockfiles changes too often.


Strategy 1 — Per-Package Caching

Cache each package separately:


jobs:
  frontend:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/cache@v4
        with:
          path: packages/frontend/node_modules
          key: fe-${{ runner.os }}-${{ hashFiles('packages/frontend/package-lock.json') }}
          restore-keys: fe-${{ runner.os }}-
      - run: cd packages/frontend && npm ci

  backend:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/cache@v4
        with:
          path: packages/backend/node_modules
          key: be-${{ runner.os }}-${{ hashFiles('packages/backend/package-lock.json') }}
          restore-keys: be-${{ runner.os }}-
      - run: cd packages/backend && npm ci

Pros: Precise cache invalidation per package. Cons: More cache entries; more verbose workflow.


Strategy 2 — Combined Lock File Hash

Hash all lockfiles together:


steps:
  - uses: actions/cache@v4
    with:
      path: |
        **/node_modules
      key: mono-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
      restore-keys: mono-${{ runner.os }}-

Pros: Simple, one cache call. Cons: Any lockfile change invalidates the entire cache.


Strategy 3 — npm/Yarn/pnpm Workspace Cache

For workspace-aware package managers:


steps:
  - uses: actions/checkout@v4

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

  - uses: actions/setup-node@v4
    with:
      node-version: 20
      cache: 'pnpm'          # built-in pnpm cache support

  - run: pnpm install --frozen-lockfile

Yarn (Berry/v3+)


steps:
  - uses: actions/checkout@v4
  - uses: actions/setup-node@v4
    with:
      node-version: 20
      cache: 'yarn'
  - run: yarn install --immutable


Strategy 4 — Turborepo Cache

Turborepo has its own build cache:


steps:
  - uses: actions/checkout@v4

  - uses: actions/cache@v4
    with:
      path: .turbo
      key: turbo-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-${{ github.sha }}
      restore-keys: |
        turbo-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-
        turbo-${{ runner.os }}-

  - run: npx turbo run build test --cache-dir=.turbo

Turborepo caches build outputs per task, so unchanged packages skip entirely.


Cache Size Tips for Monorepos

Tip Impact
Use pnpm (shared store) Smaller cache than npm
Cache the download cache, not node_modules Safer, less storage
Exclude dev dependencies in production builds Smaller cache
Use Turborepo remote caching Shared across developers

Common Mistakes

Mistake Fix
Cache thrashes on every commit Use lockfile hash, not commit SHA in cache key
Caching too broadly Cache per-package or use workspace-aware tools
Missing packages after restore Run install after cache restore — cache supplements, not replaces
Cache exceeds 10 GB Audit cache sizes with gh cache list; prune unused entries

Quick Reference

Strategy Best For
Per-package Independent packages with separate lockfiles
Combined hash Small monorepos, few packages
Workspace manager pnpm/Yarn workspaces
Turborepo Large monorepos with build caching

← Previous: Cache Strategies

Next: Practice Exercises →

Updated: