Safeguard
Container Security

10 Docker image security best practices

Ten concrete Docker image security practices — minimal base images, secret handling, reachability-based scanning, non-root runtimes, and SBOMs — with real CVEs and data.

Michael
Cloud Security Architect
Updated 10 min read

Docker images ship more than your application code — they ship every base OS package, language runtime, and transitive dependency baked into each layer, and any one of them can be the entry point for an attacker. Sysdig's 2023 Cloud-Native Security and Usage Report found that 87% of container images running in production contain at least one high- or critical-severity vulnerability, and most of those flaws sit in packages the application never actually calls. Runc's CVE-2019-5736 let a malicious container overwrite the host runc binary and escape to the host. CVE-2021-41092 exposed Docker Desktop APIs without authentication. Log4Shell (CVE-2021-44228) rode into production inside thousands of container images that bundled a vulnerable log4j-core jar most teams didn't know they had. None of these required exotic tradecraft — they required an image nobody had properly inventoried, scanned, or hardened. Below are ten concrete practices, grouped into the questions engineers actually ask, for closing that gap before an image ever reaches a registry.

A quick grounding on docker architecture explains why this list works the way it does. What Docker is used for, at its core — the short version of what "docker" means as a technology — is packaging an application together with its filesystem, dependencies, and runtime into layered, immutable images that run identically across environments. That layered architecture of Docker is exactly why one bad layer can compromise an otherwise-hardened image, and the same isolation and reproducibility that explain why Docker is used for production workloads in the first place are why Docker image hygiene has to be deliberate rather than assumed.

What base image should you start from to shrink your attack surface?

