Build Cache and Multi-Stage Builds

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

A Dockerfile that is correct can still be slow to build and produce a bloated image. Two BuildKit features fix both problems: the build cache, which skips any step whose inputs have not changed, and multi-stage builds, which keep compilers, build caches and test tools out of the image that ships. This page explains both, using BuildKit 0.33.x with the docker/dockerfile:1 frontend under Docker Engine 29.8.x and Buildx 0.37.x. The instruction syntax itself is on Dockerfile Fundamentals.

How the build cache works

Every build step is cached by a key derived from its inputs: the parent step, the instruction text and, for COPY, ADD and RUN --mount=type=bind, a checksum of the files involved. When BuildKit reaches a step whose key matches a cached result it reuses it; the first step whose key does not match is re-executed, and so is every step after it in that stage, because their parent changed.

flowchart TB s1["FROM eclipse-temurin:25-jdk
cache HIT"] --> s2["COPY pom.xml mvnw .mvn/
cache HIT (unchanged)"] s2 --> s3["RUN mvnw dependency:go-offline
cache HIT"] s3 --> s4["COPY src/ src/
cache MISS (a .java file changed)"] s4 --> s5["RUN mvnw package
re-executed"] s5 --> s6["Later steps in the stage
re-executed"] classDef hit fill:#eef7ee,stroke:#2d6a34,color:#222222 classDef miss fill:#fbeeee,stroke:#8f2a2a,color:#222222 class s1,s2,s3 hit class s4,s5,s6 miss

The rules that follow from the cache invalidation page:

  • Instruction text: changing an instruction invalidates it and everything after it.

  • Files: COPY and ADD compare file content and metadata (not modification time). Copying the whole context with COPY . . early means any edit anywhere — even a README — invalidates every later step.

  • RUN does not look at the outside world: RUN apt-get update stays cached for months unless an earlier layer changes; the cache key is only the command string. Use --no-cache or --no-cache-filter <stage> to force it.

  • Build arguments: an ARG whose value changes invalidates the first step that uses it; SOURCE_DATE_EPOCH invalidates from WORKDIR onwards.

  • Secrets are not part of the cache key — changing a secret value does not re-run the step that used it.

Ordering for cache hits

Order instructions from least to most frequently changed. For a Maven project that means: base image, build tool files, dependency resolution, and only then the sources.

# syntax=docker/dockerfile:1
FROM eclipse-temurin:25-jdk AS build
WORKDIR /src
# 1. Rarely changes: the wrapper and the POM
COPY mvnw pom.xml ./
COPY .mvn/ .mvn/
# 2. Changes when dependencies change: resolve them into the cache mount
RUN --mount=type=cache,target=/root/.m2 ./mvnw -B -ntp dependency:go-offline
# 3. Changes on every commit: the sources
COPY src/ src/
RUN --mount=type=cache,target=/root/.m2 ./mvnw -B -ntp package -DskipTests

RUN --mount: caches, bind mounts, secrets, SSH and tmpfs

Mounts attach something to a single RUN step without leaving it in a layer.

Cache mounts

A cache mount is a directory that persists across builds on the same builder. Package managers keep their download caches there, so even when the layer cache misses (for example because pom.xml changed), only the new dependencies are downloaded. This complete Dockerfile caches both the apt packages it installs and the Maven repository:

# syntax=docker/dockerfile:1
FROM eclipse-temurin:25-jdk AS build
# apt: disable the image's docker-clean hook so downloaded packages stay in the cache mount
RUN rm -f /etc/apt/apt.conf.d/docker-clean; \
    echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
# sharing=locked serialises concurrent builds writing the same apt cache
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 git
WORKDIR /src
COPY . .
# Maven: the local repository persists across builds
RUN --mount=type=cache,target=/root/.m2 ./mvnw -B -ntp package -DskipTests

Other build tools follow the same pattern with their own cache directory, for example --mount=type=cache,target=/root/.gradle for Gradle or --mount=type=cache,target=/root/.npm,sharing=locked in front of npm ci. Options: id (defaults to the target path), sharing=shared|private|locked, from/source to seed it, and mode/uid/gid for a non-root build user. Cache mounts are local to a builder: an ephemeral CI runner starts empty unless the builder cache is exported (see BuildKit and Buildx). Derived from RUN --mount=type=cache (including its "cache apt packages" example).

Bind mounts

A bind mount uses files from the context (or another stage) for one step without copying them into a layer. It is read-only by default; add rw when the step writes next to the sources, knowing that those writes are discarded when the step ends — so copy anything you need to keep to a path outside the mount:

# syntax=docker/dockerfile:1
FROM eclipse-temurin:25-jdk AS build
WORKDIR /src
RUN --mount=type=bind,target=.,rw \
    --mount=type=cache,target=/root/.m2 \
    ./mvnw -B -ntp package -DskipTests \
 && cp target/*.jar /tmp/app.jar

Derived from RUN --mount=type=bind.

Secret mounts

A secret — here a Maven settings.xml with repository credentials — is readable only during the step that mounts it:

# syntax=docker/dockerfile:1
FROM eclipse-temurin:25-jdk AS build
WORKDIR /src
COPY . .
RUN --mount=type=secret,id=maven-settings,target=/root/.m2/settings.xml,required=true \
    --mount=type=cache,target=/root/.m2/repository \
    ./mvnw -B -ntp package -DskipTests
docker build --secret id=maven-settings,src=$HOME/.m2/settings.xml -t orders:build .

Derived from RUN --mount=type=secret and Build secrets. A secret mounted this way is not written to any layer and does not appear in docker history; see Image Best Practices and Supply-Chain Security.

SSH mounts

An SSH mount forwards the host’s SSH agent, for example to clone a private repository. The image needs an SSH client and git, and the server’s host key must be in known_hosts before the clone:

# syntax=docker/dockerfile:1
FROM eclipse-temurin:25-jdk AS private-lib
RUN apt-get update \
 && apt-get install -y --no-install-recommends git openssh-client \
 && rm -rf /var/lib/apt/lists/*
RUN mkdir -p -m 0700 ~/.ssh && ssh-keyscan github.com >> ~/.ssh/known_hosts
RUN --mount=type=ssh git clone git@github.com:myorg/private-lib.git /src/private-lib
eval $(ssh-agent)
ssh-add ~/.ssh/id_ed25519
docker build --ssh default .

Derived from RUN --mount=type=ssh (its "access to GitLab" example, adapted to GitHub and git clone).

tmpfs mounts

A tmpfs mount is scratch space in memory that never touches disk or a layer:

# syntax=docker/dockerfile:1
FROM eclipse-temurin:25-jdk AS build
WORKDIR /src
COPY . .
RUN --mount=type=tmpfs,target=/tmp/work \
    --mount=type=cache,target=/root/.m2 \
    MAVEN_OPTS=-Djava.io.tmpdir=/tmp/work ./mvnw -B -ntp verify -DskipTests

Derived from RUN --mount=type=tmpfs.

Multi-stage builds

A multi-stage Dockerfile has several FROM lines. Each starts a new stage with a fresh filesystem, and only the last stage (or the one selected with --target) becomes the image. Later stages copy just the artifacts they need from earlier ones with COPY --from=<stage>, so compilers, sources and caches never reach the final image. Multi-stage builds arrived in Docker 17.05 and are not covered by the 2015 books.

Three-stage build where the deps stage resolves dependencies and the build stage compiles and packages and only the packaged application is copied into a small runtime stage

The main techniques:

  • Named stages — FROM eclipse-temurin:25-jdk AS build, then COPY --from=build /src/target/app.jar /app/.

  • Selecting a stage — docker build --target build . builds up to build and stops; handy for a development image or for running tests.

  • External images as stages — COPY --from=busybox:1.37-musl /bin/wget /usr/local/bin/wget copies a file out of any image without making it a stage.

  • Parallel and skipped stages — BuildKit builds independent stages concurrently and skips stages the target does not depend on.

  • A test stage — a stage that runs the tests, selected in CI with --target test, keeps the runtime image free of test dependencies while still failing the build on a red test.

Derived from Multi-stage builds.

A complete Java example

# syntax=docker/dockerfile:1

# ---- deps: resolve dependencies once, reuse across source changes
FROM eclipse-temurin:25-jdk AS deps
WORKDIR /src
COPY mvnw pom.xml ./
COPY .mvn/ .mvn/
RUN --mount=type=cache,target=/root/.m2 ./mvnw -B -ntp dependency:go-offline

# ---- build: compile and package
FROM deps AS build
COPY src/ src/
RUN --mount=type=cache,target=/root/.m2 ./mvnw -B -ntp package -DskipTests \
 && cp target/*.jar /tmp/app.jar

# ---- test: run the unit tests (docker build --target test .)
FROM deps AS test
COPY src/ src/
RUN --mount=type=cache,target=/root/.m2 ./mvnw -B -ntp test

# ---- runtime: JRE only, non-root
FROM eclipse-temurin:25-jre AS runtime
RUN groupadd --system --gid 10001 app && useradd --system --uid 10001 --gid app --no-create-home app
WORKDIR /app
COPY --from=build --chown=10001:10001 /tmp/app.jar /app/app.jar
USER 10001:10001
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "/app/app.jar"]
docker build --target test .                    # fails the build if a unit test fails
docker build -t orders-service:1.4.0 .          # the runtime stage (last one) is the image
docker image ls orders-service:1.4.0            # a JRE base plus one application layer

Derived from Multi-stage builds and Java language-specific guide: Containerize a Java application. The Spring Boot version, which splits the JAR into dependency and application layers so a code change re-ships only a few kilobytes, is on Java and Spring Boot Images.

For the Node.js equivalent of this multi-stage Dockerfile, see Docker & Containers in the Node.js section.

Choosing a base image

The base image decides the image’s size, its CVE backlog, which C library your native code links against and whether there is a shell to debug with. Options, from most to least batteries-included:

Base Examples Strengths Trade-offs

Ubuntu / Debian

ubuntu:24.04, debian:trixie, eclipse-temurin:25-jre (Ubuntu based)

Familiar tooling, glibc, wide package availability

Larger; more packages mean more CVEs to triage

Debian slim

debian:trixie-slim

glibc with far fewer packages

Some tools must be installed explicitly

Alpine

alpine:3.22, eclipse-temurin:25-jre-alpine

Very small; apk package manager

musl instead of glibc — native libraries and DNS resolution behave differently; some JDK builds and native dependencies are glibc-only

Distroless

Google’s distroless Java images (gcr.io/distroless/java*)

No shell or package manager; tiny attack surface

Debugging needs docker debug or a sidecar (see Debugging Containers)

Docker Hardened Images

dhi.io/eclipse-temurin:<tag>

Minimal, distroless-style runtime variants with -dev build variants, SLSA Build Level 3 provenance, signed SBOMs and VEX, continuously patched

Requires docker login dhi.io; runtime variants have no shell

scratch

FROM scratch

Empty — for static binaries only

No libc, no CA certificates, no users unless you add them

Prefer Docker Official Images or a Verified Publisher for anything you do not build yourself, pin the tag you depend on, and let a bot such as Dependabot or Renovate propose updates. Derived from Base images and Docker Hardened Images. For DHI, the documented Java migration uses a -dev Maven image for the build stage and a runtime JRE image for the final stage:

# syntax=docker/dockerfile:1
FROM dhi.io/maven:3-jdk21-alpine3.22-dev AS builder
WORKDIR /app
COPY . ./
RUN mvn -B package -DskipTests

FROM dhi.io/eclipse-temurin:21-alpine3.22
WORKDIR /app
COPY --from=builder /app/target/app.jar /app/app.jar
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

Derived verbatim from Java migration example; pick the Java 25 tags from the DHI catalog for a Java 25 service.

Managing the build cache

docker buildx du                              # what the builder cache holds
docker builder prune                          # remove dangling build cache
docker builder prune --all --filter until=72h # everything unused for three days

Derived from Build garbage collection, which also shows how to cap the cache size in the builder configuration.