Safeguard
AI Security

How to Improve Security for Docker Containers

Practical security for Docker containers: minimal base images, non-root users, image scanning, and runtime hardening you can apply to any Dockerfile today.

Marcus Chen
DevSecOps Engineer
6 min read

Security for Docker containers comes down to shrinking what you ship, dropping privileges you do not need, and scanning every image before it runs. A container is not a security boundary by default — it shares the host kernel and, unless you configure it otherwise, runs as root. Most real-world container incidents trace back to a handful of avoidable mistakes: bloated base images full of vulnerable packages, processes running as root, secrets baked into layers, and images that were never scanned.

This guide walks through the controls that matter, in roughly the order they pay off.

Start with a minimal base image

Every package in your base image is attack surface and a potential CVE. A node:24 image built on full Debian carries hundreds of OS packages your application never calls. Switching to a slim or distroless variant removes shells, package managers, and libraries an attacker would otherwise use after a breakout.

# Heavy: full OS, many packages, larger CVE surface
FROM node:24

# Lighter: Debian slim, far fewer packages
FROM node:24-slim

# Minimal: no shell, no package manager
FROM gcr.io/distroless/nodejs24-debian12

Distroless and Alpine-based images routinely cut the vulnerability count of a fresh image by an order of magnitude, mostly because the packages simply are not present to be flagged. The trade-off with distroless is that you lose a shell for debugging, which is a feature from a security standpoint — use ephemeral debug containers when you genuinely need to poke around.

Never run as root

By default a container process runs as UID 0. If an attacker achieves code execution and then exploits a kernel or runtime flaw to escape, they land on the host as root. Create and switch to an unprivileged user in your Dockerfile:

FROM node:24-slim
WORKDIR /app
COPY --chown=node:node . .
RUN npm ci --omit=dev
USER node
CMD ["node", "server.js"]

The official Node.js images already ship a node user, so you rarely need to create one. Enforce this at the platform level too: in Kubernetes, set runAsNonRoot: true and a specific runAsUser in the pod security context so a container that ignores USER is refused admission.

Keep secrets out of image layers

Every COPY and RUN creates a layer, and layers are immutable and inspectable. Copying a .env file or passing an API key through an ARG leaves it recoverable with docker history even if a later layer deletes it. Use BuildKit secret mounts for build-time credentials and inject runtime secrets through environment variables or a secrets manager, never the image:

# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci --omit=dev

Add a .dockerignore that excludes .git, .env, and local credential files so they never enter the build context in the first place.

Scan images for vulnerabilities

You cannot fix what you cannot see. Scan every image for known CVEs in its OS packages and application dependencies, and do it in CI so a vulnerable image never reaches a registry. Trivy is a common open-source starting point:

trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:latest

The --exit-code 1 fails the pipeline when high or critical issues appear, turning the scan into a gate rather than a report nobody reads. Scanning the OS layer catches base-image CVEs; scanning the dependency manifests catches vulnerable libraries. A software composition analysis tool such as Safeguard can track those dependency vulnerabilities transitively across images and tell you which are actually reachable, so you triage the exploitable ones first instead of drowning in noise. Rescan on a schedule too — an image that was clean at build time accumulates CVEs as new advisories publish against packages it already contains.

Pin versions and rebuild often

Vague tags undermine everything else. FROM node:latest means your build is not reproducible and you have no idea which base you shipped. Pin to a specific minor version, and for the strongest guarantee pin by digest:

FROM node:24.4.1-slim@sha256:<digest>

Digest pinning makes the image immutable, but it also means you must deliberately bump the digest to pick up patches. Automate that with a dependency bot so pinning does not turn into staleness. The two failure modes — floating tags that change under you, and pinned digests that never get patched — are both dangerous, and the fix is automation, not choosing one extreme.

Harden the runtime

Build-time hygiene gets you a clean image; runtime configuration keeps it contained. The high-value flags:

  • Run with a read-only root filesystem (--read-only) and mount a small writable tmpfs only where the app genuinely needs to write.
  • Drop all Linux capabilities and add back only what is required (--cap-drop=ALL --cap-add=NET_BIND_SERVICE).
  • Set --security-opt=no-new-privileges so a setuid binary inside the container cannot escalate.
  • Apply resource limits (--memory, --cpus) so a compromised or buggy container cannot starve the host.

In Kubernetes these map to securityContext fields and are best enforced with an admission policy so they are not left to each developer to remember.

Use multi-stage builds

Multi-stage builds keep compilers, build tools, and dev dependencies out of the final image. You build in a fat stage and copy only the runtime artifacts into a slim one:

FROM node:24 AS build
WORKDIR /app
COPY . .
RUN npm ci && npm run build

FROM gcr.io/distroless/nodejs24-debian12
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["dist/server.js"]

The final image contains no npm, no source, and no build toolchain — nothing an attacker can turn into a foothold.

FAQ

Is a Docker container a security boundary?

Not a strong one on its own. Containers share the host kernel, so a kernel exploit or a misconfiguration can allow escape. Treat container isolation as one layer among several — non-root users, dropped capabilities, and seccomp profiles — rather than a hard wall. For hostile multi-tenant workloads, consider a sandboxed runtime like gVisor or a VM-based isolation layer.

How often should I rescan images already in production?

At least daily for anything internet-facing, and on every rebuild. New CVEs are published constantly against packages your image already contains, so an image that scanned clean last week can be vulnerable today without a single line of your code changing. Automated scheduled scans catch this drift.

Does using Alpine make my container secure?

Alpine reduces image size and package count, which shrinks attack surface, but it is not a security guarantee. Alpine uses musl libc instead of glibc, which occasionally causes subtle runtime issues, and you still need non-root users, scanning, and runtime hardening. Distroless images give a similar minimal footprint with fewer compatibility surprises for many stacks.

What is the single highest-impact change I can make?

Stop running as root. It is one line in the Dockerfile and one field in your pod spec, and it neutralizes the most damaging outcome of a container breakout. Pair it with image scanning as a CI gate and you have covered the two issues behind most container incidents.

Never miss an update

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