Skip to content

Phase 2 · CONTAINERS & CI/CD

Image best practices — multi-stage builds, size, security

Day 29 of 90 ~55 min 0/25 in phase Builds on Day 28

By the end of today

  • Split a Dockerfile into builder and final stages with COPY --from
  • Shrink an image with a slim final stage and non-root USER
  • Pin a base by digest and scan the result for known CVEs

Multi-stage builds: compile in one image, ship a tiny, hardened other

Section 1 of 5 · ~3 min

The last few days you built a working image. Today you make it small and safe — the two go together, and one technique delivers most of both: the multi-stage build.

A multi-stage build puts more than one FROM in a single Dockerfile. Each FROM starts a new stage. You name the first — FROM golang:1.23 AS builder — and do all the heavy lifting there: pull the compiler, download dependencies, run go build. Then you open a fresh, tiny final stage and COPY --from=builder only the finished binary into it. Everything the builder needed — the ~800 MB toolchain, your source, the module cache — is thrown away. The image you ship contains the artifact and nothing else.

Why that matters is more than disk space. A smaller image pulls faster and costs less to store, but the bigger prize is a smaller attack surface. A compiler, a package manager, or a shell you shipped by accident are all tools an attacker can use once they’re inside. An image that holds one static binary and no shell has almost nothing to exploit.

A small image is also a safer one

Three habits harden the image you ship:

  • Non-root USER. By default a container runs as root — and that root is the host’s root. Add a USER line, or start from a base that is already non-root, so a container breakout lands as an unprivileged user instead of root on the node.
  • Pin the base by digest. FROM golang:1.23 floats; FROM golang:1.23@sha256:… is frozen to exact bytes, so a rebuilt base can’t silently change under you. It’s Day 23’s :latest trap taken to its logical end.
  • Scan before you ship. docker scout or Trivy read the image’s package inventory against CVE databases and tell you which known vulnerabilities you’re carrying. Run it in CI so a vulnerable base fails the build, not production.
A multi-stage build: the builder stage from golang holds the toolchain, source and compile step and is roughly 800MB; COPY --from=builder moves only the finished binary into a tiny distroless final stage of about 10MB that ships. stage 1: builder FROM golang:1.23 AS builder toolchain + source + go build ~800 MB — discarded COPY --from=builder /out/server only stage 2: final FROM distroless :nonroot binary + runtime, no shell ~10 MB — shipped
Do the heavy work in a throwaway builder stage; ship only the artifact in a tiny final stage.

Real world: Think of a furniture workshop versus the finished chair delivered to your door. The workshop is full of saws, clamps, glue and sawdust — everything needed to make the chair, none of it something you want in your living room. Multi-stage builds keep the workshop and the delivery van separate: all the messy tooling stays in the builder stage, and only the finished chair rides out in a clean, empty van.

A named example makes it concrete. Google’s distroless images (gcr.io/distroless) contain your app and its runtime and nothing else — no shell, no package manager, no apt. Their :nonroot variants even run as an unprivileged user out of the box. Pair a golang builder stage with a distroless final stage and a Go web service ships as a roughly 10 MB image carrying near-zero OS CVEs — small and hardened from the same six lines of Dockerfile.

That’s the whole move: heavy work in a stage you discard, artifact in a stage you ship, non-root and pinned and scanned. You build exactly that below.

Hands-On Lab

Section 2 of 5 · ~4 min

Budget about 30 minutes. Open your WSL2 Ubuntu 24.04 terminal with Docker 27+ installed (Day 23’s setup). You’ll build a tiny Go web server as a multi-stage image, prove the final image is a fraction of the builder’s size, confirm it runs as non-root, and scan it. Type every command and read every line. Image IDs, digests, sizes, and scan results are unique to each build — yours will differ from the samples.

# 1. Make a project directory and move into it.
mkdir -p ~/multistage-lab && cd ~/multistage-lab
pwd
# Output:
# /home/pushkar/multistage-lab
# 2. Write a tiny Go web server that uses only the standard library (no dependencies).
cat > main.go <<'EOF'
package main

import (
	"fmt"
	"net/http"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "Hello from a multi-stage image!")
	})
	http.ListenAndServe(":8080", nil)
}
EOF
cat main.go
# Output:
# package main
#
# import (
# 	"fmt"
# 	"net/http"
# )
#
# func main() {
# 	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
# 		fmt.Fprintln(w, "Hello from a multi-stage image!")
# 	})
# 	http.ListenAndServe(":8080", nil)
# }
# 3. Declare the module — no external deps, so this is all Go needs to build.
cat > go.mod <<'EOF'
module goweb

