Multi-Stage Builds
The problem with single-stage builds
Section titled “The problem with single-stage builds”A typical application needs two very different sets of tools:
- Build time: compilers, test runners, type checkers, build toolchains (
tsc,go build,maven, etc.) - Run time: only the compiled output and its runtime dependencies
In a naive single-stage Dockerfile you install everything in one image. The compiler that was essential during the build now sits idle in production, adding hundreds of megabytes and unnecessary attack surface.
# Naive single-stage — compiler ends up in the final imageFROM node:24WORKDIR /appCOPY . .RUN npm ciRUN npm run buildCMD ["node", "dist/server.js"]A typical result:
myapp:naive 1.21GBMulti-stage builds
Section titled “Multi-stage builds”Docker BuildKit supports multi-stage builds: a single Dockerfile with multiple FROM instructions. Each FROM starts a fresh stage. You can copy artifacts from an earlier stage into a later one using COPY --from=<stage>.
The key insight: only the last stage is exported as the final image. All intermediate stages — along with every tool installed in them — are discarded automatically.
Anatomy of a multi-stage Dockerfile
Section titled “Anatomy of a multi-stage Dockerfile”# syntax=docker/dockerfile:1
# --- Stage 1: build ---FROM node:24-alpine AS buildWORKDIR /appCOPY package.json package-lock.json ./RUN npm ciCOPY src/ ./src/RUN npm run build # produces dist/
# --- Stage 2: runtime ---FROM node:24-alpine AS runtimeWORKDIR /appCOPY --from=build /app/dist ./distCOPY --from=build /app/node_modules ./node_modulesENV NODE_ENV=productionEXPOSE 3000CMD ["node", "dist/server.js"]What changes:
| Single-stage | Multi-stage | |
|---|---|---|
| Final image contains | Source + devDeps + dist | dist + prodDeps only |
| Typical size (Node.js) | ~1.2 GB | ~170–250 MB (the node:24-alpine runtime base alone measures ~166 MB as of September 2026 — production node_modules adds the rest) |
| Build tools in prod | Yes | No |
Naming stages
Section titled “Naming stages”Give each stage a meaningful name with AS <name>. You can then reference it in COPY --from=<name> and also build only up to a specific stage during development:
# Build only the build stage (useful for running tests on CI)docker build --target build -t myapp:ci .
# Build the full image for productiondocker build -t myapp:prod .A Go example
Section titled “A Go example”Go is an ideal language for multi-stage builds: the compiler produces a single static binary that needs no runtime at all.
# syntax=docker/dockerfile:1FROM golang:1.27-alpine AS buildWORKDIR /appCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 go build -o server ./cmd/server
FROM scratch AS runtimeCOPY --from=build /app/server /serverEXPOSE 8080ENTRYPOINT ["/server"]FROM scratch is the absolute minimum base — a completely empty filesystem. The only file in the final image is the compiled binary. Result: often under 10 MB.
Hands-on: multi-stage Node.js build
Section titled “Hands-on: multi-stage Node.js build”The snippet below runs a two-stage build inside Play with Docker and prints the final image size.
# syntax=docker/dockerfile:1
# Stage 1 — build
FROM node:24-alpine AS build
WORKDIR /app
RUN echo '{"name":"demo","version":"1.0.0","scripts":{"build":"echo built"}}' > package.json
RUN npm run build
RUN mkdir -p dist && echo "console.log('Hello from optimized image!');" > dist/server.js
# Stage 2 — runtime (only dist/ lands here)
FROM node:24-alpine AS runtime
WORKDIR /app
COPY --from=build /app/dist ./dist
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node","dist/server.js"]
# --- build & run ---
# docker build -t myapp:multi .
# docker run --rm myapp:multi
# docker images myapp:multiTrade-offs
Section titled “Trade-offs”| Option | Benefit | Cost |
|---|---|---|
| Multi-stage build | Very small final image, only runtime artifacts, no leftover build tools | The Dockerfile is more complex — two or more stages have to be kept in sync |
| Runtime stage on alpine | Small, and has a shell so you can exec in to debug | Still has a package manager and a shell, a larger attack surface than distroless |
| Runtime stage on distroless | Smaller still than alpine, no shell so it is more secure | No shell means no interactive exec into a running container to debug |
Common mistakes
Section titled “Common mistakes”- Using the same base image for both the build stage and the runtime stage, when the runtime never needs a compiler — missing a big opportunity to shrink the image.
COPY --from=<stage>an entire directory from the build stage instead of only the specific artifact files needed, making the final image bigger than necessary.- Not pinning the base image version in each stage (using
latest), making the build non-reproducible across environments.
💡 Real-world example
A statically compiled Go or Rust binary run on a
distrolessorscratchbase image regularly lands well under 20 MB for the final production image — a pattern Google recommends and publishes itself through the GoogleContainerTools/distroless project ongcr.io/distroless.