To secure Docker containers you harden four layers at once: the base image, the build, the runtime, and the host — because a container that runs as root with a bloated image and full Linux capabilities is one bad dependency away from a host compromise. Most teams I work with treat "it builds and runs" as the finish line. It isn't. A container inherits every package in its base image and every default the runtime hands it, and those defaults were chosen for convenience, not safety.
Here is how to secure Docker containers in a way that survives a real audit, ordered roughly by how much risk each step removes.
Start With a Minimal Base Image
The single biggest lever is the base image. A python:3.12 image pulls in a full Debian userland with hundreds of packages, most of which your app never touches — and every one of them is attack surface and a potential CVE. Switch to a slim or distroless variant and the package count drops by an order of magnitude.
# Instead of this
FROM python:3.12
# Prefer this
FROM python:3.12-slim
# Or, for a truly minimal runtime image
FROM gcr.io/distroless/python3-debian12
Distroless images ship no shell, no package manager, and no coreutils. That means an attacker who lands code execution can't curl | sh a second stage or spawn /bin/bash. The tradeoff is that debugging is harder, so keep a debug tag with a shell for local work and ship the shell-less variant to production.
Whatever you pick, pin it by digest, not just tag. Tags are mutable; python:3.12-slim today is not the same bytes as next month. FROM python:3.12-slim@sha256:... guarantees reproducibility.
Never Run as Root
By default a container process runs as UID 0. If that process is compromised and a runtime or kernel isolation bug is in play, root in the container can become root on the host. Create an unprivileged user in your Dockerfile and drop to it.
RUN groupadd --gid 10001 app \
&& useradd --uid 10001 --gid app --no-create-home app
USER 10001:10001
Use a numeric UID in the USER directive so Kubernetes runAsNonRoot checks pass even without a matching entry in /etc/passwd. Also set no-new-privileges at runtime so a setuid binary inside the container can't escalate:
docker run --security-opt no-new-privileges:true myapp
Drop Capabilities and Go Read-Only
Docker grants a default set of Linux capabilities to every container, including ones most apps never need like NET_RAW and MKNOD. Drop everything and add back only what you use.
docker run \
--cap-drop ALL \
--cap-add NET_BIND_SERVICE \
--read-only \
--tmpfs /tmp \
myapp
--read-only mounts the root filesystem as immutable, which stops an attacker from writing a web shell or tampering with binaries. Give the app a writable tmpfs for scratch space and mount named volumes for anything that legitimately needs persistence. This one change breaks a large class of post-exploitation techniques.
Use Multi-Stage Builds to Shrink the Attack Surface
Build tooling — compilers, git, dev headers, secrets used to fetch private packages — has no business in the final image. Multi-stage builds let you compile in one stage and copy only the artifacts into a clean runtime stage.
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=build /app /app
USER 65532:65532
ENTRYPOINT ["/app"]
The runtime image contains one static binary and nothing else. No shell to hijack, no package manager to abuse, and no leftover build secrets baked into an intermediate layer. Remember that COPY and ADD write to layers permanently, so a secret you delete in a later RUN still lives in the earlier layer's history — use build secrets (--mount=type=secret) instead.
Scan Images and Watch Dependencies
Hardening the runtime does nothing about a known-vulnerable library sitting three levels deep in your dependency tree. Scan the image before it ships and again on a schedule, because new CVEs are disclosed against images that were clean when you built them.
Tools like Trivy and Grype read the image layers, extract the software bill of materials, and match packages against vulnerability databases. Wire the scan into CI so a build fails on a fixable critical:
trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:latest
Image scanning catches OS-level packages, but application dependencies deserve their own analysis. A software composition analysis tool such as Safeguard can flag a vulnerable transitive dependency that a surface-level image scan misses, and tie it back to the manifest that introduced it. If you're weighing options here, our SCA overview walks through what reachability-aware scanning changes about triage.
Lock Down the Runtime and the Host
The container is only as isolated as the host lets it be. A few host-level defaults matter as much as anything in the Dockerfile:
- Never mount the Docker socket (
/var/run/docker.sock) into a container unless you fully trust it — socket access is root on the host. - Set memory and CPU limits (
--memory,--cpus) so a runaway or malicious container can't starve its neighbors. - Enable user namespace remapping (
userns-remap) so container root maps to an unprivileged host UID. - Keep a seccomp profile in place. Docker's default profile blocks around 44 dangerous syscalls; don't disable it with
--privilegedor--security-opt seccomp=unconfinedunless you have a specific, reviewed reason.
The --privileged flag deserves special mention: it disables nearly every isolation feature at once. If you find it in a compose file or a Kubernetes manifest, treat it as a finding until someone justifies it.
Put It in the Pipeline
None of this holds without enforcement. A container that was hardened last quarter drifts as base images update and someone adds a quick --privileged to unblock a demo. Codify the rules: a linter like Hadolint on every Dockerfile, an admission policy (OPA/Gatekeeper or Kyverno) that rejects root containers and privileged pods, and a scanning gate in CI. The Safeguard academy has walkthroughs for wiring these gates without turning your pipeline into a bottleneck.
Security that lives in a wiki gets ignored. Security that fails a build gets fixed.
FAQ
Do I need distroless images to secure Docker containers?
No. Distroless removes the shell and package manager, which is a strong control, but a -slim image plus a non-root user, dropped capabilities, and a read-only filesystem already eliminates most practical attack paths. Distroless is the next step up when you want to remove the shell entirely.
Is running as non-root enough on its own?
It's necessary but not sufficient. Non-root stops the easiest privilege escalation, but you still want no-new-privileges, dropped capabilities, and a seccomp profile. Defense in depth matters because container escapes usually chain several weaknesses together.
How often should I rescan container images?
Rescan on every build and on a daily or weekly schedule for images already in production. Vulnerabilities are disclosed continuously, so an image that passed a scan at build time can accumulate several known CVEs within weeks without any change to its bytes.
What does --privileged actually do?
It gives the container nearly all host capabilities, disables seccomp and AppArmor confinement, and exposes host devices. It effectively removes the isolation boundary. Avoid it; if a workload genuinely needs a specific capability, add that one capability instead.