Section 2 · Production-Grade Images
Scanning Images and Keeping Secrets Out
20 min4 min readSpot the bug
Lesson at a glance
Scan notes-api images for known vulnerabilities with Docker Scout, triage what you find, and use BuildKit secret mounts so tokens needed at build time never land in an image layer.
- 1Scan before you ship
- 2Triage in a sensible order
- 3How secrets leak into images
- 4BuildKit secret mounts
- 5Runtime secrets are not the image’s job
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
The worst incident on my record was not a crash. A build step needed a token for a private npm registry, so someone passed it as a build argument. The image went to a registry with broad read access, and months later a routine audit found the token sitting in the image metadata, valid the whole time. This lesson is about what’s inside your images, the parts you didn’t write and the parts you didn’t mean to include.
Scan before you ship
Every package in your image, from Debian’s libssl to npm’s express, has a public history of vulnerabilities (CVEs). A scanner reads the image’s package inventory and matches it against those databases. Docker Scout is built into the Docker CLI:
docker scout quickview notes-api:1.4.0
docker scout cves --only-severity critical,high notes-api:1.4.0
docker scout recommendations notes-api:1.4.0quickview gives a one-screen summary: vulnerability counts for your image, for its base image, and for a newer version of that base if one exists. cves lists each finding with the package, the installed version and the version that fixes it. recommendations suggests base image tags with fewer known vulnerabilities. Trivy and Grype are popular open-source scanners that do the same job; pick one and use it everywhere.
Triage in a sensible order
A long list of findings is not a to-do list. Work through it like this:
- Base image findings first. If most findings come from OS packages, update to a freshly patched base or move to a smaller one. The switch to distroless in lesson 2.2 removes most of these in one step.
- Fixable app dependencies next. If a fixed version exists, bump it in
package.json, runnpm ci, rebuild. - Unfixable findings get a decision, not silence. Write down why the code path isn’t reachable, or what mitigates it, and set a date to look again.
Then make it a gate so regressions can’t sneak in:
docker scout cves --only-severity critical,high --exit-code notes-api:1.4.0With --exit-code, the command fails when it finds vulnerabilities at those severities, so your CI job fails before the push. While you’re in CI, ask the builder for supply-chain metadata too: docker buildx build --sbom=true --provenance=mode=max … attaches a software bill of materials and build provenance to the image, which makes the next audit a lookup instead of an investigation.
How secrets leak into images
Images remember more than you’d think. Three common leaks:
ENV API_KEY=…is stored in the image config and set in every container.docker image inspectshows it.ARG NPM_TOKENused in aRUNstep is recorded in the image’s build history.docker history --no-trunccan reveal it.- A file written and later deleted (
COPY .npmrcthenRUN rm .npmrc) still exists in the earlier layer. Anyone with the image can extract that layer.
Docker’s build checks warn about the first two (SecretsUsedInArgOrEnv) when the variable name looks like a secret. Take the warning seriously.
BuildKit secret mounts
The right tool is a secret mount: the secret is made available to a single RUN step as a file (or an environment variable) and never written to a layer. notes-api’s build needs a token for a private package, so:
FROM node:24-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
--mount=type=cache,target=/root/.npm \
npm cidocker build --secret id=npmrc,src=$HOME/.npmrc -t notes-api:1.4.0 .During that RUN, npm finds its config at /root/.npmrc. Before and after, the file doesn’t exist, and nothing about it is stored in the image or the build cache. Without a target, secrets are mounted at /run/secrets/<id>. If a tool reads a token from an environment variable instead, mount it as one with RUN --mount=type=secret,id=npm_token,env=NPM_TOKEN npm ci and pass --secret id=npm_token,env=NPM_TOKEN on the command line, which reads the value from your shell’s own NPM_TOKEN.
Runtime secrets are not the image’s job
The database password notes-api uses in production has no business in the image at all. It arrives when the container starts: from an environment variable in Compose, from a Kubernetes Secret, or from your cloud’s secret manager. You’ll wire up both of the first two in the next sections. The image stays identical across environments; only the injected configuration changes.
Section 2 is done: your image is lean, unprivileged, traceable and clean. Next you’ll give it a database to talk to, with Docker Compose.
Key takeaways
docker scout cveslists known CVEs per package;--only-severity critical,high --exit-codeturns it into a CI gate.- Fix base-image findings first by updating or switching the base;
docker scout recommendationssuggests candidates. - Anything passed with
ARGorENV, or written to a file in aRUNstep, is recoverable from the image, even if a later step deletes it. RUN --mount=type=secretexposes a secret to one build step without writing it into any layer.- Runtime secrets such as database passwords are injected when the container starts, never built into the image.