Open this post in EchoOS →

Chapter 1.12 - Multi-Stage Builds: From a 1.3 GB Image to 7 MB

The obvious Dockerfile for a compiled program starts from the language’s official image, copies the source, builds, and runs. It works, and it ships everything: the compiler, the package cache, the source code, a full Debian userland. None of it is needed at run time, all of it has to be pulled onto every server, and every package in it is something a vulnerability scanner will flag.

Multi-stage builds fix this inside a single Dockerfile: one stage has the full toolchain and builds the program; the final stage starts from something tiny and copies in only the finished binary.

The starting point

A small Go service, main.go, using only the standard library:

package main

import (
	"fmt"
	"log"
	"net/http"
	"os"
)

func main() {
	http.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "pong")
	})

	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}
	log.Printf("listening on :%s", port)
	log.Fatal(http.ListenAndServe(":"+port, nil))
}

and a go.mod:

module example.com/pingsvc

go 1.22

The naive Dockerfile:

FROM golang:1.22
WORKDIR /src
COPY . .
RUN go build -o /usr/local/bin/pingsvc .
CMD ["pingsvc"]

Built on my machine, that image is 1.31 GB, for a program whose binary is a few megabytes.

The multi-stage version

# syntax=docker/dockerfile:1

# ---- Stage 1: build ----
FROM golang:1.22-alpine AS build
WORKDIR /src

COPY go.mod ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download

COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/pingsvc .

# ---- Stage 2: runtime ----
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /out/pingsvc /pingsvc
USER 65534:65534
EXPOSE 8080
ENTRYPOINT ["/pingsvc"]

The result: 7.24 MB, about 180 times smaller.

flowchart TD
  subgraph S1["Stage 1: build (golang:1.22-alpine)"]
    src[source + go.mod] --> dl[go mod download]
    dl --> b[go build]
    b --> bin["/out/pingsvc"]
  end
  subgraph S2["Stage 2: runtime (scratch)<br/>the image you ship"]
    certs[CA certificates]
    app["/pingsvc"]
  end
  bin -- "COPY --from=build" --> app
  S1 -. "discarded: compiler, source,<br/>module cache, Alpine userland" .-> x([not shipped])

Line by line

Stage 1, the build:

(You’ll still find -a -installsuffix cgo in many guides. Those flags date from before Go 1.10’s build cache; today -a only forces a full rebuild of the standard library every time.)

Stage 2, the runtime:

Build and check:

docker build -t pingsvc:1.0 .
docker image ls pingsvc
docker run --rm -p 8080:8080 pingsvc:1.0
curl http://localhost:8080/ping        # pong

Only the last stage becomes the image; the build stage stays behind as cache.

What you give up with scratch, and the alternatives

An empty image has no shell. docker exec -it … sh fails with executable file not found, and there’s no curl or ps for debugging. That’s a security win, since there’s nothing for an attacker to use either, but it changes how you debug: use logs and metrics, or attach a tools container to the target’s namespaces (Chapter 1.8’s container mode).

If scratch is too bare, there are two common middle grounds:

Runtime base Size Includes Choose it when
scratch ~0 nothing static binaries (Go, Rust); you add certs yourself
gcr.io/distroless/static-debian12:nonroot ~2 MB CA certs, time zone data, a non-root user, no shell static binaries, without the manual extras
alpine:3.20 ~8 MB shell, apk package manager you need a few tools, or the binary needs libc (musl)

With distroless, stage 2 shrinks to:

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/pingsvc /pingsvc
ENTRYPOINT ["/pingsvc"]

The certificates and the non-root user are already in the base image.

The same idea for a JavaScript frontend

Multi-stage isn’t only for compiled languages. A React or Vue app needs Node.js and hundreds of megabytes of node_modules to build, but the result is a folder of static files:

# ---- build: Node, dependencies, bundler ----
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# ---- runtime: just a web server and the built files ----
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html

Tests as a build stage

Stages don’t have to end up in an image. A stage that runs the test suite turns “the build passes” into “the tests pass”:

FROM build AS test
RUN go test ./...
docker build --target test .          # build up to the test stage; fails if tests fail
docker build -t pingsvc:1.0 .         # the default: the last stage, the runtime image

A normal docker build skips the test stage entirely: BuildKit only builds stages the final image actually depends on.

Checklist

That’s the end of the Docker chapter. We started with docker run hello-world; by now you’ve built images, wired services together on networks, run databases and CI in containers, managed remote hosts and a cluster, and shipped images a fraction of their naive size.