Skip to main content
CodeOath
← All posts

Docker105 min total · 19 parts

Docker Fundamentals: Images, Containers, and Writing a Good Dockerfile

Part 16 of 19 · ~3 min

Security Basics

Before Berrywell goes live, Priya runs the team through what she calls the pre-launch pass — a handful of checks that, in her experience, are exactly what a reviewer at a company with an actual security team would flag if any of them were missing.

Run as a non-root user. Nothing about being inside a container demotes root to an ordinary user — it's still root, with all the same privilege that implies, unless something explicitly tells the container to run as someone else. So the question worth asking is what happens if a process managed to break out of its container entirely: an escape from a root process hands the attacker root on the host, while an escape from an unprivileged one hands them a great deal less. On node:20-alpine, the snippet looks like this:

RUN addgroup -S app && adduser -S app -G app
USER app

Worth flagging for whoever copies this: addgroup -S and adduser -S are BusyBox syntax, specific to Alpine's minimal shell tools. Point the exact same lines at node:20-slim (Debian-based, and a perfectly legitimate choice from the base-image table) and they fail outright, because Debian's adduser/addgroup take a different set of flags entirely. Whichever base image you actually land on, check that its non-root snippet matches — don't assume one distro's version is universal.

Stop tagging anything latest. That tag is a moving target by definition — whatever node:latest resolves to this month is not a promise about what it resolves to next month, and a rebuild months apart can pull in a different image entirely without a single character of your own Dockerfile changing. Pinning to a specific digest (node:20-alpine@sha256:...) is the only version of "pin the base image" that actually guarantees the same bytes every time.

Have something scan for known CVEs, and actually wire it into CI. Trivy and Docker Scout both do the same basic job — check every package an image has installed against a vulnerability database and fail loudly when something matches. It's not a hypothetical safeguard: a week before Berrywell's launch, exactly this catches a HIGH-severity CVE sitting in a transitive OS package worker's base image happened to pull in, something none of the three of you had ever heard of and had no reason to go looking for by hand. The fix ends up being a one-line bump to a newer base image tag that already ships the patch. Nobody finds that kind of thing by reading changelogs; the scan is the only reason it gets found before a customer's server does.

No secret ever belongs in a layer, full stop. This is where the two near-misses from earlier chapters get their explicit payoff: the --build-arg Marcus almost used for a Stripe key, and the .env file that nearly rode along in a COPY . .. Whether a value lands there via ENV, or gets dropped into a file that a later instruction supposedly "cleans up," makes no difference — either way it's sitting permanently in the image's history, readable by anyone with a pull and enough patience to inspect it. What that leaves you with is: hand it to the container at runtime, keep it in a proper secrets manager, or if a build step genuinely must see it, --secret and nothing else.

Default to the smallest base image that actually works — Alpine, slim, or distroless — because every package that isn't installed is one fewer thing anybody ever has to patch. Just remember the Berrywell incident before assuming that swap costs nothing.