Skip to content

Multi-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 image
FROM node:22
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
CMD ["node", "dist/server.js"]

A typical result:

myapp:naive 1.21GB

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.

# syntax=docker/dockerfile:1
# --- Stage 1: build ---
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY src/ ./src/
RUN npm run build # produces dist/
# --- Stage 2: runtime ---
FROM node:22-alpine AS runtime
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/server.js"]

What changes:

Single-stageMulti-stage
Final image containsSource + devDeps + distdist + prodDeps only
Typical size (Node.js)~1.2 GB~110 MB
Build tools in prodYesNo

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:

Terminal window
# Build only the build stage (useful for running tests on CI)
docker build --target build -t myapp:ci .
# Build the full image for production
docker build -t myapp:prod .

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:1
FROM golang:1.23-alpine AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o server ./cmd/server
FROM scratch AS runtime
COPY --from=build /app/server /server
EXPOSE 8080
ENTRYPOINT ["/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.

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
In a multi-stage Dockerfile, which stage ends up in the final image?
What does `COPY --from=build /app/dist ./dist` do?
Why is `FROM scratch` useful for compiled Go binaries?
Which flag lets you build only up to a specific stage?