IAnswerable

Speed Up GitHub Actions with Proper Caching

02 Feb 2026

An eight-minute CI run is usually seven minutes of reinstalling the exact same dependencies and rebuilding the exact same layers it built last time. Caching fixes most of that, and it’s a few lines of YAML, not a rearchitecture.

Dependency caching

For Node, actions/setup-node has caching built in — you don’t need actions/cache separately:

- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: 'npm'

This caches ~/.npm keyed on your lockfile hash. Same for setup-python with cache: 'pip', and setup-go caches the module and build cache automatically.

If you’re using a package manager without built-in support, do it manually:

- uses: actions/cache@v4
  with:
    path: ~/.cache/pnpm-store
    key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
    restore-keys: |
      pnpm-${{ runner.os }}-

restore-keys is what makes this actually useful. If the exact lockfile hash doesn’t match (you changed one dependency), it falls back to the most recent cache with that prefix instead of a cold install. You still install the one changed package fresh, but everything else comes from cache.

Build output caching

Compiled languages and bundlers benefit more from caching build artifacts than dependencies:

- uses: actions/cache@v4
  with:
    path: |
      .next/cache
    key: nextjs-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
    restore-keys: |
      nextjs-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-

Next.js’s own build cache understands incremental builds — if you restore it before next build runs, only changed pages rebuild.

Docker layer caching

This is the one people skip and it’s usually the biggest win if your CI builds a Docker image. Use Buildx with GitHub Actions cache backend:

- uses: docker/setup-buildx-action@v3

- uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: myapp:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

mode=max caches every layer, including intermediate build stages — not just the final image layers. Without this, every CI run reinstalls every OS package and every dependency from scratch inside the container, even though nothing in your Dockerfile changed.

Order your Dockerfile so things that change rarely (base image, system packages) come before things that change often (your source code):

FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

Copying package*.json and running npm ci before copying the rest of the source means that layer only invalidates when dependencies change, not on every commit.

What breaks caching

Takeaway

Add dependency caching first — it’s one with: cache: line and cuts install time immediately. Add Docker layer caching second if you build images; it’s usually the bigger win but takes a bit more Dockerfile discipline to get right.