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:22WORKDIR /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:22-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:22-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 | ~110 MB |
| 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.23-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:22-alpine AS build
WORKDIR /app
RUN echo '{"name":"demo","version":"1.0.0","scripts":{"build":"echo built"}}' > package.json
RUN npm run build
RUN echo "console.log('Hello from optimized image!');" > dist/server.js
# Stage 2 — runtime (only dist/ lands here)
FROM node:22-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:multi