Week 6: Multi-stage Builds, Image Optimization & Registries

Closing out the Docker half of this course: shipping an image that's actually production-shaped, not a 1.2GB snapshot of your entire dev toolchain. Multi-stage builds separate "what it takes to build the app" from "what it takes to run it," and a few base-image and user choices cut both image size and attack surface dramatically — then we push the result to a real registry.

Module 6 of 17 Week 6 of 18 ~3.5 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Write a multi-stage Dockerfile that ships only what's needed at runtime
  • Choose appropriate base images and run containers as a non-root user
  • Tag images with a real versioning strategy and push them to a container registry

1. Multi-stage Builds

Compiling a Go binary, bundling a React app, or building a Java JAR all need a full toolchain — a compiler, a package manager, build-time dependencies. None of that is needed to actually run the result. A multi-stage build lets you use one image to build, and a completely different, much smaller image to ship:

Dockerfile — a Go app, single-stage (the naive way)
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go build -o server .
CMD ["./server"]
# Final image size: ~900MB — includes the ENTIRE Go toolchain, unnecessarily
Dockerfile — the same app, multi-stage
# Stage 1: "builder" — has the full Go toolchain, only exists during the build
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o server .

# Stage 2: the actual runtime image — tiny, no compiler, no source code
FROM alpine:3.19
COPY --from=builder /app/server /server
CMD ["/server"]
# Final image size: ~15MB — just the compiled binary and a minimal OS

COPY --from=builder is the entire trick: it pulls one specific file (the compiled binary) out of the first stage into the second, and everything else from the builder stage — the compiler, the source code, build caches — is discarded and never ships in your final image.

The same pattern works for any language

A Node app runs npm run build in a builder stage, then copies only the resulting static files into an nginx runtime stage. A Java app builds a JAR in a Maven builder stage, then copies just the JAR into a slim JRE runtime stage. The principle is identical everywhere: compile heavy, ship light.

2. Base Images & Non-root Users

Base image choice alone swings final image size dramatically, and it's a security decision as much as a size one — fewer packages means fewer known CVEs shipping in your image.

base image sizes, roughly
ubuntu:22.04         # ~78MB  — full-featured, familiar, but bigger than needed
node:20               # ~1.1GB — includes full Debian + build tools
node:20-slim          # ~250MB — trimmed Debian, most build tools removed
node:20-alpine        # ~180MB — musl-based Alpine Linux, much smaller, fewer packages
gcr.io/distroless/nodejs20  # ~120MB — no shell, no package manager, almost nothing but the runtime

By default, a container process runs as root inside the container. If an attacker finds a way to execute code in your app, root-inside-the-container is a meaningfully worse starting point for them to work from — closer to a full container escape — than an unprivileged user. Fix it explicitly:

running as a non-root user
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

CMD ["node", "server.js"]
Many official images already do this for you

Some official images (like postgres) already drop to a non-root user internally where it makes sense. Always check with docker run <image> whoami before assuming — don't do the work twice, but don't skip it either.

3. Image Scanning

A small, non-root image is still only as safe as the packages baked into it. Scanning tools check your image's dependencies against known-vulnerability databases:

scanning with Docker Scout / Trivy
docker scout cves my-app:1.0
# lists every known CVE affecting packages in your image, with severity

trivy image my-app:1.0
# an open-source alternative, same idea, widely used in CI pipelines

This is exactly why a smaller base image matters beyond raw megabytes: fewer installed packages means a smaller set of things that can have a CVE against them. A distroless or alpine image routinely reports far fewer vulnerabilities than a full ubuntu-based one, purely by having less installed in the first place.

Scan in CI, not just locally

Running a scan once on your laptop finds the CVEs present at that moment — but base images get new CVEs disclosed against them constantly. Real pipelines re-scan on every build and can be configured to fail a build outright on new critical/high findings, which you'll wire up as part of Week 17's CI pipeline.

4. Registries & Tagging Strategy