Start from a minimal or distroless base image, because every package you don't include is a package you never have to patch. A default ubuntu:22.04 image — the docker Ubuntu image most teams reach for first — carries roughly 80-100 packages and a full shell, package manager, and utilities — none of which your application needs at runtime but all of which count as attack surface and CVE exposure. Google's distroless images and Chainguard's wolfi-based images strip that down to libc, your runtime, and your app, cutting the median CVE count per image by 60-95% depending on the language. This is also where Docker's isolation model matters: containers share the host kernel, so Docker isolation is process- and namespace-level, not a hard security boundary like a VM, which is exactly why a bloated base image widens the blast radius of any escape. Concretely:

  • Prefer gcr.io/distroless/*, cgr.dev/chainguard/*, or Alpine-based images over full OS bases.
  • Pin base images by digest (FROM node@sha256:1a2b3c...) instead of a mutable tag like node:20, so a compromised or silently updated upstream tag can't change what you ship.
  • Rebuild on a schedule (weekly, not just on dependency bumps) so you inherit upstream security patches even when your own code hasn't changed — Alpine and Debian both ship security patches multiple times a month.

The same minimal-base logic carries across ecosystems, just with different mechanics. A Go docker image can often skip a runtime entirely — since Go compiles to a static binary, the final stage can be FROM scratch or distroless with nothing else installed. A docker npm build should multi-stage away devDependencies, the npm client itself, and any build tooling from the final runtime layer, shipping only the compiled output and production dependencies. PHP docker images (including docker Laravel deployments) are the hardest to shrink since the PHP interpreter and its extensions are runtime dependencies, not build-time ones — the best you can usually do is a slim, non-Alpine-if-extension-support-matters base with only the required PHP extensions compiled in.

How do secrets end up baked into image layers, and how do you stop it?

Secrets leak into images when they're passed as build args, COPY'd files, or ENV values, because Docker layers are cacheable and extractable even after later layers "delete" the file. Running docker history or docker save | tar -xO on an image can recover an API key that was written in layer 3 and removed in layer 7 — the bytes are still sitting in the layer tarball. This is not theoretical: GitGuardian's 2024 State of Secrets Sprawl report logged over 12.8 million secrets exposed on public GitHub in 2023 alone, and container images pushed to public registries are a well-documented subset of that exposure. Fix it structurally:

  • Use BuildKit's --mount=type=secret flag so credentials are available during the build but never written to a layer.
  • Use multi-stage builds so build-time secrets and toolchains stay in an intermediate stage that's discarded, and only the final artifact ships in the runtime stage.
  • Add a .dockerignore — a docker ignore file — that excludes .env, .git, id_rsa, and cloud credential files by default — the same way you'd add them to .gitignore.
  • Scan built images for embedded secrets (not just source repos) as part of CI, since secrets can enter through cached layers or vendored dependencies, not just your own COPY commands.

Why should you scan images before they merge, not after they deploy?

Scanning pre-merge catches the vulnerability while it's a one-line diff instead of a production incident, because the cost of fixing a dependency multiplies at every stage it survives. IBM's Cost of a Data Breach research has consistently shown breaches identified and contained faster cost millions less than those caught after the fact, and the same economics apply to a vulnerable image: a CVE caught in a pull request is a version bump; the same CVE caught by a runtime alert three weeks after deployment is an incident review, a hotfix deploy, and possibly a customer notification. Practically, that means:

  • Gate CI/CD pipelines on image scan results, not just unit tests — fail the build on new critical vulnerabilities with a known fix available.
  • Scan on every build, not on a weekly cron job, since a base image that was clean on Monday can have a new critical CVE disclosed by Wednesday.
  • Re-scan images already sitting in your registry, not just new builds — a six-month-old "approved" image is exactly the kind of thing that quietly accumulates newly disclosed CVEs nobody re-checks.

Whether you run a Snyk docker image scan, use Snyk to scan docker image layers specifically, or rely on another scanner entirely, the mechanics of securing docker images are the same: catch the CVE before merge, not after deploy. These ten practices are also a practical, working implementation of the OWASP best practices for container and dependency security — OWASP doesn't mandate a specific tool, just the outcomes (minimal surface, verified provenance, continuous scanning) this list covers.

Do all the vulnerabilities a scanner reports actually matter?

No — most flagged vulnerabilities are in code paths your application never executes, which is why reachability analysis matters more than raw CVE counts. A typical container image scan for a mid-sized Node.js or Java service returns 150-400 findings; industry data from vendors including Endor Labs and Chainguard consistently shows that somewhere between 60-90% of those findings are in functions or packages that are never called at runtime. Without prioritization, teams either burn weeks patching irrelevant transitive dependencies or, more commonly, tune out the alerts entirely and miss the handful that are genuinely exploitable. Effective triage looks at:

  • Whether the vulnerable function is actually reachable from your application's call graph, not just present in the dependency tree.
  • Whether the vulnerable package is present in the final image at all — many CVEs live in build-time-only dependencies that never ship.
  • Exploit maturity (is there a public PoC or is it actively exploited, per CISA's KEV catalog) combined with reachability, rather than CVSS score alone, which measures theoretical severity, not actual exposure.

Should your container ever run as root?

No — a container process should run as an unprivileged, non-root user, because a root process inside a container that's compromised via an application vulnerability (deserialization bug, path traversal, arbitrary file write) gets root on the container and, if any breakout primitive exists, a much shorter path to the host. Docker containers default to running as root unless you explicitly set otherwise, which means most images are one privilege-escalation bug away from a bad day. This applies whether you're running plain Docker or a Kubernetes Docker image in a cluster — the pod spec inherits whatever the image declares. Harden the runtime posture with:

  • USER 1000:1000 (or a named non-root user) set explicitly in the Dockerfile — don't rely on the orchestrator to override it.
  • --read-only root filesystems in Kubernetes (readOnlyRootFilesystem: true) or Docker run, with explicit writable volumes only where the app genuinely needs to write.
  • Dropping all Linux capabilities and adding back only what's required (--cap-drop=ALL --cap-add=NET_BIND_SERVICE), rather than shipping with the default capability set.
  • Disallowing privilege escalation (--security-opt=no-new-privileges) so a setuid binary can't hand a process more power than it started with.

How do you know what's actually inside an image six months after you shipped it?

You generate a Software Bill of Materials (SBOM) at build time and keep it, because without one you're reconstructing an image's contents after the fact — usually during an incident, under time pressure, using tools that weren't built for forensics. Executive Order 14028 and the resulting NTIA minimum-elements guidance pushed SBOMs from a nice-to-have to a procurement requirement for U.S. federal software vendors starting in 2021, and the practice has since become standard due diligence for any vendor selling into regulated industries. A usable SBOM program means:

  • Generating an SBOM (CycloneDX or SPDX format) as a CI build artifact for every image, not retroactively scanning a running container and hoping the metadata lines up.
  • Storing SBOMs alongside the image (as an OCI artifact or in a dedicated store) so "what was in the image we shipped on March 3rd" is a lookup, not an investigation.
  • Diffing SBOMs between versions so a dependency bump that silently pulls in three new transitive packages doesn't go unnoticed.
  • Signing images and their provenance (Sigstore/cosign, in-toto attestations) so you can prove an image was built by your pipeline from a known commit, not tampered with in the registry, regardless of which docker image format or registry you standardize on.
  • Setting a LABEL on every image (source commit, build date, maintainer) — a docker label costs one line in the Dockerfile and turns "what shipped this" from a registry archaeology project into a docker inspect call.
  • Treating "rebuild docker image" as a scheduled pipeline job, not a manual one-off — an SBOM and signature are only trustworthy if they're regenerated every time the image is rebuilt.

How Safeguard Helps

Safeguard turns these ten practices from a manual checklist into a pipeline that runs on every build. Griffin AI ingests scan results from your existing tools or Safeguard's own scanner and runs reachability analysis against your actual call graph, so instead of triaging 300 CVEs you get the dozen that are exploitable from code your service actually executes. Safeguard generates CycloneDX SBOMs automatically at build time — or ingests SBOMs you already produce — and diffs them release over release so drift in base images and transitive dependencies is visible before it ships. When a fixable vulnerability is confirmed reachable, Safeguard opens an auto-fix pull request with the minimum version bump needed, so remediation is a one-click merge instead of a ticket that sits in a backlog for a quarter. The result is fewer false positives, faster patch cycles, and an audit trail that shows exactly what shipped and why it was safe to ship.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.