Docker builds were fast locally but rebuilt every layer in GitHub Actions. That difference is expected when a CI job starts on a fresh runner and no external BuildKit cache is imported.
Use an external BuildKit cache
This workflow uses Docker's GitHub Actions cache backend:
name: build
on:
push:
jobs:
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build image
uses: docker/build-push-action@v7
with:
context: .
push: false
tags: my-app:${{ github.sha }}
cache-from: type=gha,scope=my-app
cache-to: type=gha,mode=max,scope=my-appcache-from imports reusable layers and cache-to exports the updated cache. mode=max exports intermediate layers as well as the final result. A named scope prevents a repository that builds several images from having them all overwrite the default buildkit cache object.
When using docker/build-push-action, the action populates the GitHub cache URL and token for the BuildKit backend. A hand-written docker buildx build shell step needs those runtime values exposed separately.
Order the Dockerfile for cache reuse
An external cache cannot help if an early layer changes on every commit. Copy dependency manifests before application source:
# syntax=docker/dockerfile:1
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run buildWith this order, a source-code edit does not invalidate the dependency installation layer unless package.json or package-lock.json changes.
Keep the build context stable too:
.git
node_modules
.next
coverage
*.logFiles included by COPY participate in cache checks. Sending generated output, logs, or dependency directories can invalidate the layer or make the context unnecessarily large.
Why the cache can still miss
Check the plain BuildKit output and the workflow summary instead of relying only on total duration:
docker buildx build --progress=plain .Common reasons for misses include:
- the lockfile changed;
- a broad
COPY . .occurs before dependency installation; - different branches cannot access the same cache under GitHub's cache restrictions;
- several images share the default cache scope and overwrite one another;
- the cache was evicted because of GitHub cache limits;
- a build argument used by an early layer changes on every run;
- the build uses a different builder or cache backend than expected.
BuildKit cache mounts, such as RUN --mount=type=cache,target=/root/.npm, are a separate concern. Docker notes that cache mounts are not automatically preserved in the GitHub Actions cache backend in the same way as ordinary layers.
Verification
Run the workflow twice without changing dependency files. On the second run, stable steps should report cache hits. Then change one application source file: the dependency layer should remain cached while COPY . . and later steps rebuild.
For an architecture-specific CI failure, see Docker builds that pass on Apple Silicon but fail on CI. For a complete pipeline example, see building a Docker CI/CD pipeline with GitHub Actions and ECR.