Containerize the App
The plan starts with turning Snip’s source code into something shippable. That “something” is a container image — the immutable artifact every later stage moves around. This page builds it well, and every choice in the Dockerfile is justified by a manual step it removes or an attack it closes.
Snip is a small Go HTTP service. (Go keeps the example crisp — a single static binary, no runtime to ship — but the shape of this Dockerfile is the same in any compiled language, and the principles transfer to interpreted ones too.) It talks to Postgres for the durable mapping and Redis for the cache, and it reads every connection string from the environment.
Why containerize at all — the step it removes
Section titled “Why containerize at all — the step it removes”The manual step a container image removes is the oldest lie in software: “works on my machine.” Before images, shipping meant reproducing a runtime on a server by hand — the right language version, the right system libraries, the right environment, assembled from a wiki page and tribal memory. Drift between “my laptop” and “the server” was where mysterious prod-only bugs were born. An image freezes the entire userspace the app needs into one content-addressed artifact. The bytes that ran in CI are the bytes that run in prod. That’s the safety: the environment stops being a variable.
The multi-stage Dockerfile
Section titled “The multi-stage Dockerfile”The single most important technique is the multi-stage build: one stage with the full toolchain
compiles the app; a second, tiny stage carries only the finished binary. The build tools — compiler,
package caches, source — never ship. Here is Snip’s Dockerfile:
# ---- Stage 1: build ----------------------------------------------------# A full toolchain image. Big, but it never ships.FROM golang:1.22-bookworm AS build
WORKDIR /src
# Copy only the dependency manifests first, so this layer is cached# and only re-runs when dependencies actually change (see "layer order").COPY go.mod go.sum ./RUN go mod download
# Now the source. Changing source invalidates only from here down.COPY . .
# Build a static, stripped binary. CGO off => no libc dependency, so it# runs on a near-empty base. -ldflags strips debug info to shrink it.RUN CGO_ENABLED=0 GOOS=linux go build \ -ldflags="-s -w" \ -o /out/snip ./cmd/snip
# ---- Stage 2: runtime --------------------------------------------------# Distroless: no shell, no package manager, no OS utilities. Just enough# to run the binary. Smallest attack surface available short of scratch.FROM gcr.io/distroless/static-debian12:nonroot AS runtime
# Run as an unprivileged user (uid 65532, baked into the :nonroot tag).USER nonroot:nonroot
# Copy ONLY the compiled binary out of the build stage.COPY --from=build /out/snip /usr/local/bin/snip
# Document the port; config and secrets come from the environment at runtime.EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/snip"]That is a complete, production-shaped image, and the final artifact is typically only a few megabytes larger than the binary itself.
Why each choice — layer by layer
Section titled “Why each choice — layer by layer”Each line is a deliberate trade. Read them as what it buys and what it costs.
| Choice | What it buys | What it costs |
|---|---|---|
| Multi-stage | Toolchain never ships → tiny image, no compiler in prod | A slightly longer Dockerfile |
distroless:nonroot final | No shell/package manager → huge classes of exploits impossible | Harder to exec in for debugging |
USER nonroot | A container escape lands as an unprivileged user, not root | Must ensure the app needs no privileged ports/paths |
CGO_ENABLED=0, static | Runs on a near-empty base; no libc CVEs to inherit | Some libraries need cgo; not always possible |
-ldflags="-s -w" | Smaller binary, no debug symbols to leak | No symbols when debugging the binary |
| Manifests copied before source | Dependency layer is cached across most builds | None worth mentioning |
Layer order is a performance decision
Section titled “Layer order is a performance decision”Images are layers, and a layer is only rebuilt when its inputs change.
We copy go.mod/go.sum and run go mod download before copying the source, so the (slow) dependency
download is cached and reused on every build where dependencies didn’t change — which is almost every
build. Reverse the order and every one-line code change re-downloads the world. Same final image, but a
30-second CI build instead of a 5-minute one. (The full reasoning is in Images &
Layers.)
Non-root and distroless are the security pair
Section titled “Non-root and distroless are the security pair”The .dockerignore
Section titled “The .dockerignore”The build context — everything sent to the daemon when you run docker build — should contain only what
the build needs. A .dockerignore keeps secrets, git history, and local cruft out of the context, so
they can never accidentally end up in an image layer:
# Version control and CI.git.github
# Local dev artifacts that must never ship.env*.localnode_modules/tmp
# Build outputs and tests/bin/distcoverage.out
# Docs and editor noise*.md.vscode.ideaExcluding .git and .env here is a real security control: a stray .env copied into an image is one
of the most common ways credentials leak into a registry.
Config and secrets: 12-factor, from the environment
Section titled “Config and secrets: 12-factor, from the environment”Notice what the Dockerfile does not contain: no database password, no Redis URL, no ENV DATABASE_URL=.
That is deliberate. Following 12-factor config, everything that
varies between environments comes from the environment at runtime, not baked into the image:
# Provided by Kubernetes at runtime (ConfigMap for non-secret, Secret for secret).DATABASE_URL=postgres://snip@snip-db:5432/snip # connection targetREDIS_URL=redis://snip-cache:6379 # cache targetPORT=8080LOG_LEVEL=infoThe same image runs in staging and prod unchanged — only the injected env differs. This is what makes “promote the same artifact” (from Artifacts & Registries) possible: if config were baked in, staging and prod would need different images, and “it passed in staging” would prove nothing about the bytes in prod. Baking a secret into an image is doubly bad — it’s in every layer, visible to anyone who can pull the image, and it ships to every environment.
The healthcheck
Section titled “The healthcheck”Kubernetes needs to know whether a container is alive and ready to serve. Snip exposes a
/healthz endpoint that returns 200 only when it can reach Postgres. Because distroless has no shell
or curl, we don’t put a HEALTHCHECK in the Dockerfile; instead the orchestrator probes the endpoint
directly — the Kubernetes-native way, which we’ll wire up in the
observability page:
# (preview of the Deployment, page 5)readinessProbe: httpGet: { path: /healthz, port: 8080 } initialDelaySeconds: 3 periodSeconds: 5livenessProbe: httpGet: { path: /healthz, port: 8080 } periodSeconds: 10The distinction matters: the readiness probe gates traffic (don’t send requests until the app can serve them), while the liveness probe restarts a wedged container. Both ride on the reconciliation loop — Kubernetes continuously checks and acts.
The thread
Section titled “The thread”The manual step this page removes is assembling and trusting a runtime by hand on each server. The
image makes the environment a single, content-addressed, scanned, non-root artifact that is identical
everywhere. Production gets safer three ways at once: the same bytes run in test and prod, so testing
means something; the attack surface is minimized (no shell, no root, no extra packages); and no secrets
or local cruft can stow away, because the .dockerignore and env-based config keep them out. The cost is
real — distroless images are harder to poke at when debugging — but it’s a price paid in convenience, not
in safety, and that’s the right direction.
We have an artifact. Now we need a machine that builds it, proves it, scans it, and signs it on every commit, refusing to proceed when anything fails: The CI Pipeline.
Check your understanding
Section titled “Check your understanding”- Explain how a multi-stage build produces a small and secure final image. What specifically is in the build stage that you never want shipped to production?
- Two security choices appear together:
USER nonrootand a distroless base. What does each one stop on its own, and why are they stronger together? - Why are
go.mod/go.sumcopied and downloaded before the application source? What changes in CI build time if you reverse that order, and why? - Snip’s Dockerfile contains no
DATABASE_URL. Where does it come from, and how does that decision make “promote the same artifact” actually mean something? - Name two things the
.dockerignorekeeps out of the image and explain the concrete risk of each if it slipped into a layer.
Show answers
- The build stage has the full compiler toolchain, package caches, and the entire source tree; the runtime stage copies out only the finished binary. So the final image carries no compiler, no source, and no package manager — it’s small (just the binary on a near-empty base) and has almost no attack surface, while still having been built from full tooling.
USER nonrootmeans a container escape lands as an unprivileged user, not root on the host. A distroless base means there’s no shell,curl, or package manager for an attacker to use even with code execution. Together: an attacker who gets in is both unprivileged and toolless — standing in an empty room.- Layers are cached by their inputs. Copying dependency manifests and running
go mod downloadfirst puts the slow dependency step in a layer that only changes when dependencies change. Reverse it and every one-line code edit invalidates the dependency layer, re-downloading everything — turning a ~30s build into minutes, for the same final image. - It’s injected from the environment at runtime (a Kubernetes ConfigMap/Secret), per 12-factor config. Because config isn’t baked in, the identical image runs in staging and prod, so “it passed in staging” is a real statement about the exact bytes going to prod.
- Examples:
.env(would leak credentials into a layer that anyone who can pull the image can read, shipped to every environment) and.git(would bloat the image and could expose history/secrets in commit objects). Either turns a private file into something baked into a distributed artifact.