Intro
Slow Docker builds drain CI budgets and hide real failures behind long queues. The fastest relief comes from understanding how Docker decides to reuse work, then arranging your Dockerfile and CI to hit the cache predictably. This guide shows how to:
- Diagnose cache misses and read build logs
- Order layers for reuse
- Use BuildKit cache mounts for language-specific tool caches
- Lock down dependency installs with lockfiles
- Persist cache across CI runners with
docker buildx - Test changes locally with Docker Compose and roll out safely
Workflow Overview
Use this end-to-end sequence to keep changes safe and measurable:
Time a clean build and a no-change rebuild. Record layer-by-layer output for comparison.
- Baseline
Turn on BuildKit locally and in CI. Switch to plain progress logs for visibility.
- Enable BuildKit
Reorder instructions so stable layers come first. Add a strict .dockerignore to prevent noisy invalidations.
- Fix layer ordering and context
Use RUN --mount=type=cache to persist tool caches (pip, npm, Go modules, apt).
- Add cache mounts for dependencies
Use docker buildx with --cache-to and --cache-from targeting a registry.
- Persist cache across CI runs
Work through a checklist to catch common misses.
- Troubleshoot leftovers
Validate performance and correctness on one service first.
- Pilot locally, then roll out
How the Docker build cache works
Each Dockerfile instruction creates a layer. Docker can reuse a cached layer if:
- The instruction text is identical
- The build args and environment that affect that instruction are identical
- All files that the instruction reads from the build context are unchanged
Key implications:
COPY/ADDare cache-sensitive: changing any referenced file invalidates that layer and all layers after it.- Instruction order matters: when a layer misses, all following layers must rebuild.
- A tight
.dockerignorereduces accidental cache misses by excluding logs, local builds, venvs,node_modules, coverage, tmp, and other noise.
Example .dockerignore:
.git
**/__pycache__/
**/*.pyc
node_modules/
.env
build/
dist/
.coverage
coverage/
tmp/
.DS_Store
Ordering layers for maximum reuse
Arrange your Dockerfile so stable work happens before volatile work:
- Stable: base image, OS packages, language runtimes
- Semi-stable: dependency installation keyed to lockfiles
- Volatile: application source code
General pattern:
# syntax=docker/dockerfile:1.7
FROM python:3.11-slim AS app
# 1) System deps first
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*
# 2) Copy only lockfiles, then install deps
WORKDIR /app
COPY requirements.txt ./
# Deps install goes here (see cache mount below)
# 3) Copy the rest only after deps are installed
COPY . .
# 4) Final command
CMD ["python", "-m", "my_service" ]
Why this helps: editing a source file does not force dependency reinstallation because the dependency layer depends only on requirements.txt.
Avoid common footguns:
- Do not place frequently changing
ARGorENVbefore heavy layers. - Do not
COPYthe whole repo before installing dependencies keyed by lockfiles. - Keep
RUNcommands deterministic (pin versions, sort package lists).
BuildKit cache mounts that help
BuildKit can persist caches for tools without baking them into the final image. Enable it and use --mount=type=cache.
Enable BuildKit and plain logs:
export DOCKER_BUILDKIT=1
export BUILDKIT_PROGRESS=plain
Python example with pip wheel cache
# syntax=docker/dockerfile:1.7
FROM python:3.11-slim AS app
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --upgrade pip
COPY requirements.txt ./
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
COPY . .
CMD ["python", "-m", "my_service" ]
Node.js example with npm cache
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS app
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --prefer-offline --no-audit --no-fund
COPY . .
CMD ["node", "server.js" ]
Go example with module cache
# syntax=docker/dockerfile:1.7
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
CGO_ENABLED=0 go build -o /out/app ./cmd/app
FROM gcr.io/distroless/base
COPY --from=builder /out/app /app
ENTRYPOINT [
/app
]
Apt cache example
# syntax=docker/dockerfile:1.7
FROM debian:stable-slim
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
Notes:
- The cache mount persists between builds on the same machine. To persist across CI machines, export/import cache via buildx (next section).
- Keep cache targets aligned with your tool defaults (pip:
/root/.cache/pip, npm:/root/.npm, Go:/go/pkg/mod).
Dependency install patterns
Follow these rules for reliable reuse:
- Always use lockfiles (
requirements.txt,poetry.lock,Pipfile.lock,package-lock.json,yarn.lock,go.sum). Pin versions. COPYonly the lockfiles first, then install deps.COPYthe rest of the source after.- Use clean, cache-friendly commands:
npm cifor Node.js; pip with wheel cache;go mod downloadbefore building. - Avoid network work in later layers. Put downloads in early, cacheable layers.
- For multi-stage builds, keep build-only deps in the builder stage and copy only artifacts to the final stage.
Multi-stage example for a React front end behind a Python API:
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS ui
WORKDIR /ui
COPY ui/package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --prefer-offline
COPY ui/. .
RUN npm run build
FROM python:3.11-slim AS api
WORKDIR /app
COPY requirements.txt ./
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
COPY . .
COPY --from=ui /ui/build ./static
CMD ["python", "-m", "api" ]
CI speedups with registry cache
Use docker buildx to persist cache between CI runs.
Initialize buildx locally (CI runners often do this for you):
docker buildx create --use --name ci-builder || docker buildx use ci-builder
docker buildx inspect --bootstrap
Build with cache export to a registry:
# Build and push image and cache
IMAGE=registry.example.com/my/app:$(git rev-parse --short HEAD)
CACHE_REF=registry.example.com/my/app:buildcache
docker buildx build \
--progress=plain \
--push \
--tag "$IMAGE" \
--cache-to type=registry,ref=$CACHE_REF,mode=max \
--cache-from type=registry,ref=$CACHE_REF \
-f Dockerfile .
Notes:
mode=maxstores more metadata for better reuse across changes.- Keep
CACHE_REFstable across runs for the same repo/service. - Pull
cache-frombefore building so remote runners benefit immediately.
Docker Compose for local testing (build stage targets):
services:
app:
build:
context: .
dockerfile: Dockerfile
target: app
environment:
- PYTHONUNBUFFERED=1
ports:
- "8080:8080"
command: ["python", "-m", "my_service" ]
Troubleshooting checklist
Work down this list when a step is not cached:
- Turn on plain logs
export DOCKER_BUILDKIT=1
export BUILDKIT_PROGRESS=plain
Check which steps say CACHED and which re-run.
- Check context size and noise
- Run:
docker buildx build . --progress=plain 2>&1 | head -n 50 - Ensure
.dockerignoreexcludes:node_modules,venv, build artifacts, logs,tmp,.git(unless you need it).
- Verify COPY order
COPYlockfiles first; run installs;COPYsource last.- If you
COPY .before installing deps, every code change breaks the dep layer cache.
- Stabilize ARG/ENV
- Build args that change per-commit should be placed after heavy layers.
- Avoid non-deterministic
RUNsteps (timestamps, random seeds,curl | shwithout pinning versions).
- Align cache mounts
- Confirm your tool uses the path you mounted. For pip, check
/root/.cache/pipafter a build. For npm,/root/.npm.
- Check base image drift
- If
FROMdigest changes frequently, dependency layers may be invalidated. Consider pinning to a digest (e.g.,python:3.11-slim@sha256:...).
- Multi-stage and cache-from
- If using multi-stage, ensure
cache-frompoints to the right image or stage name you previously pushed.
- Reproduce on a clean machine
- Pull
cache-from, then build. If it still misses, the cache key inputs are changing between runs.
Local Pilot Plan
Pick one service with slow builds and run this narrow, measurable experiment:
Goal: Reduce repeat build time by 40%+ without changing runtime behavior.
Scope: Only dependency installation cache for one service (for example, pip wheels or npm cache) and Dockerfile layer reordering around lockfiles.
Steps:
Time two builds back-to-back:
- Baseline
time docker build -t pilot:baseline .
time docker build -t pilot:rebuild .
Record the second time; this is the cache hit baseline.
- Enable BuildKit and reorder layers
- Add
# syntax=docker/dockerfile:1.7 - Export
DOCKER_BUILDKIT=1andBUILDKIT_PROGRESS=plain COPYlockfiles first; install deps; thenCOPYsource.
- Add cache mounts
- For pip:
--mount=type=cache,target=/root/.cache/pip - For npm:
--mount=type=cache,target=/root/.npm
- Rebuild and measure again
time docker build -t pilot:optimized .
time docker build -t pilot:optimized2 .
Expect the second optimized build to be much faster.
- Functional check with Docker Compose
docker compose up --build
# Visit the service or run a quick smoke command
Optional: test registry-backed cache Push --cache-to and rebuild on a fresh runner with --cache-from.
Exit criteria:
- Repeat build time reduces by 40%+
- Service starts and passes a basic smoke test
If the pilot meets goals, apply the pattern to other services.
Conclusion
You can cut CI build times by combining four practices: correct layer ordering, BuildKit cache mounts for dependencies, strict .dockerignore, and registry-backed cache for reuse across runners. Start with a small local pilot, measure gains, and roll the pattern out service by service.