Every GitHub-hosted runner is a fresh virtual machine. Docker's layer cache lives on the machine that built the image, so on a new runner it's empty, and docker build reinstalls every dependency and recompiles everything on every push, even when one line changed.
The fix is to export BuildKit's cache somewhere that outlives the runner and import it on the next build. This post covers where to put it, the one setting that matters for multi-stage builds, and the cache that doesn't come along unless you ask.
Part of my guide to deploying Node.js to production.
TL;DR#
- Order the Dockerfile so dependencies install before the source is copied; no cache backend fixes a bad layer order.
- Use
cache-from: type=ghaandcache-to: type=gha,mode=maxwithdocker/setup-buildx-actionanddocker/build-push-action. mode=maxcaches every stage. Without it, a multi-stage build loses the layers of the stages that aren't in the final image.- Give each image its own
scopewhen one repository builds several. - Use a registry cache for self-hosted runners or caches bigger than GitHub's limit.
- Cache mounts (
RUN --mount=type=cache) aren't exported with the layers; inject them withbuildkit-cache-dance.
Get the layer order right first#
Docker reuses a layer only if it and every layer before it are unchanged. Copy the whole project before installing dependencies and every source edit invalidates the install:
# Every code change reinstalls every package
COPY . .
RUN npm ci
Copy only the manifests first, and the install layer survives until the lockfile changes:
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
Add a .dockerignore that excludes node_modules, .git and build output too, so local files don't change the build context. My NestJS multi-stage Dockerfile shows the full layout. Everything below assumes the Dockerfile is already ordered like this.
Option 1: the GitHub Actions cache (type=gha)#
The simplest backend stores BuildKit's cache in GitHub's own cache service. No registry and no credentials:
name: Build
on:
push:
branches: [main]
pull_request:
jobs:
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# The default "docker" driver can't export a cache; this creates a BuildKit builder that can.
- uses: docker/setup-buildx-action@v4
- uses: docker/build-push-action@v7
with:
context: .
push: false
tags: api:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
How GitHub's cache behaves shapes how well this works:
- Branches share downwards. A run can restore caches made on its own branch or on the default branch, so a warm cache on
mainspeeds up every pull request. - There's a size limit. By default a repository gets 10 GB of cache in total, and entries not used for 7 days are removed. A monorepo building several large images can hit that and start evicting its own layers.
mode=max: the setting multi-stage builds need#
cache-to has two modes, and the default is the wrong one for most Node.js images:
| Mode | What it exports | Good for |
|---|---|---|
min (default) |
Only the layers of the final image | Single-stage Dockerfiles |
max |
Every layer of every stage | Multi-stage builds |
In a multi-stage build, npm ci and the TypeScript compile run in earlier stages, and the final image only copies their output. With mode=min those stages' layers aren't exported, so the slowest steps run from scratch on every build, and the cache looks like it isn't working. Set mode=max.
Several images in one repository: scope#
Every type=gha cache goes to the same scope by default, so two images built in one repository overwrite each other's cache, and each build finds the other one's layers. Give each image its own scope:
strategy:
matrix:
service: [api, worker]
steps:
- uses: actions/checkout@v7
- uses: docker/setup-buildx-action@v4
- uses: docker/build-push-action@v7
with:
context: ./apps/${{ matrix.service }}
tags: ${{ matrix.service }}:${{ github.sha }}
cache-from: type=gha,scope=${{ matrix.service }}
cache-to: type=gha,mode=max,scope=${{ matrix.service }}
Option 2: a registry cache (type=registry)#
A registry cache stores the layers as an artifact next to your images, in GHCR, ECR, Docker Hub or any OCI registry. Reach for it when:
- you build on self-hosted runners, where GitHub's cache is less useful;
- the cache is bigger than GitHub's limit allows;
- you want other systems (another CI, your laptop) to use the same cache.
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
push: true
tags: ghcr.io/acme/api:${{ github.sha }}
cache-from: type=registry,ref=ghcr.io/acme/api:buildcache
cache-to: type=registry,ref=ghcr.io/acme/api:buildcache,mode=max
Keep the cache under its own tag (buildcache here), never one of your release tags. The job needs packages: write permission to push to GHCR. The registry cache docs list the other options.
There's also type=inline, which embeds the cache metadata in the image itself. It only supports min mode, which makes it a poor fit for multi-stage builds.
The catch: cache mounts aren't exported#
A cache mount keeps a directory between builds without putting it in a layer, the usual way to keep npm's download cache:
RUN --mount=type=cache,target=/root/.npm npm ci
On your laptop that works across builds. In GitHub Actions it doesn't, because the mount lives in the builder's local storage, which disappears with the runner, and cache backends don't export it. When the lockfile changes and the npm ci layer has to rebuild, npm downloads every package again.
Docker's docs point to buildkit-cache-dance, which copies the mount's contents in and out of the builder so actions/cache can keep them:
- uses: docker/setup-buildx-action@v4
- uses: actions/cache@v6
id: npm-cache
with:
path: npm-cache
key: npm-cache-${{ hashFiles('package-lock.json') }}
restore-keys: npm-cache-
- uses: reproducible-containers/buildkit-cache-dance@v3
with:
cache-map: |
{
"npm-cache": "/root/.npm"
}
skip-extraction: ${{ steps.npm-cache.outputs.cache-hit }}
- uses: docker/build-push-action@v7
with:
cache-from: type=gha
cache-to: type=gha,mode=max
It's worth it when dependency changes are frequent and installs are slow. If your lockfile rarely changes, the layer cache alone does most of the work.
Why the cache still misses#
When builds stay slow, the log tells you which steps ran: a cached step is marked CACHED. The usual reasons one isn't:
- A file copied too early.
COPY . .before the install step, or a missing.dockerignore. - A value that changes every build, such as a build number or timestamp passed as an
ARGorENVnear the top of the Dockerfile. Move it to the last stage, as late as possible. - A moving base image.
FROM node:22changes when a new Node patch ships, and everything after it rebuilds. That's often what you want; pin a digest if you'd rather choose when. - A different platform. An
arm64build can't use anamd64cache. - Eviction. Unused for 7 days, or pushed out by newer entries over the size limit.
FAQ#
Does GitHub Actions cache Docker layers automatically?#
No. Each GitHub-hosted runner starts with an empty Docker cache, so builds start from scratch unless you export the cache and import it on the next run. With docker/build-push-action, set cache-from: type=gha and cache-to: type=gha,mode=max to store it in GitHub's cache.
What's the difference between type=gha and type=registry?#
type=gha stores the build cache in GitHub Actions' cache service: no registry or credentials needed, but a size limit per repository (10 GB by default). type=registry stores it as an artifact in a container registry, which suits self-hosted runners, larger caches and sharing the cache outside GitHub Actions.
Why use mode=max for the Docker cache?#
The default, mode=min, only exports layers that end up in the final image. In a multi-stage build, the dependency install and compile steps run in earlier stages, so min throws their layers away and they run again on every build. mode=max exports every stage.
Why does npm ci run on every build even with caching?#
Either the layer before it changed (often COPY . . placed before the install, or a missing .dockerignore), or the lockfile changed. When it reruns, a RUN --mount=type=cache npm cache doesn't help in GitHub Actions, because cache mounts aren't exported with the layer cache; buildkit-cache-dance carries them between runs.
