A Docker build that takes ten minutes on every code change usually has a caching problem, not a hardware problem. Understanding how Docker decides what to rebuild lets you get most builds down to seconds.
How layer caching works
Each instruction in a Dockerfile produces a layer. For each step, Docker checks whether it can reuse a cached layer:
- For
RUN: the instruction text and the parent layer must be the same. - For
COPY/ADD: additionally, the checksums of the copied files must match.
Once one step misses the cache, every following step is rebuilt. So the order of instructions decides how much work a small change triggers.
Order: stable things first
Bad:
FROM node:22-slim
WORKDIR /app
COPY . . # any code change invalidates this…
RUN npm ci # …so dependencies reinstall on every build
RUN npm run build
Good:
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci # cached until the lockfile changes
COPY . .
RUN npm run build
The same pattern for other ecosystems:
# Python
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Go
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# PHP
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader
COPY . .
RUN composer dump-autoload --optimize
.dockerignore
Files in the build context that change often — logs, .git, local node_modules, build output — can invalidate COPY . . even when no source changed, and they slow down sending the context to the builder:
.git
node_modules
dist
*.log
.env
BuildKit cache mounts
BuildKit (the default builder in current Docker) can mount a persistent cache directory into a RUN step. Package managers keep their download caches between builds — even when the layer itself has to be rebuilt:
# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/root/.npm npm ci
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build go build -o /out/app ./cmd/app
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends curl
When the lockfile changes, only new or updated packages are downloaded. The cache isn’t part of the image, so it doesn’t make images bigger. (For the apt example, Debian-based images have a config file that deletes downloaded packages; remove /etc/apt/apt.conf.d/docker-clean first to benefit fully.)
Multi-stage builds cache independently
In a multi-stage build, stages that don’t depend on changed files stay cached, and BuildKit builds independent stages in parallel. Split dependency installation, compilation and the runtime image into stages.
Caching in CI
CI runners often start with an empty cache, so every build is a full build. Export and import the cache:
GitHub Actions with the GitHub Actions cache backend:
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/org/app:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
Registry cache (works anywhere):
docker buildx build \
--cache-from type=registry,ref=registry.example.com/app:buildcache \
--cache-to type=registry,ref=registry.example.com/app:buildcache,mode=max \
-t registry.example.com/app:1.4 --push .
A self-hosted runner on a persistent VPS keeps its local cache between builds automatically — often the simplest speed-up. See self-hosted GitHub runners.
Busting the cache deliberately
Sometimes you want a fresh build — for example to pick up security updates in base packages:
docker build --pull --no-cache -t app . # fresh base image, no cache
docker build --no-cache-filter=runtime . # rebuild only one stage
A scheduled weekly CI build with --pull keeps base layers current without slowing down everyday builds.
Note that RUN apt-get update stays cached forever if its line doesn’t change — another reason for periodic --no-cache builds.
Inspect the cache
docker buildx du # build cache usage
docker builder prune # remove unused build cache
docker build --progress=plain . # see CACHED vs executed steps
See docker prune for cleanup.
Checklist
- Dependency manifests copied and installed before source code
-
.dockerignoreexcludes.git, local dependencies and build output - BuildKit cache mounts for package managers
- Multi-stage builds
- CI cache export/import or a persistent self-hosted runner
- Periodic
--pull --no-cachebuilds for security updates
Related: Dockerfile tutorial and reducing Docker image size.