Why size matters
docker images on a beginner's machine often shows apps weighing 1.2 GB, and size is not only disk space.
Slow ships. Every deploy and every CI run pulls the image, and gigabytes across many machines and many deploys a day adds up to real minutes and real money.
Attack surface. A full OS image ships hundreds of packages the app never uses, and every one of them can carry security vulnerabilities that now need patching.
The first fix is choosing a smaller base in the FROM line.
| Base image | Rough size | Trade |
|---|---|---|
python:3.12 | about 1.0 GB | everything included |
python:3.12-slim | about 150 MB | no build tools or docs |
python:3.12-alpine | about 50 MB | minimal libc, occasional breakage |
slim drops build tools and documentation. alpine uses a minimal Linux distribution, which is tiny but occasionally incompatible with packages that expect the standard C library. slim is the safe default, which is why the Dockerfile in lesson 3-1 used it.
Putting numbers on the size argument
At 50 deploys a month, the difference between a full and a slim image is easy to quantify with integer arithmetic.
full=1000 slim=150 deploys=50 echo "full: $((full * deploys)) MB" echo "slim: $((slim * deploys)) MB" echo "saved: $(((full - slim) * deploys)) MB"
Output
full: 50000 MB slim: 7500 MB saved: 42500 MB
| Image | Monthly transfer |
|---|---|
| full, 1000 MB | 50000 MB |
| slim, 150 MB | 7500 MB |
| difference | 42500 MB |
$(( )) is bash arithmetic expansion, and variables inside it need no $ prefix. The savings line multiplies the size difference by the deploy count, which is about 42 GB of transfer avoided per month for a one-line change to the FROM instruction.
Multi-stage builds
Compiled languages have a worse problem: building needs the whole toolchain (compiler, headers, caches), but running needs only the final binary. A multi-stage build uses two FROMs in one Dockerfile:
FROM golang:1.22 AS builder WORKDIR /src COPY . . RUN go build -o /out/server . FROM alpine:3.20 COPY --from=builder /out/server /server CMD ["/server"]
FROM golang:1.22 AS builder: stage one, namedbuilder, has the full ~800 MB Go toolchainRUN go build ...: compile to a single binaryFROM alpine:3.20: stage two starts fresh from a ~8 MB baseCOPY --from=builder ...: reach back into stage one and take only the binary
Only the final stage becomes the shipped image. The toolchain, source, and caches are all left behind. Result: ~15 MB instead of ~900 MB.
Why the toolchain does not ship
The Go toolchain stays out of the shipped image because only the final FROM stage becomes the image, and it copies just the compiled binary out of the builder stage.
Each FROM starts a new stage, and only the last stage is the output image. COPY --from=builder cherry-picks the one artifact worth keeping.
| Stage | Contents | Shipped |
|---|---|---|
builder | Go toolchain, sources, caches | no |
final alpine | the compiled binary | yes |
Everything else in the builder stage is simply discarded, which is why the technique cuts an image from roughly 900 MB to roughly 15 MB without changing a line of application code.
Naming the two-stage technique
The technique is a multi-stage build, recognizable because the Dockerfile has more than one FROM.
Stage one, written as FROM golang AS builder, does the heavy compiling. Stage two starts from a tiny base and uses COPY --from=builder to take only the binary.
| Stage | Base | Job |
|---|---|---|
| one | golang:1.22 | compile |
| two | alpine:3.20 | run |
Only the small final stage ships, and the toolchain never leaves the build. The same pattern applies well beyond Go, covering anything with a build step whose tools are not needed at runtime, including bundled JavaScript front ends and compiled Java jars.