Q12 of 38 · CI/CD & DevOps
How do you cache dependencies and Docker layers to speed up CI?
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 buildxwith--cache-toand--cache-frompointing 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 tomain. - Use
--mount=type=cachefor 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 broad —
ubuntu-latestonly — cache hits forever, even when lockfile changes. Always hash the lockfile. - Cache key too narrow —
hashFiles('**/*.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
// COMMON PITFALL
// Related questions
How would you structure a monorepo's CI to only test changed packages?
CI/CD & DevOps
How do you parallelise your test suite to keep CI runs under 10 minutes?
CI/CD & DevOps
How would you decide which test suites run on every commit versus nightly?
CI/CD & DevOps
A QA repo is growing uncontrollably because large binary test fixtures (video recordings, DB dumps) are committed directly. How do you solve this?
Git