A registry stores and distributes images — Docker Hub is the default public one, but GitHub Container Registry (GHCR), Amazon ECR, and Google Artifact Registry are all common, especially for private images tied to a specific cloud or CI provider.

pushing to a registry
docker login ghcr.io -u your-username

docker build -t ghcr.io/your-org/my-app:1.4.0 .
docker push ghcr.io/your-org/my-app:1.4.0

docker pull ghcr.io/your-org/my-app:1.4.0   # anyone with access can now pull it
a real tagging strategy
my-app:1.4.0        # semantic version — immutable, this is what production pins to
my-app:1.4          # rolling "latest patch of 1.4" — convenient, still reasonably safe
my-app:sha-a1b2c3d  # tagged by Git commit SHA — perfect traceability, CI-friendly
my-app:latest       # the moving pointer — fine for local dev, never for production

A disciplined pipeline typically tags every built image with both the immutable Git SHA and the semantic version at release time — the SHA guarantees you can always trace an image back to the exact commit it was built from, which latest or even a version tag alone can't guarantee once a tag is accidentally reused.

Looking ahead

Every image you push in the next 12 weeks will be referenced by tag in a Kubernetes Deployment manifest — the tagging discipline you build here directly determines how safe and traceable your rollouts and rollbacks are once real workloads are running on a cluster.

5. Hands-on Exercise

Hands-on

Shrink one of your existing images by at least 5x, then push it to a real registry

Take an image from earlier weeks, rebuild it as a multi-stage, non-root, minimal-base image, measure the size difference, and publish it somewhere real.

Part 1 — Optimize:

  1. Pick your Week 2 (or Week 4) app's Dockerfile. Note its current image size with docker images.
  2. Rewrite it as a multi-stage build: one stage with the full toolchain to install dependencies/build, a second, minimal stage (alpine or distroless if your language supports it) that copies only what's needed to run.
  3. Add a non-root USER instruction to the final stage.
  4. Rebuild, compare the new size against the original, and confirm the app still runs and behaves identically.
Hint

If your app breaks under a non-root user with a "permission denied" error, it's usually trying to write to a directory the new user doesn't own — add a RUN chown -R appuser:appgroup /app before the USER instruction to fix it.

Part 2 — Scan and publish:

  1. Run a vulnerability scan (Docker Scout or Trivy) against both your old and new image, and compare the number of findings.
  2. Create a free account on a registry of your choice (Docker Hub or GHCR are the easiest to start with).
  3. Tag your optimized image with your registry username/org, a semantic version, and push it.
  4. From a different directory (simulating "someone else's machine"), run docker pull for the exact tag you pushed and confirm it runs correctly — this proves your image is genuinely portable, not just working by accident on your one machine.
Hint

If you're not ready to make the image public, both Docker Hub and GHCR support private repositories on free tiers — just remember docker login is required before pulling a private image too, even on the "different machine" you're testing with.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

What does COPY --from=builder actually do in a multi-stage Dockerfile?

It copies specific files or directories from an earlier build stage (named "builder") into the current stage, without pulling in anything else from that stage — the compiler, source code and build-time dependencies from the builder stage are discarded, keeping the final image small.

Q2

Why run a container process as a non-root user instead of the default root?

If an attacker manages to execute code inside the container, root privileges give them a much stronger starting position — closer to a full container escape or broader filesystem/capability access — than an unprivileged user would. It's a defense-in-depth measure that costs almost nothing to add.

Q3

Why does a smaller base image (alpine/distroless) tend to report fewer vulnerabilities in a scan?

Vulnerability scanners check installed packages against known-CVE databases — fewer installed packages means fewer things that can have a disclosed CVE against them in the first place. A minimal base image has a proportionally smaller attack surface simply by including less software.

Q4

Why tag an image with both a Git commit SHA and a semantic version, instead of just one?

The semantic version is human-friendly and communicates intent (a release), but tags can in theory be reused or reassigned. The commit SHA is a permanent, unambiguous link back to the exact source code the image was built from, giving you perfect traceability even if a version tag were ever misapplied.