The convenience of docker build hides an uncomfortable truth: an image is not just your application. It is a stack of layers containing a base OS, a package manager, a language runtime, system libraries, and every transitive dependency your code pulls in — and each of those is something an attacker can target once your container is running. Sysdig's Cloud-Native Security and Usage Report has repeatedly found that the large majority of production images carry at least one high- or critical-severity vulnerability, and most of that risk lives in packages the application never actually invokes. The Log4Shell disclosure (CVE-2021-44228) drove the point home: thousands of images shipped a vulnerable log4j-core jar their owners did not know was there. Securing a Docker image is mostly about shipping less and trusting less. Here is how to do both.
Start from the smallest base you can justify
The single highest-leverage decision in a Dockerfile is the FROM line. A full ubuntu or debian base carries a shell, a package manager, and dozens of utilities your compiled service never calls — all of it counting as attack surface and CVE exposure. Distroless and Wolfi-based images strip that down to a runtime and your app, which removes the majority of what a scanner would otherwise flag simply because the vulnerable packages are no longer present.
# Build stage: full toolchain
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server
# Runtime stage: nothing but the binary
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /app /app
USER nonroot
ENTRYPOINT ["/app"]
The final image has no shell, so even if an attacker achieves code execution they cannot drop into sh, run curl, or install tooling — the primitives most post-exploitation playbooks depend on are simply absent.
Never run as root
Docker containers run as root unless you say otherwise, which means the average image is one application bug away from a root process inside the container and a much shorter path to the host if any breakout primitive exists. Set an explicit non-root user and drop write access to the root filesystem:
FROM node:20-slim
RUN useradd --uid 10001 --create-home appuser
WORKDIR /home/appuser/app
COPY --chown=appuser:appuser . .
RUN npm ci --omit=dev
USER 10001
CMD ["node", "server.js"]
Do not rely on the orchestrator to override the user at deploy time — bake non-root into the image so the safe default travels with it.
Keep secrets out of the layers
Secrets leak into images through build args, copied .env files, and ENV values, and Docker layers are cacheable and extractable — a key written in an early layer and "deleted" later is still recoverable from the layer tarball with docker history or by unpacking the image. Use BuildKit secret mounts so credentials exist only during the build step that needs them:
# syntax=docker/dockerfile:1
FROM alpine:3.20
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci
Pair that with a .dockerignore that excludes .env, .git, id_rsa, and cloud credential files by default, the same way you protect a repository with .gitignore.
Pin what you pull
A mutable tag like node:20 can point at different bytes tomorrow than it does today, which is exactly the vector a compromised or hijacked upstream tag exploits. Pin base images by digest so what you build is reproducible and tamper-evident:
FROM node:20-slim@sha256:2b3c4d5e...
Then rebuild on a schedule — weekly, not only when your own code changes — so you inherit upstream security patches for the base OS and libraries even during quiet development periods.
Generate an SBOM at build time
Six months after you ship an image, "what is actually inside it?" should be a lookup, not a forensic investigation. Generate a Software Bill of Materials (CycloneDX or SPDX) as a build artifact for every image and store it alongside the image. When the next Log4Shell-class CVE lands, you answer "are we affected?" for your entire fleet in minutes instead of re-scanning running containers and guessing what got flattened during compilation.
Dockerfile hardening checklist
- Minimal or distroless base image, pinned by digest
- Multi-stage build so toolchains and build secrets never ship
- Explicit non-root
USER - No secrets in
ARG,ENV, or copied files — BuildKit secret mounts only .dockerignoreexcludes credentials and VCS metadata- SBOM generated and stored per build
- Image scanned in CI and re-scanned in the registry over time
- Image signed so provenance is verifiable
How Safeguard helps
Most of this checklist is only as good as the enforcement behind it, and manual review does not scale to every pull request. Safeguard's container security scanning inspects each image for vulnerable packages, embedded secrets, and misconfigurations, then gates your pipeline on the results so a regression cannot merge unnoticed. Because raw scans over-report, Griffin AI applies reachability analysis to separate the CVEs that sit in code your service actually executes from the noise, and opens an auto-fix pull request with the minimal version bump when a fixable, reachable issue appears. Safeguard also produces CycloneDX SBOMs directly from your build and pairs with software composition analysis so your image inventory and dependency risk live in one place. If you are comparing scanners, see how we stack up against Trivy and Aqua.
Ship leaner, safer images without hand-auditing every Dockerfile. Start free at Safeguard or explore the setup guides in the Safeguard docs.