go 1.23
EOF
cat go.mod
# Output:
# module goweb
#
# go 1.23
# 4. Write the multi-stage Dockerfile: build in golang, ship in distroless as non-root.
cat > Dockerfile <<'EOF'
# Pin the tag; in real work also pin @sha256:<digest> (yours will differ) so the base can't drift.
FROM golang:1.23 AS builder
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
# CGO_ENABLED=0 forces a static binary with no libc dependency — required for distroless/scratch.
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/server .

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /out/server /server
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/server"]
EOF
cat Dockerfile
# Output: (the two-stage Dockerfile you just wrote is echoed back)
# 5. Keep the build context lean so junk never reaches the daemon or a COPY.
cat > .dockerignore <<'EOF'
.git
*.md
Dockerfile
EOF
cat .dockerignore
# Output:
# .git
# *.md
# Dockerfile
# 6. Build the multi-stage image. Watch both stages run, then only the final stage export.
docker build -t goweb:1 .
# Output (BuildKit; digests, IDs, sizes and timings are yours-will-differ):
# [+] Building 22.4s (14/14) FINISHED                              docker:default
#  => [internal] load build definition from Dockerfile                       0.0s
#  => [builder 1/6] FROM docker.io/library/golang:1.23@sha256:<digest>       4.1s
#  => [builder 5/6] COPY . .                                                 0.0s
#  => [builder 6/6] RUN CGO_ENABLED=0 GOOS=linux go build -o /out/server .  12.6s
#  => [stage-1 1/2] FROM gcr.io/distroless/static-debian12:nonroot@sha256:<digest>  0.3s
#  => [stage-1 2/2] COPY --from=builder /out/server /server                  0.1s
#  => exporting to image                                                     0.2s
#  => => naming to docker.io/library/goweb:1                                 0.0s
# 7. Compare sizes: the golang builder base is huge; your shipped image is tiny.
docker images | grep -E 'goweb|golang|distroless'
# Output (SIZE and IMAGE ID vary by build/arch — yours will differ):
# REPOSITORY                        TAG               IMAGE ID       CREATED         SIZE
# goweb                             1                 <image-id>     8 seconds ago   ~11MB
# golang                            1.23              <image-id>     2 weeks ago     ~840MB
# gcr.io/distroless/static-debian12 nonroot           <image-id>     N/A             ~2MB
# 8. Run it in the background, publishing container port 8080 as host 8080, and hit it.
docker run -d -p 8080:8080 --name goweb goweb:1
curl http://localhost:8080/
# Output (the container ID printed by run is yours-will-differ):
# Hello from a multi-stage image!
# 9. Prove it runs as a non-root user (distroless :nonroot has no shell, so we inspect, not exec).
docker inspect --format '{{.Config.User}}' goweb:1
# Output:
# nonroot:nonroot
# 10. Scan the image for known CVEs. Distroless carries almost none.
docker scout quickview goweb:1
# Output (counts and analysed digest are yours-will-differ; Trivy: `trivy image goweb:1` is the standalone alternative):
#     Target             │  goweb:1        │    0C     0H     0M     0L
#       digest           │  <sha256-short> │
#     Base image         │  distroless/static-debian12:nonroot
# 11. Clean up the running container so the port is free for tomorrow.
docker rm -f goweb
# Output:
# goweb

Read the last steps back: you compiled a Go server in an 840 MB builder, shipped only the binary in an ~11 MB distroless image, confirmed it answers HTTP, proved it runs as nonroot, and scanned it clean. Same app as a naive single-stage build — a fraction of the size, and almost nothing left to attack.

Common Errors & Fixes

Section 3 of 5 · ~3 min

These three are the classic multi-stage and hardening traps. Read the error text slowly — parsing it is the actual skill.

Common error: Misspelling the stage name in COPY --from, so Docker looks for an image by that name instead of the builder stage — for example COPY --from=bulider /out/server /server:

ERROR: failed to solve: bulider: failed to resolve source metadata for docker.io/library/bulider:latest: pull access denied, repository does not exist or may require authorization

Why: --from takes either a stage name or an external image. When the name doesn’t match any AS <name> stage in the Dockerfile, Docker falls back to treating it as an image reference and tries to pull bulider:latest from a registry — which doesn’t exist — so the error talks about pull access, not a typo.

Fix: Match the name exactly to the FROM … AS builder line. Stage names are case-sensitive and must be identical in both places; copy-paste the name rather than retyping it.

How you’d spot it in prod: A build that fails with pull access denied on a name that looks like one of your own stages — not a real image — is almost always a --from typo, not a missing registry credential. Grep the Dockerfile for the AS names before adding a docker login step.

Common error: Forgetting CGO_ENABLED=0, so Go produces a dynamically-linked binary that then can’t run in a distroless or scratch final stage that has no libc:

exec /server: no such file or directory

Why: The message is misleading — the file is there. A dynamically-linked binary needs a loader and shared libraries (libc) at runtime, and distroless static and scratch don’t ship them. The kernel can’t find the interpreter the binary asks for, and reports it as “no such file or directory” for the executable itself.

