The most secure code is the code that is not in your image. Build minimal, non-root images with multi-stage builds and distroless bases, keep secrets out of layers, and pin your inputs.
Every binary and library in your image is potential vulnerability and potential attacker tooling. A full OS base ships hundreds of packages you never use; a minimal or distroless base ships almost none. Distroless images contain your app and its runtime but no shell and no package manager — which also means an attacker who lands code has nothing to pivot with.
Same app, three bases:
ubuntu:latest ~ 400+ packages, a shell, apt -> big CVE surface
alpine:3.20 ~ tiny, musl, has /bin/sh -> much smaller
distroless/base ~ app + libc, NO shell, NO apt -> smallest, safest
No shell means 'kubectl exec' won't give an attacker bash, and a
web-shell payload has nothing to spawn. Pick the smallest base your
app actually needs.Build tools, compilers, and dev dependencies are needed to build the app but must never ship with it — they are pure attack surface in production. A multi-stage build compiles in a fat builder stage and copies only the finished artifact into a minimal final stage. The result is small, has no build tooling, and runs as a non-root user.
# ---- build stage: has the full toolchain ----
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
# ---- final stage: minimal, no toolchain, non-root ----
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER nonroot # distroless ships a nonroot user
EXPOSE 3000
CMD ["dist/server.js"]Two image mistakes cause outsized damage. Running as root (the default) means any escape is root-on-host — so create and switch to an unprivileged user. And a secret copied into any layer is permanent: even if a later layer deletes it, it remains in history and in the pushed image. Inject secrets at runtime instead, and keep build context lean with .dockerignore.
# add a non-root user if your base doesn't provide one
RUN addgroup -S app && adduser -S -G app app
USER app
# SECRETS: never COPY .env or keys into the image. They persist in
# layer history even after a later 'rm'. Use build secrets / runtime env:
# docker build --secret id=npmrc,src=$HOME/.npmrc ... (not COPY)
# at runtime: inject via env / mounted secret, never in the Dockerfile
# .dockerignore keeps secrets + junk OUT of the build context entirely:
# .git
# .env
# node_modules
# **/*.pemA tag like node:20 is mutable — the image behind it changes, which is both a reproducibility and a supply-chain problem (covered in the Supply Chain course). Pin the base by immutable digest so every build uses the exact reviewed image. Small hardening touches — a healthcheck, dropping setuid bits — round out a production-grade image.
# pin the base image by digest, not a moving tag
FROM gcr.io/distroless/nodejs20-debian12@sha256:2f7c... # exact bytes
# a healthcheck lets the orchestrator detect a wedged container
HEALTHCHECK --interval=30s --timeout=3s \
CMD ["/nodejs/bin/node", "healthcheck.js"]
# verify what you built — no root, minimal packages:
# docker run --rm app id -> should NOT be uid=0
# dive app / syft app -> inspect layers + contents