Safeguard
Guides

How to Harden a Dockerfile in 10 Practical Steps

Ten concrete Dockerfile changes — digest pinning, multi-stage builds, non-root users, BuildKit secrets, SBOM attestations — that remove whole classes of container risk.

Aisha Bello
Application Security Engineer
6 min read

Hardening a Dockerfile comes down to three moves — shrink what ships, pin what you pull, and drop what the process does not need — and you can get most of the benefit with ten mechanical changes that take under an hour on a typical service. None of this requires a platform migration. It is all edits to one file plus two flags on docker build. Here are the ten steps, in the order I apply them during reviews.

Steps 1–3: control the base image

1. Pin the base image by digest, not tag. FROM node:22-slim means "whatever that tag points at today." Tags move; digests do not:

FROM node:22-slim@sha256:2f0b0c1a8e7c4a9d... AS build

Yes, the digest is ugly. Renovate and Dependabot will both keep it current with readable PRs, so the ugliness costs you nothing after setup.

2. Pick the smallest base that runs your artifact. Every package in the image is attack surface and scanner noise. For compiled languages, gcr.io/distroless/static or a Chainguard image gets you to a handful of components. For Node and Python, the -slim variants cut hundreds of Debian packages versus the full tag. Alpine is small too, but musl has bitten enough teams (DNS behavior, native wheel availability) that I only recommend it when you have tested against it specifically.

3. Update packages in the same layer you install them. If you must apt-get install, do it as one RUN with cleanup, so stale package lists never get baked into a layer:

RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
    && rm -rf /var/lib/apt/lists/*

Steps 4–5: multi-stage builds and non-root

4. Build in one stage, ship another. Compilers, dev dependencies, and your .git directory have no business in production layers:

FROM golang:1.24@sha256:... AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/app

FROM gcr.io/distroless/static@sha256:...
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]

A team I worked with cut their image from 1.1 GB to 18 MB this way, and their Grype finding count went from 213 to 4. Same application.

5. Run as a non-root user. Container escape techniques almost all assume UID 0 inside the container. Distroless images ship a nonroot user; elsewhere, create one:

RUN useradd --uid 10001 --no-create-home appuser
USER 10001

Use the numeric UID in USER — Kubernetes runAsNonRoot checks can only verify numeric IDs.

Steps 6–7: keep secrets and junk out of layers

6. Never pass secrets through ARG, ENV, or COPY. All three persist in image history — docker history --no-trunc reads them right back. BuildKit secret mounts exist for exactly this:

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
docker build --secret id=npmrc,src=$HOME/.npmrc .

The secret is available during that single RUN and never written to a layer.

7. Write a real .dockerignore. At minimum: .git, .env*, node_modules, *.pem, terraform state, and your CI config. COPY . . copies everything the ignore file does not exclude, and I have found long-lived AWS keys in image layers more than once because a .env slipped through.

Steps 8–9: make the build attest to itself

8. Emit SBOM and provenance attestations at build time. BuildKit will attach both with two flags:

docker build --sbom=true --provenance=mode=max -t registry.example.com/app:1.4.2 .

That gives every image a CycloneDX/SPDX inventory and a SLSA provenance statement describing which commit and builder produced it. Downstream, admission controllers and SBOM tooling can consume these instead of re-deriving the inventory by scanning layers — the attested version is both faster and closer to ground truth.

9. Add health and metadata instructions. HEALTHCHECK is a hardening step people forget: an orchestrator that can tell "up" from "wedged" restarts compromised-or-crashed processes faster. And OCI labels (org.opencontainers.image.source, .revision) make incident response dramatically less archaeological.

Step 10: lint and scan on every build

10. Gate the Dockerfile and the image in CI. Hadolint catches Dockerfile antipatterns statically; a scanner checks the result:

hadolint Dockerfile
grype registry.example.com/app:1.4.2 --fail-on high

Wire both into the pipeline, not a cron job. Findings attached to the PR that introduced them get fixed; findings in a weekly report get triaged into oblivion. If you want reachability and fix suggestions layered on top of raw CVE matching, that is the job of a proper SCA platform — Safeguard's container scanning consumes the same SBOM attestations you generated in step 8. For the runtime side of this story (seccomp, registries, containerd config), see our containerd security configuration guide.

The checklist

#StepEffort
1Pin base image by digest5 min
2Smallest viable base image30 min
3Install + clean in one layer5 min
4Multi-stage build30 min
5Non-root numeric USER10 min
6BuildKit secret mounts15 min
7Real .dockerignore10 min
8--sbom and --provenance flags5 min
9HEALTHCHECK + OCI labels10 min
10hadolint + scanner in CI20 min

Frequently asked questions

Which step matters most if I can only do one?

The multi-stage build (step 4). It removes the most attack surface per minute of effort — build toolchains, dev dependencies, and shell utilities all vanish from the shipped image, and every downstream scan gets quieter.

Is Alpine safer than Debian slim?

Not inherently. It is smaller, which reduces surface, but musl-related edge cases and delayed advisory metadata cut the other way. Distroless or Chainguard images beat both when your runtime allows it.

Do BuildKit SBOM attestations replace a dedicated SBOM tool?

They are an excellent source of truth for image contents, but they only cover what the build saw. Correlating those SBOMs across hundreds of services, tracking drift, and answering "where do we ship xz 5.6.0" is a management problem, not a generation problem.

Should the image scan block deploys?

Block on fixable critical findings in the final image, and alert on the rest. Blocking on unfixable base-image CVEs teaches everyone to click override, which is worse than not blocking at all.

Never miss an update

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