Phase 2 · CONTAINERS & CI/CD
Image best practices — multi-stage builds, size, security
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
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 aUSERline, or start from a base that is already non-root, so a container breakout lands as an unprivileged user instead ofrooton the node. - Pin the base by digest.
FROM golang:1.23floats;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:latesttrap taken to its logical end. - Scan before you ship.
docker scoutor 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.
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
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
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 exampleCOPY --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 authorizationWhy:
--fromtakes either a stage name or an external image. When the name doesn’t match anyAS <name>stage in the Dockerfile, Docker falls back to treating it as an image reference and tries to pullbulider:latestfrom 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 builderline. 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 deniedon a name that looks like one of your own stages — not a real image — is almost always a--fromtypo, not a missing registry credential. Grep the Dockerfile for theASnames before adding adocker loginstep.
Common error: Forgetting
CGO_ENABLED=0, so Go produces a dynamically-linked binary that then can’t run in a distroless orscratchfinal stage that has no libc:exec /server: no such file or directoryWhy: The message is misleading — the file is there. A dynamically-linked binary needs a loader and shared libraries (
libc) at runtime, and distrolessstaticandscratchdon’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 asgcr.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 directoryright 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 theCOPY.
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 deniedWhy: 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
chowna 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 deniedthe moment you add aUSERline 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
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
Optional extras if you have ~30 more minutes today:
- 5 min — Rebuild the lab image with a single-stage
FROM golang:1.23Dockerfile and rundocker 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:1for the full CVE breakdown, then scan anode:20orpython:3.12image 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