🎉 DevOps Interview Prep Bundle is live — 1000+ Q&A across 20 topicsGet it →
All Fixes
Today I Fixed

Docker build cache not being used in CI — every build starting from scratch

DockerJun 8, 202620 minutes to fixdockercicdgithub-actions

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:

yaml
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-app

cache-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:

dockerfile
# syntax=docker/dockerfile:1
FROM node:22-alpine
WORKDIR /app
 
COPY package.json package-lock.json ./
RUN npm ci
 
COPY . .
RUN npm run build

With 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:

dockerignore
.git
node_modules
.next
coverage
*.log

Files 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:

bash
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.

Sources

Did this fix work?

Tell us what needs improving. No account required.