Fix: Build a static binary with CGO_ENABLED=0 (as the lab Dockerfile does), or use a final base that includes libc such as gcr.io/distroless/base-debian12. For Go, static is the usual choice and keeps the image smallest.

How you’d spot it in prod: A container that exits instantly with exec … no such file or directory right after a green build is almost always a static-vs-dynamic mismatch between the builder and the final stage, not a missing file — check how the binary was linked before touching the COPY.

Common error: A non-root container trying to write somewhere only root can write — for example an app that logs to / or writes a cache to a read-only path:

open /data/cache.db: permission denied

Why: Under USER nonroot, the process no longer owns most of the filesystem, and distroless images are deliberately minimal. Writing to a directory owned by root, or to a read-only mount, is refused — which is the hardening working as intended, not a bug.

Fix: Write only to a path the non-root user owns. Create and chown a writable directory in the builder before copying, mount a volume for state, or point the app at /tmp. Give the process exactly the write access it needs and no more.

How you’d spot it in prod: An image that worked as root but fails with permission denied the moment you add a USER line is hitting exactly this — the app assumed root’s filesystem access. Map its writable paths to a volume or an owned directory rather than reverting to root.

Image Best Practices Interview Questions

Section 4 of 5 · ~1 min

Multi-stage builds, image size, non-root and scanning are among the most common Docker follow-ups once you’ve shown you can write a Dockerfile — a calm answer that explains why each habit exists beats reciting flags. Cover each answer, say your own version out loud first, then compare — recalling before revealing is what makes it stick for interview day. The four questions and answers render right after this note.

Go Deeper

Section 5 of 5 · ~1 min

Optional extras if you have ~30 more minutes today:

  • 5 min — Rebuild the lab image with a single-stage FROM golang:1.23 Dockerfile and run docker images — seeing the ~840 MB result next to today’s ~11 MB is the whole argument for multi-stage in one screen.
  • 10 min — Run docker scout cves goweb:1 for the full CVE breakdown, then scan a node:20 or python:3.12 image and compare the counts — the size of the base and its vulnerability count track each other closely.
  • 15 min — Read the images-and-layers and optimisation sections of the Docker for DevOps guide for how multi-stage builds, base-image choice and scanning fit the wider container workflow you build across this phase.
What is a multi-stage build and why would you use one? Both

A multi-stage build puts more than one FROM in a single Dockerfile. Each FROM starts a new stage; you name one — FROM golang:1.23 AS builder — do the heavy work there, compilers and go build, then start a fresh tiny final stage and COPY --from=builder only the finished binary into it. Everything in the builder — the toolchain, source and caches — is discarded. You use it because the shipped image ends up containing the artifact and nothing else: it pulls faster, costs less to store, and exposes a far smaller attack surface. A compiler or shell you left in by accident is something an attacker can use, so leaving them behind is a security win, not just a size one.

How would you make a Docker image smaller? Product

A few habits do most of the work. Start from a slim base — python:3.12-slim or node:20-alpine — which alone can cut hundreds of megabytes. Combine related RUN commands and clean up in the same layer, because deleting files in a later layer doesn't shrink the earlier one that added them. Use a .dockerignore so build junk never enters the context. But the biggest lever is a multi-stage build: compile in a heavy builder stage and copy only the finished artifact into a tiny final image, leaving the compilers and dev dependencies behind. For a compiled language that final stage can be distroless or scratch, taking an image from gigabytes down to tens of megabytes for the same app.

Why run a container as a non-root user? Both

By default a container's process runs as root, and that root is the host's root — user namespaces aside, uid 0 inside is uid 0 outside. So if an attacker escapes the container through a kernel or runtime bug, they land as root on the node. Running as a non-root USER means a breakout lands as an unprivileged user instead, which is defence in depth. It also catches bad habits early: a process that assumes it can write anywhere fails loudly in testing rather than in production. I either add a USER line in the Dockerfile or start from a base that is already non-root, like distroless :nonroot, and I keep the filesystem read-only where the app allows it.

How do you scan a container image for vulnerabilities, and what does a scanner actually check? Service

I run a scanner like docker scout or Trivy against the built image — docker scout cves myimage:1 or trivy image myimage:1. It reads the image's package inventory — the OS packages and the language dependencies baked into the layers — and matches their exact versions against public CVE databases. The output lists known vulnerabilities by severity so I can decide what to patch. The key is where it runs: I wire it into CI so a critical CVE fails the pull request, not production, and I rebuild on a fresh base to pick up upstream fixes. Scanning also explains why a slim or distroless base is safer — fewer packages means fewer things that can have a CVE in the first place.

Mark Day 29 complete

Tomorrow you learn to debug a misbehaving container from the outside — reading logs, shelling in with exec, and inspecting its state with inspect and stats.

Stuck on today’s lab? Ask in Mission 90 Q&A