03

Multi-Stage Docker Build

Run it in the public monorepo

This course is built around the public HelixWorks Kubernetes Lab monorepo. The excerpt below is runnable source, not pseudocode.

Source: app/Dockerfile

FROM nginx:1.29-alpine

COPY index.html /usr/share/nginx/html/index.html

EXPOSE 80

Code to reality

Declared intent
Package the first web workload with one repeatable runtime and HTTP port.
Interpreter
The Docker builder executes each instruction and records an immutable image layer.
Software effect
The image contains the HelixWorks page at the nginx document root and declares port 80.
Hardware effect
A running container consumes host CPU, memory, storage layers, and a network namespace.
Observable evidence
docker image inspect and an HTTP request to the running container identify the image and response.

Key terms

TermMeaning
Multi-stage buildSeveral FROM stages in one Dockerfile
Build stageThe stage that compiles/produces artifacts
Runtime stageThe final slim image that actually ships
COPY --fromPulls files from an earlier stage
ArtifactCompiled output (binary, bundle) carried forward
Final imageThe last stage, which becomes the shipped image

Problem & solution

Naive Dockerfiles ship compilers, source, and build dependencies in the final image, producing bloated, slow-to-pull images with a large attack surface. We need final images that contain only what's required to run the app.

Solution: Use multiple build stages, compile in a fat toolchain image and copy only the final artifact into a slim runtime image, for small, secure images.

The analogy

When a craftsman builds a product, the workshop crate is full of saws, offcuts, and raw materials; you would never ship that whole mess to the customer. Instead you take just the finished item out and place it in a small, clean finished-goods crate for delivery. A multi-stage Docker build does exactly this: the builder stage holds the compiler and source, and you copy only the artifact into a tiny runtime image that is all that ships.

Problem: fat images

A single-stage build ships build tools + source + dependencies in the final image. Result: huge, slower, larger attack surface.

Solution: multi-stage builds

Use multiple FROM stages. Build in one stage, copy only the final artifact into a tiny runtime stage.

Single-stage vs Multi-stage (ASCII)

A single-stage build keeps everything in one image; a multi-stage build discards the heavy builder and ships only the final artifact.

Graph legend — each node maps to a stage in the Go productcatalogservice build below:

Graph nodeMaps toWhat it does
Single stage - about 1 GBa one-FROM DockerfileShips the whole golang:1.22 toolchain plus source: huge image
Stage 1 build - golang:1.22the FROM golang:1.22 AS build stageCompiles the static server binary
Stage 2 runtime - scratchthe FROM scratch stageCarries only the copied binary, a few MB final image

Example: build a React/Vite app, serve with nginx

Here the app is built with Node, then the static output is copied into a tiny nginx image to serve it.

# ---- Stage 1: build ----
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build          # outputs to /app/dist

# ---- Stage 2: runtime ----
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Example: Go binary (even smaller)

This builds Google Online Boutique's productcatalogservice — a real Go microservice that serves the product catalog over gRPC on port 3550. A compiled Go binary needs no runtime, so the final stage can be scratch (an empty base) for the smallest possible image.

# productcatalogservice (Online Boutique, Go gRPC service)
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server .

FROM scratch                 # empty base = minimal
COPY --from=build /app/server /server
EXPOSE 3550
ENTRYPOINT ["/server"]

Key flag: `COPY --from=<stage>`

COPY --from=<stage> pulls a file out of an earlier stage into the current one — the heart of multi-stage builds.

  COPY --from=build  /app/server   /server
              ^source stage  ^path in that stage   ^dest in final image

Build & verify size

After building, compare image sizes to confirm the multi-stage version is much smaller than a single-stage build.

docker build -t productcatalogservice:multi .
docker images productcatalogservice    # compare sizes vs single-stage

Benefits

Multi-stage builds win on size, security, and simplicity.

  + Smaller images        -> faster pulls / deploys
  + No build tools shipped -> smaller attack surface
  + One Dockerfile         -> build + package in one place

End-to-end flow

Build heavy, ship light: compile in the builder stage and copy only the artifact into a slim runtime image.

Graph legend — each node maps to the Go productcatalogservice Dockerfile above:

Graph nodeMaps toWhat it does
Source code - productcatalogservicethe Go service repoInput to the build stage
Builder stage golang:1.22FROM golang:1.22 AS buildCompiles the binary with the full toolchain
Artifact /app/servergo build -o /app/server outputThe single static binary that gets carried forward
Runtime stage scratchFROM scratchEmpty final base, nothing but the binary
Slim final imagethe built imageA few-MB image exposing port 3550
Registrydocker push targetStores the slim image for deployment

Key takeaways

  • Name stages with AS <name>, pull artifacts with COPY --from=<name>.
  • Final stage should be a minimal runtime (alpine / scratch / distroless).
  • Build-time deps never reach production.

Checklist

  • [ ] Converted a single-stage Dockerfile to multi-stage
  • [ ] Confirmed final image is dramatically smaller
  • [ ] Understand COPY --from= and named stages