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
- Lock down dependency installs
- Persist cache across CI runs
- Test changes locally with Docker Compose and roll out safely
Workflow Overview
Use this end-to-end sequence to keep changes safe and measurable:
- Baseline
- Time a clean build and a no-change rebuild.
- Record layer-by-layer output for comparison.
- Enable BuildKit
- Turn on BuildKit locally and in CI.
- Switch to plain progress logs for visibility.
- Fix layer ordering and context
- Reorder instructions so stable layers come first.
- Add a strict .dockerignore to prevent noisy invalidations.
- Add cache mounts for dependencies
- Use RUN --mount=type=cache to persist tool caches.
- Persist cache across CI runs
- Use docker buildx with cache-to and cache-from.
- Troubleshoot leftovers
- Use a checklist to catch common misses.
- Pilot locally, then roll out
- Validate performance and correctness on one service first.
How the Docker build cache works
Each Dockerfile instruction creates a layer. Docker can reuse a cached layer if:
- The instruction text is the same
- The build args and environment that affect that instruction are the same
- All files that the instruction reads from the build context are unchanged
Key implications:
- COPY/ADD are 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 .dockerignore reduces 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 ARG or ENV before heavy layers.
- Do not COPY the whole repo before installing dependencies keyed by lockfiles.
- Keep RUN commands 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.
- COPY only the lockfiles first, then install deps. COPY the rest of the source after.
- Use clean, cache-friendly commands: npm ci for Node.js; pip with wheel cache; go mod download before 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=max stores more metadata for better reuse across changes.
- Keep CACHE_REF stable across runs for the same repo/service.
- Pull cache-from before 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 .dockerignore excludes: node_modules, venv, build artifacts, logs, tmp, .git (unless you need it).
- Verify COPY order
- COPY lockfiles first; run installs; COPY source 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 RUN steps (timestamps, random seeds, curl pipes without pinning versions).
- Align cache mounts
- Confirm your tool uses the path you mounted. For pip, check /root/.cache/pip after a build. For npm, /root/.npm.
- Check base image drift
- If FROM digest changes frequently, dependency layers may be invalidated. Consider pinning to a digest.
- Multi-stage and cache-from
- If using multi-stage, ensure cache-from points 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:
- Baseline
- Time two builds back-to-back:
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=1 and BUILDKIT_PROGRESS=plain
- COPY lockfiles first; install deps; then COPY source.
- 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.