Securing Docker containers comes down to shrinking what is inside the image, dropping the privileges the container runs with, and scanning every layer before it ships. No single flag makes a container "secure." What works is a short chain of sensible defaults applied consistently, so that a mistake in one place does not hand an attacker the host. This guide walks the chain from base image to runtime.
The reason securing Docker gets attention is that a container is not a strong security boundary by default. It shares the host kernel, and a misconfigured container running as root with the wrong capabilities is a short hop from the machine underneath it.
Start with a minimal base image
Every package in your base image is attack surface and something you now have to patch. A full ubuntu or node image drags in shells, package managers, and libraries your app never calls, and each of those can carry a known vulnerability.
Prefer slim or distroless bases. Instead of FROM node:20, use FROM node:20-slim or a distroless runtime image that ships your app and its language runtime and almost nothing else, no shell, no apt.
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=build /app /app
USER nonroot
CMD ["server.js"]
The multi-stage build keeps build tools out of the final image. The distroless runtime removes the shell entirely, which means an attacker who lands remote code execution has no /bin/sh to pivot with.
Never run as root
By default a container process runs as UID 0. If that process is compromised and any container escape exists, the attacker is root on the way out. Create and switch to an unprivileged user:
RUN useradd --system --uid 10001 appuser
USER appuser
At runtime, back this up with --user, and add --read-only for the root filesystem plus a small writable tmpfs for anything that genuinely needs to write. Drop every Linux capability you do not use:
docker run --read-only --cap-drop=ALL \
--cap-add=NET_BIND_SERVICE \
--security-opt=no-new-privileges \
myapp:latest
no-new-privileges stops a process from gaining more rights through setuid binaries, and --cap-drop=ALL starts you from zero instead of Docker's permissive default set.
Scan images for known vulnerabilities
An image is a stack of dependencies: OS packages plus your application's libraries. Both go stale. Scan images as part of the build, not as an afterthought, and fail the pipeline on severities you have decided you will not ship.
Open-source scanners like Trivy or Grype read the image and match its contents against vulnerability databases:
trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:latest
Scanning the application layer is where software composition analysis earns its keep, because a base image can be clean while your node_modules or Python wheels carry a critical CVE transitively. Wiring the scan into CI means a vulnerable image never reaches your registry in the first place. If you want to see how automated coverage compares across tools, our Snyk comparison breaks it down.
Keep secrets out of images and layers
A secret baked into an image is a secret published to everyone who can pull it, and it stays in the layer history even if a later layer deletes it. Do not COPY a .env file, and do not ENV an API key.
Use build-time secret mounts for credentials needed only during the build, and inject runtime secrets through the orchestrator (Kubernetes secrets, a secrets manager) at start time:
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci
The token never becomes a layer. Also add a .dockerignore so stray .git, .env, and key files cannot be swept into the build context by a broad COPY . ..
Harden the runtime and the daemon
The image is half the story; how you run it is the other half.
- Set explicit CPU and memory limits so one container cannot starve its neighbors or the host.
- Do not mount the Docker socket (
/var/run/docker.sock) into a container unless you fully understand that you are granting root-equivalent control of the host. - Avoid
--privileged. It disables most of the isolation you are trying to keep. - Pin images by digest, not just a mutable tag like
latest, so you always run the bytes you scanned. - Apply a seccomp profile. Docker's default already blocks dozens of dangerous syscalls; keep it on rather than passing
--security-opt seccomp=unconfined.
Sign and verify what you ship
Once an image is built and scanned, you want a guarantee that the thing running in production is the thing you approved. Image signing with tools like Cosign lets you sign a digest at build time and verify the signature at deploy time, closing the gap where a registry compromise could swap in a tampered image. In a policy-gated pipeline, an unsigned or unscanned image simply does not get admitted.
Pulling the chain together: a slim base, a non-root user, a read-only filesystem with dropped capabilities, scanned and signed layers, and no secrets baked in. Each link is cheap on its own, and together they turn a container from a soft target into a hard one. For teams formalizing this, the Safeguard academy has deeper walkthroughs of the supply-chain side.
FAQ
Do containers isolate applications as well as virtual machines?
No. Containers share the host kernel, so isolation is weaker than a VM's hardware-level boundary. That is precisely why non-root users, dropped capabilities, and seccomp matter: they narrow what a compromised container can do to the shared kernel.
What is the single most impactful change for securing Docker containers?
Running as a non-root user with dropped capabilities. It does not prevent a compromise, but it dramatically limits what an attacker can do afterward, which is often the difference between a contained incident and a host takeover.
Should I scan base images or my application dependencies?
Both. A base image can be freshly patched while your application libraries carry a critical vulnerability, or vice versa. Scan the full image so OS packages and app dependencies are both checked, and fail the build on the severities you will not ship.
Is distroless always the right choice?
It is a strong default for production runtimes because it removes the shell and package manager. The trade-off is harder debugging, since you cannot exec into a shell. Many teams run distroless in production and a slim, shelled variant in development.