Optimizing GitHub Actions Caching for Monorepo CI/CD Pipelines

The Monorepo CI Bottleneck
Monorepos simplify code sharing across projects, but inefficient dependency caching can quickly slow down CI/CD pipelines. Re-running npm install or pip install across a massive codebase for every pull request consumes unnecessary compute credits and delays developer feedback. Implementing hash-based caching in GitHub Actions solves this problem by efficiently storing package archives at a granular level.
Implementing Granular Dependency Caching (Node.js Example)
Caching the entire node_modules directory directly often results in oversized cache files and potential cross-platform binary incompatibilities between runner environments. A more effective strategy is targeting the global npm or Yarn cache folder. Deriving the cache key from a lockfile hash ensures that cached assets are automatically invalidated whenever dependencies are updated.
name: Monorepo CI Pipeline
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js 20.x uses: actions/setup-node@v4 with: node-version: '20.x'
- name: Get npm cache directory id: npm-cache-dir # Extracts the exact path to the npm cache dynamically run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- name: Cache npm dependencies uses: actions/cache@v4 id: npm-cache # Used to check for cache hits later with: path: ${{ steps.npm-cache-dir.outputs.dir }} # The key incorporates the OS, Node version, and the hash of the package-lock.json # If the lockfile changes, the hash changes, and a new cache is created. key: ${{ runner.os }}-node-${{ hashFiles('/package-lock.json') }} # Restore keys provide fallback options if an exact match isn't found, # speeding up resolution for minor dependency updates. restore-keys: | ${{ runner.os }}-node-
- name: Install Dependencies # If we got a cache hit, npm ci will use the cache, significantly reducing network IO run: npm ci
- name: Run Build (Nx / Lerna / Turborepo) run: npx turbo run build --filter=...
The critical element in this configuration is hashFiles('/package-lock.json'). In a monorepo setup, this function evaluates and hashes lockfiles throughout the entire repository structure. For further optimization, you can isolate CI jobs per package and hash only the lockfile corresponding to that specific component.