Q12 of 38 · CI/CD & DevOps

How do you cache dependencies and Docker layers to speed up CI?

CI/CD & DevOpsMidci-cdcachedockerbuildxperformance

Short answer

Short answer: Hash lockfiles for cache keys (package-lock.json, poetry.lock, pom.xml). Use the CI provider's cache action with restore-keys for partial hits. For Docker, use buildx with cache-to/cache-from to a registry, and order Dockerfile layers least-to-most volatile so changes invalidate as little as possible.

Detail

Dependency caching basics:

  • Key the cache on the lockfile hash. hashFiles('**/package-lock.json') in GitHub Actions, similar in GitLab/Circle.
  • Restore-keys (fallbacks): if the exact key isn't present, restore the closest prefix match — partial cache better than no cache.
  • Cache the install output (node_modules, .venv, ~/.m2/repository), not the package manager's metadata cache. The latter still triggers a resolve.

Docker layer caching:

  • Use docker buildx with --cache-to and --cache-from pointing to a registry (GHCR, ECR). The cache is shared across all CI runs and developers.
  • Tag the cache by branch: type=registry,ref=ghcr.io/org/repo:cache-${{ github.head_ref }} with fallback to main.
  • Use --mount=type=cache for package manager directories inside Dockerfiles.

Dockerfile layer ordering:

  • Copy lockfiles and install dependencies before copying source. Source changes shouldn't invalidate the dep-install layer.
  • Order from least-volatile (base image, OS packages, deps) to most-volatile (source, app config).
  • Multi-stage: a "builder" stage with deps + tooling; a "runtime" stage that's slim. Cache keeps the heavy builder warm.

Impact:

  • Cold install of a typical node app: 60-120 seconds.
  • Warm install from cache: 5-10 seconds.
  • Cold Docker build: 3-5 minutes.
  • Warm Docker build with layer cache: 30-60 seconds.

Pitfalls:

  • Cache key too broadubuntu-latest only — cache hits forever, even when lockfile changes. Always hash the lockfile.
  • Cache key too narrowhashFiles('**/*.json') — invalidates on unrelated changes.
  • Forgetting restore-keys — cache miss = full reinstall every time the lockfile changes.
  • Caching test results — sounds clever, leads to "tests passed last time, must still pass" scenarios. Don't.

For monorepos: cache per-package, keyed on that package's lockfile hash. Otherwise the whole monorepo's cache invalidates on any lockfile bump.

// EXAMPLE

.github/workflows/cached-build.yml

- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: 'npm'  # caches ~/.npm based on package-lock.json

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

- uses: docker/build-push-action@v5
  with:
    context: .
    cache-from: type=registry,ref=ghcr.io/org/app:buildcache
    cache-to: type=registry,ref=ghcr.io/org/app:buildcache,mode=max

// WHAT INTERVIEWERS LOOK FOR

Lockfile-hashed keys with restore-keys, registry-backed Docker layer cache, layer ordering from least to most volatile, and the trade-off awareness.

// COMMON PITFALL

Caching `node_modules` keyed on `runner.os` only — gets stale, hides bugs that only manifest on a fresh install.