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:
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go build -o server .
CMD ["./server"]
# Final image size: ~900MB — includes the ENTIRE Go toolchain, unnecessarily
# 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.
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.
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:
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"]
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:
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.
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.
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
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.
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
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:
- Pick your Week 2 (or Week 4) app's Dockerfile. Note its current image size with
docker images. - 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.
- Add a non-root
USERinstruction to the final stage. - Rebuild, compare the new size against the original, and confirm the app still runs and behaves identically.
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:
- Run a vulnerability scan (Docker Scout or Trivy) against both your old and new image, and compare the number of findings.
- Create a free account on a registry of your choice (Docker Hub or GHCR are the easiest to start with).
- Tag your optimized image with your registry username/org, a semantic version, and push it.
- From a different directory (simulating "someone else's machine"), run
docker pullfor 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.
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?
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?
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?
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?
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.