Docker Image Hardening Checklist: 8 Steps With Code
Published July 27, 2026 · 10 min read
A hardened Docker image is not one specific setting — it is a set of defaults that together limit what an attacker can do if something inside the container is ever compromised. This checklist covers eight concrete steps, each with the exact Dockerfile or manifest snippet to apply it.

Hardening versus patching
Patching and scanning close known vulnerabilities; hardening assumes one will eventually slip through anyway and limits the blast radius when it does. Both matter, and neither substitutes for the other — the steps below are the hardening half of that pair.
Step 1: Run as a non-root user
The default root user inside a container can write to system paths, bind privileged ports, and generally has far more reach than any application process needs:
FROM node:22-slim RUN groupadd -r app && useradd -r -g app -u 10001 app WORKDIR /app COPY --chown=app:app . . USER app CMD ["node", "server.js"]
Step 2: Use the smallest viable base image
Fewer installed packages means fewer things that can be exploited and fewer findings in a scan:
# Instead of the full distribution FROM python:3.13 # Prefer slim, or distroless where feasible FROM python:3.13-slim # or FROM gcr.io/distroless/python3-debian12
See our Alpine vs Debian vs Distroless comparison for how to choose between the tiers.
Step 3: Enforce a read-only root filesystem
Blocking writes to the container filesystem stops an attacker from dropping a persistent payload, and forces you to be explicit about the paths that genuinely need to be writable:
# Plain Docker
docker run --read-only --tmpfs /tmp -d myapp:hardened
# Kubernetes
securityContext:
readOnlyRootFilesystem: true
volumes:
- name: tmp
emptyDir: {}
volumeMounts:
- name: tmp
mountPath: /tmpStep 4: Drop all Linux capabilities by default
Most application containers need none of the default capability set. Drop everything and add back only what is proven necessary:
# Kubernetes
securityContext:
capabilities:
drop: ["ALL"]
allowPrivilegeEscalation: false
runAsNonRoot: true
# Plain Docker
docker run --cap-drop=ALL --security-opt=no-new-privileges -d myapp:hardenedStep 5: Pin versions and exclude what you don't need
Unpinned base tags and default package-manager behavior both quietly widen the surface. Pin the base by digest, and disable recommended-package installs:
FROM debian:12-slim@sha256:9b2e7a3c...
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*Step 6: Never bake secrets into layers
Secrets in ENV, ARG, or a copied .env file remain readable in the image history forever, even if a later layer deletes the file. Use a BuildKit secret mount instead:
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=db_password \
DB_PASSWORD=$(cat /run/secrets/db_password) ./run-migrations.sh
# docker build --secret id=db_password,src=./db_password.txt -t myapp .Step 7: Add a HEALTHCHECK the orchestrator can act on
A health check does not close a vulnerability, but it bounds how long a compromised or crash-looping container stays in rotation before it is restarted:
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD ["node", "healthcheck.js"]
Write the check as an exec-form command using your application's own runtime so it still works if you later migrate to a shell-less base image such as distroless.
Step 8: Gate every build on a vulnerability scan
Hardening settings do not catch known-vulnerable packages; a scan does. Fail the build when critical findings appear:
curl -fsSL https://scanrook.io/install.sh | sh docker save myapp:hardened -o myapp.tar scanrook scan --file myapp.tar --format json --out report.json CRIT=$(jq '.summary.critical' report.json) if [ "$CRIT" -gt 0 ]; then echo "::error::$CRIT critical findings in hardened image" exit 1 fi
Our scanning guide covers the full range of options for wiring this into CI.
Verifying the hardening took effect
Confirm each setting is actually active rather than assuming the Dockerfile change applied:
# Confirm the container is not running as root
docker exec mycontainer id
# uid=10001(app) gid=10001(app)
# Confirm the filesystem is read-only
docker exec mycontainer sh -c "touch /test" 2>&1
# touch: /test: Read-only file system
# Confirm capabilities were dropped
docker inspect mycontainer --format '{{.HostConfig.CapDrop}}'Where ScanRook fits
Hardening settings and scan results answer different questions, and you need both to report a real security posture. ScanRook covers the vulnerability side — matching installed packages against OSV, NVD, and vendor advisories — so you can gate the build described in Step 8 with confidence. See the docs for CI integration.
Frequently asked questions
What is the single most important hardening step?
Running as a non-root user — it caps what an attacker can do after exploiting any other vulnerability in the container.
Should every container run with a read-only filesystem?
Where the application allows it, yes — mount explicit writable volumes only for the paths that genuinely need them.
What capabilities should a web app container drop?
Drop ALL by default; most stateless web services need none of the default Linux capability set.
Is this checklist a substitute for scanning?
No — hardening limits post-compromise damage, while scanning finds and helps fix the vulnerabilities that enable compromise in the first place.
Pair hardening with scanning in ScanRook
Gate your hardened build on a real vulnerability scan and get severity totals you can report alongside your checklist.