To secure a Docker container you harden three layers at once: the image it is built from, the way it runs, and the software supply chain that feeds it. A secure Docker container is not a single flag or a scanner badge. It is the result of a small number of decisions made deliberately, most of them at build time, where fixing a problem is cheap. This guide walks through those decisions with real Dockerfile and runtime settings you can apply today.
Start with the smallest base image you can justify
Every package in your base image is attack surface you did not write. A python:3.12 image ships a full Debian userland; a python:3.12-slim image drops most of it, and a distroless or Alpine-based image drops nearly all of it.
# Heavier than it needs to be
FROM python:3.12
# Much smaller attack surface
FROM python:3.12-slim
Distroless images go further by removing the shell and package manager entirely. That breaks docker exec debugging, which is a real tradeoff, but it also means an attacker who lands code execution has no shell to pivot from. Pick the smallest image your team can operate, and pin it by digest rather than tag so a rebuild does not silently pull a different image:
FROM python:3.12-slim@sha256:<digest>
Never run as root
By default a container process runs as root inside the container, and a container escape from root is far more dangerous than one from an unprivileged user. Create a dedicated user and drop to it before the app starts.
RUN groupadd --gid 10001 app \
&& useradd --uid 10001 --gid app --no-create-home app
USER 10001
Using a numeric UID matters: Kubernetes can then enforce runAsNonRoot: true because it can verify the UID without resolving a name. Combine this with a read-only root filesystem at runtime so the process cannot rewrite its own binaries.
docker run --read-only --tmpfs /tmp your-image
Use multi-stage builds to leave the toolchain behind
Compilers, build dependencies, and dev headers do not belong in a production image. A multi-stage build compiles in one stage and copies only the artifacts into a clean final 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 10001
ENTRYPOINT ["/app"]
The final image contains a single static binary and nothing an attacker can use to explore. This one change often removes more CVEs than any scanner remediation, because the vulnerable packages are simply not present.
Scan the image and pin the dependencies
A secure Docker container depends on software you did not write, and those dependencies age. Scanning the built image for known CVEs should run in CI on every build, and the results should gate the merge, not just email someone. Tools like Trivy or Grype scan image layers, and a broader software composition analysis approach ties image findings back to the manifests in your repository. An SCA tool such as Safeguard can flag a vulnerable transitive dependency before it is baked into the layer, which is where remediation is cheapest.
Pin everything. An unpinned apt-get install curl or pip install requests resolves to whatever the latest version is on build day, which makes builds non-reproducible and makes it impossible to reason about what shipped.
RUN pip install --no-cache-dir requests==2.32.3
For OS packages, pin versions where your base image allows it and clean the package cache in the same layer so it does not persist:
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates=20230311 \
&& rm -rf /var/lib/apt/lists/*
Keep secrets out of the image
Anything you COPY or ENV into an image is readable by anyone who can pull it, and it persists in the layer history even if a later layer deletes it. Do not bake API keys, tokens, or .env files into images. Use build secrets for build-time credentials and inject runtime secrets through the orchestrator.
# BuildKit build secret, not persisted in the final image
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci
A quick audit habit: run docker history --no-trunc your-image and read the layer commands. If a token appears there, it has already leaked.
Constrain what the container can do at runtime
Build-time hardening limits what is in the container; runtime hardening limits what it can do. Drop all Linux capabilities and add back only what the process needs, disable privilege escalation, and set resource limits so one container cannot starve the host.
docker run \
--cap-drop=ALL \
--security-opt=no-new-privileges \
--read-only \
--memory=512m --cpus=1 \
your-image
In Kubernetes the equivalent lives in the pod securityContext, and an admission controller can reject any pod that does not set these fields. That turns hardening from a suggestion into an enforced policy, which is the only version that survives contact with a busy team.
Rebuild regularly, do not patch in place
Containers are meant to be immutable. When a base image ships a security fix, the correct response is to rebuild and redeploy, not to apt-get upgrade inside a running container. Set up your pipeline to rebuild on a schedule and on upstream base-image updates so patched images flow out without a human remembering to trigger them. Pair that with a runtime scanner if you want to catch drift between what you built and what is actually running; our note on DAST and dynamic testing covers the running-application side of the same problem.
FAQ
What is the single most effective step to secure a Docker container?
Using a minimal base image, ideally distroless or slim, removes the largest chunk of attack surface. Most container CVEs come from packages in a fat base image that the application never uses.
Should containers ever run as root?
Almost never. Create a numeric non-root user in the Dockerfile and enforce runAsNonRoot in your orchestrator. A container escape from an unprivileged user is far less damaging than one from root.
How often should I rescan container images?
On every build in CI, and continuously for images already in production. New CVEs are disclosed against packages that were clean when you shipped, so a one-time scan goes stale within days.
Do multi-stage builds actually improve security?
Yes. They keep compilers and build dependencies out of the final image, which removes both attack surface and a large number of CVEs that only ever existed in the build toolchain.