Security Best Practices

Running as a non-root user, choosing minimal/distroless base images, and scanning images for known vulnerabilities.

Why container security is a distinct topic

A container isn't automatically safe just because it's isolated from the host — Docker Introduction already covered that container isolation is meaningfully weaker than a VM's, since every container on a host shares one kernel. On top of that shared-kernel reality, most container security incidents in practice come from much more mundane causes: running as root unnecessarily, shipping a bloated image full of software nobody audits, and never checking a base image for known vulnerabilities before deploying it. All three are fixable with habits, not exotic tooling.

Running as a non-root user

By default, unless a Dockerfile says otherwise, a container's main process runs as root — not the host's root, but root inside the container's namespace, which still has more privilege than it needs for almost any application workload. If an attacker manages to exploit a vulnerability in the running application, running as root hands them root inside the container for free, which meaningfully widens what a container-escape or shared-kernel vulnerability could do next.

Dockerfile
FROM node:20-alpine

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --omit=dev

COPY . .

# Create a dedicated, unprivileged user and switch to it before running
# the application — do this AFTER copying files so ownership is set correctly
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
RUN chown -R appuser:appgroup /app

USER appuser

EXPOSE 3000
CMD ["node", "server.js"]

Several official base images (node, as used in Images and Dockerfiles) already ship a ready-made unprivileged user — node's image includes one literally named node — so USER node alone is often enough without manually creating one. Either way, the important line is USER <something-not-root> appearing before the final CMD.

Bash
# Confirm which user a running container is actually using
$ docker exec my-container whoami
appuser

Minimal base images

Every package installed in a base image is both attack surface (a vulnerability in a library you never use is still a vulnerability) and image weight (slower pulls, slower deploys, more to scan). Base image choice is one of the highest-leverage security decisions in a Dockerfile:

Base image style Example Size (rough) Notes
Full OS-based node:20 (Debian-based) ~1 GB Full package manager, shell, many system libraries — convenient for debugging, largest surface
Slim/minimal Linux node:20-alpine ~180 MB Alpine Linux — a real but minimal distro, small standard library (musl instead of glibc)
Distroless gcr.io/distroless/nodejs20 ~120 MB No shell, no package manager, no unrelated binaries at all — just the language runtime and your app
From scratch FROM scratch Near-zero base Truly empty — only works for statically-linked binaries (common for Go, Rust) with no runtime dependencies

alpine-based images are usually the pragmatic default: dramatically smaller than a full OS-based image, but still have a shell and package manager for the rare occasion you need to docker exec in and debug something interactively. Distroless images go further specifically for production: no shell at all means a large class of "attacker got a foothold, now what" post-exploitation techniques (spawning a shell, downloading more tools) simply have nothing to work with — at the cost of debugging convenience, since you can't casually shell into a distroless container.

Scanning images for known vulnerabilities

Even a minimal base image bundles a specific set of package versions, and vulnerabilities are discovered in existing packages constantly. Scanning checks an image's contents against public vulnerability databases (like the National Vulnerability Database) and reports matches by severity.

Docker Scout, built into Docker Desktop and the CLI:

Bash
$ docker scout cves my-app:1.0

  ✗ 3C  1H  6M  2L  my-app:1.0

    ✗ CRITICAL  CVE-2024-XXXXX  openssl 3.0.8-r0
      introduced through: node:20-alpine
      fixed in: 3.0.12-r0

Trivy, a widely-used open-source alternative, works the same way and is common in CI pipelines regardless of registry:

Bash
$ trivy image my-app:1.0

my-app:1.0 (alpine 3.19.1)
==========================
Total: 4 (CRITICAL: 1, HIGH: 1, MEDIUM: 2)

┌──────────┬────────────────┬──────────┬───────────────┬───────────────┐
│ Library  │ Vulnerability  │ Severity │ Installed Ver │ Fixed Version │
├──────────┼────────────────┼──────────┼───────────────┼───────────────┤
│ openssl  │ CVE-2024-XXXXX │ CRITICAL │ 3.0.8-r0      │ 3.0.12-r0     │
└──────────┴────────────────┴──────────┴───────────────┴───────────────┘

Both tools tell you the same essential thing: which known vulnerability exists, in which package, and — critically — whether a fixed version is already available, which is usually as simple as bumping the base image tag and rebuilding. Wiring a scan into CI (failing the build on any CRITICAL finding, for instance) catches this before a vulnerable image is ever pushed to a registry, rather than relying on someone remembering to check manually.

A few more defense-in-depth habits

  • Read-only root filesystemdocker run --read-only (or read_only: true in Compose) makes the container's filesystem immutable at runtime except for explicitly mounted writable paths (like /tmp), which limits what a compromised process can actually persist or tamper with.
  • Drop unnecessary Linux capabilities — a container gets a broad default set of kernel capabilities; docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE (adding back only what's genuinely needed) follows the same least-privilege principle as file permissions.
  • Never bake secrets into an image layer — an ENV DB_PASSWORD=... or a COPY .env . is recoverable by anyone who can pull or inspect the image's layers, even ones later "removed" in a subsequent instruction; use runtime environment variables, a secrets manager, or (during build) BuildKit's --secret mount instead.

Common mistakes

  • Running a production container's main process as root because it's the Dockerfile's default, without ever adding a USER instruction.
  • Choosing a full OS-based image purely out of habit when an alpine or distroless equivalent would work identically for the application, needlessly growing the attack surface and image size.
  • Treating a vulnerability scan as a one-time check at build time and never re-scanning images already sitting in a registry — new CVEs are discovered in already-shipped packages constantly, so scanning should be recurring, not a single gate.
  • Putting a secret directly in a Dockerfile instruction (ENV, ARG used carelessly, or COPYing a credentials file) — it persists in that image layer's history even if a later instruction seems to remove it.
  • Assuming a smaller image is automatically a more secure one — size and vulnerability count are correlated but not identical; a small image with an old, unpatched package can still be worse than a larger, actively maintained one.