Most Dockerfiles in production were written to make the build work, not to make the image secure, and the two goals only overlap by accident. Secure Docker images start from a minimal, pinned base image, run as a non-root user, exclude secrets and build tooling from the final layer, and get scanned before they're allowed to push to a registry. None of that is exotic — it's a checklist most teams can apply to an existing Dockerfile in an afternoon, and each item closes a specific, well-documented class of container risk.
What base image should you actually start from?
Start from the smallest base image that still runs your application, because every package the base image includes is a package you inherit the CVE exposure for, whether your code uses it or not. A full ubuntu:22.04 or debian:bookworm image ships a shell, a package manager, and dozens of utility packages your application will never call. Distroless images (gcr.io/distroless/*) or Alpine-based images cut that surface down to close to nothing beyond your runtime. Concretely:
- Prefer distroless or Alpine bases over full OS images unless you have a specific reason (like needing a shell for debugging) to keep more.
- Pin the base image by digest, not a mutable tag —
FROM node@sha256:...instead ofFROM node:20, so an upstream tag update can't silently change what ships. - Rebuild on a schedule even when your own code hasn't changed, so you inherit upstream security patches rather than freezing on a stale, unpatched layer.
Why does running as root inside the container matter?
Running as root inside a container means a successful application-level exploit — a remote code execution bug, a deserialization flaw, anything — gives the attacker root inside that container by default, which materially raises the blast radius of any container escape vulnerability. Add a dedicated non-root user in the Dockerfile and switch to it before the final CMD or ENTRYPOINT:
- Create a user and group explicitly (
RUN adduser -D appuser) rather than relying on a base image that may or may not default to non-root. - Set
USER appuserbefore the entrypoint, and verify withdocker run <image> whoamithat it actually took effect. - Pair this with a read-only root filesystem (
--read-onlyat runtime) where the application doesn't need to write outside a mounted volume.
How do secrets end up baked into image layers?
Secrets leak into images through build args, COPY'd config files, or ENV values, and because Docker layers are individually cacheable and extractable, a secret written in an early layer and "deleted" in a later one is still recoverable from the layer tarball itself. docker history and inspecting exported layer archives are enough to pull a credential that a later RUN rm command only removed from the final visible filesystem, not from the layer history. Fix it structurally rather than trying to remember not to do it:
- Use BuildKit's
--mount=type=secretso credentials are available at build time but never written into a layer. - Use multi-stage builds so the build toolchain and any temporary credentials live in a discarded intermediate stage, and only the compiled artifact ships in the final image.
- Add a
.dockerignoreexcluding.env,.git, and credential files, the same way you'd add them to.gitignore— aCOPY . .without one will happily copy your local.envfile into the image.
What should the multi-stage build actually separate?
A multi-stage build should separate "everything needed to build the artifact" from "everything needed to run it," because the build stage typically needs compilers, package managers, and dev dependencies that have no business existing in a production image. A Node.js image that ships npm, dev dependencies, and TypeScript source alongside compiled output is carrying attack surface and disclosure risk (source maps, internal comments) it doesn't need. The final stage should COPY --from=builder only the compiled output and runtime dependencies, nothing else.
Why does image scanning have to run before the push, not after?
Scanning after an image is already pushed to a registry and pulled into a deployment means the vulnerability has already shipped — the fix at that point is a hotfix and redeploy instead of a blocked pull request. Gating the CI pipeline on scan results, failing builds with new critical or high-severity findings that have an available fix, keeps the cost of a vulnerable dependency at "one-line version bump" instead of "incident response." This is also where reachability matters: a full-image scan of even a minimal base can return dozens of findings, and most of them sit in code paths the application never actually executes, so prioritizing by what's reachable and exposed keeps the signal usable. Safeguard's SCA and SAST/DAST products both plug into this stage so scanning happens automatically as part of the build rather than as a manual step someone has to remember to run.
FAQ
Does Alpine solve all container security problems by itself?
No — it shrinks attack surface by removing unnecessary packages, but it doesn't scan for CVEs in what remains, doesn't manage secrets, and doesn't enforce non-root execution. It's one item on the checklist, not the whole checklist.
How often should base images be rebuilt?
Weekly is a reasonable default for most teams, independent of whether application code changed, since base image maintainers ship security patches on their own schedule and a stale image misses them.
Is .dockerignore really necessary if the Dockerfile only copies specific files?
Yes, because COPY . . patterns are common and easy to reintroduce during refactors, and a .dockerignore is a durable guardrail that doesn't rely on every future contributor remembering to be careful.
What's the fastest way to check if an existing image is already vulnerable?
Run a container scanner (Trivy, Grype, Docker Scout, or Safeguard's own scanning) against the built image tag. It will enumerate known CVEs by package and severity within seconds for most images.