Multi-stage Mastery & Tiny Images
Squeeze production images down to a few megabytes by building in a fat stage and shipping only the runtime artifacts in a minimal final stage.
Once you are comfortable with a basic multi-stage build, the next step is treating the final stage as sacred: nothing gets in unless the app truly needs it at runtime. No compilers, no package manager, no shell if you can help it.
The mindset shift
A build image and a runtime image want opposite things. The build stage wants every tool under the sun. The runtime stage wants the smallest possible attack surface. Multi-stage builds let you have both in one file.
- Compile or bundle in a stage based on the full SDK image.
- Copy only the output — the binary, the
dist/folder, the productionnode_modules— into a slim or distroless final stage. - Prefer distroless or scratch for compiled languages; there is nothing for an attacker to exec into.
Measure it
Run docker images after every change and watch the number drop. A Go service can go from ~900MB to under 15MB. A Node app from ~1.1GB to ~150MB. Smaller images pull faster, start faster, and cost less to store and scan.
Handy trick: multiple runtime targets
Give each final stage a name and build the one you want with --target, so the same Dockerfile produces a debug image (with a shell) and a lean prod image.
Example
# Build stage: full toolchain
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app ./cmd/server
# Final stage: distroless, no shell, no package manager
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]When to use it
- A Go service team ships a 12 MB image using scratch as the final stage instead of a 800 MB golang builder image.
- A Rust team uses cargo chef to separate dependency caching from source compilation, cutting incremental build time from 4 minutes to 20 seconds.
- A Java team reduces a Spring Boot image from 500 MB to 80 MB by copying only the extracted layered JAR into a minimal JRE image.
More examples
Scratch-based Go image
Compiles a static Go binary with symbols stripped (-s -w) and ships only the binary plus CA certs in a scratch image.
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags='-s -w' -o /api .
FROM scratch
COPY --from=builder /api /api
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/api"]Copy only layered Spring Boot JAR
Extracts the Spring Boot layered JAR so Docker can cache dependency layers separately from application code.
FROM eclipse-temurin:21-jdk AS builder
WORKDIR /app
COPY . .
RUN ./mvnw package -DskipTests
RUN java -Djarmode=layertools -jar target/app.jar extract
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]Distroless final stage
Uses Google's distroless Node.js image as the runtime stage, removing the shell and all OS package tools.
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app .
CMD ["index.js"]
Discussion