Guides/DockerDocker/Docker Security Best Practices

Docker Security Best Practices

Harden containers where it matters: run as non-root, use minimal pinned base images, scan for CVEs, keep secrets out of layers, and lock down the runtime.


A container is not a security boundary in the way a VM is - it is a process on the host kernel wearing a costume of namespaces and cgroups. That framing is the whole game. Most container "hacks" are not exotic kernel exploits; they are an attacker who popped your app, found themselves running as root inside the container with a fat toolkit and a mounted secret, and walked outward from there. Docker security is the practice of making that walk as short and boring as possible: give the process the least it needs, ship the least you can, and remove the tools an attacker would reach for. This guide covers the handful of things that move the needle, with concrete Dockerfile and docker run examples you can copy today.

Run as a non-root user

By default a container process runs as root (UID 0). It is root inside the container, but crucially it is the same UID the kernel sees - and if anything lets that process reach the host (a misconfigured mount, a kernel bug, a capability you left enabled), it reaches the host as root. There is almost never a good reason for your web app to run as UID 0. Running as root is the single most common and most exploitable container misconfiguration.

The fix is one instruction: create an unprivileged user and switch to it before the app runs.

FROM node:20-slim

WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .

# Create a non-root user and own the app dir
RUN groupadd --system --gid 1001 app \
 && useradd --system --uid 1001 --gid app app \
 && chown -R app:app /app

USER app

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

Two things to get right. First, put USER app after the steps that need root (installing packages, chown) and before CMD, so build steps still work but the running process drops privilege. Second, make sure the app can actually run as that user - it should not need to write outside paths it owns or bind to a privileged port (anything below 1024 needs root, so expose 3000 or 8080, not 80, and let a reverse proxy handle 80).

You can also enforce this from the outside at run time, which is useful when you do not control the image:

docker run --user 1001:1001 myapp:1.4.0

If a container refuses to run non-root, that is a finding, not an excuse. Fix the image so it does not need root.

Use minimal, pinned base images

Every package in your base image is attack surface and a future CVE. A full ubuntu or node image ships a shell, a package manager, curl, git, and hundreds of libraries your app never calls - all of it available to an attacker who lands inside. Shrinking the base does two jobs at once: it removes tools an attacker would use, and it cuts the number of CVEs you have to patch to near zero.

Reach for the smallest base that still runs your app:

  • -slim variants (node:20-slim, python:3.12-slim) - the same distro with the docs, build tools, and extra packages stripped. The easy 80% win, and still has a shell for debugging.
  • alpine - tiny (musl libc, busybox). Great for size, but musl occasionally breaks glibc-compiled binaries and native modules, so test.
  • distroless (gcr.io/distroless/*) - just your app, its runtime, and CA certs. No shell, no package manager, no busybox. Nothing for an attacker to pivot with. The trade-off is you cannot exec a shell into it to debug, so you lean on logs and ephemeral debug containers instead.

And pin your tags. FROM node:20 is a moving target - it silently changes under you and is not reproducible. Pin to a specific minor version, and for the strongest guarantee pin to the image digest, which is content-addressed and cannot be swapped out:

# Bad: floating tag, not reproducible
FROM node:latest

# Better: pinned minor version
FROM node:20.11-slim

# Best: pinned by digest (immutable content hash)
FROM node:20.11-slim@sha256:5e1e...

Multi-stage builds pair perfectly with this: do the heavy build in a full image, then COPY --from=build only the finished artifact into a distroless or slim runtime, so none of the build tooling ships to production.

FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server .

FROM gcr.io/distroless/static-debian12
COPY --from=build /app/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]

Scan images for known vulnerabilities

You cannot patch what you have not measured. Even a slim image inherits CVEs from its OS packages and your dependencies, and new ones are disclosed daily against images that were clean when you built them. Scanning turns "I hope this is fine" into a list with severities and fixed-in versions.

Two tools cover the field. Trivy (open source, from Aqua) is the common pick; docker scout ships with recent Docker Desktop.

# Trivy: scan a built image, fail the build on High/Critical
trivy image myapp:1.4.0
trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:1.4.0

# Docker Scout: quick CVE overview and recommendations
docker scout cves myapp:1.4.0
docker scout recommendations myapp:1.4.0

The point is not to run this once by hand - it is to wire it into CI so a pull request with a fixable Critical fails before it merges. Use --exit-code 1 on the severities you care about so the pipeline actually blocks. When a scan flags a base-image CVE, the fix is usually just rebuilding on a newer base tag; when it flags a dependency, bump the dependency. Scanning without a rebuild habit is theater - the value is in the "rebuild on fresh base, re-scan, ship" loop.

Handle secrets correctly

This is where teams leak credentials. The rules are simple and violated constantly: never bake a secret into an image layer, and never pass one through a build ARG or a persistent ENV.

Image layers are immutable and cached. If you COPY a private key in and RUN rm it in a later step, the key still sits in the earlier layer, readable by anyone who pulls the image with docker history or by unpacking the tarball. Deleting it does not remove it. The same goes for ARG values passed at build time - they land in the build history.

# WRONG - the secret is now permanently in an image layer
COPY id_rsa /root/.ssh/id_rsa
RUN git clone git@github.com:me/private.git && rm /root/.ssh/id_rsa

# WRONG - build args are visible in image history
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc

For secrets needed at build time (a package token, a private repo key), use BuildKit build secrets. They mount the secret only for that one RUN step and never persist in a layer:

# syntax=docker/dockerfile:1
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=npmtoken \
    NPM_TOKEN=$(cat /run/secrets/npmtoken) npm ci --omit=dev
COPY . .
# The token is passed in but never written to any layer
docker build --secret id=npmtoken,env=NPM_TOKEN -t myapp:1.4.0 .

For secrets needed at run time (a database password, an API key), inject them when the container starts - do not build them in. Read them from a secrets manager or an orchestrator secret. Even -e DB_PASSWORD=... is better than baking it in, though it is visible in docker inspect and the process environment; mounting a secret file (or using Docker/Kubernetes secrets, or Vault) is stronger:

# Injected at runtime, not in the image
docker run --env-file ./secrets.env myapp:1.4.0

# Or mount a file the app reads (keeps it out of the environment)
docker run -v /run/secrets/db_pass:/run/secrets/db_pass:ro myapp:1.4.0

The rule of thumb: an image should be safe to push to a public registry. If pushing it would leak a credential, the credential is in the wrong place.

Lock down the runtime

A hardened image still runs with more privilege than it needs by default. These docker run flags remove that surplus, and they are cheap - most apps need none of what you are taking away.

Read-only root filesystem. Most apps never need to write to their own filesystem at run time. Making the root filesystem read-only means an attacker who lands inside cannot drop a binary, modify your app, or write a webshell. Mount tmpfs for the few paths that genuinely need to be writable (temp dirs, caches):

docker run --read-only \
  --tmpfs /tmp \
  --tmpfs /run \
  myapp:1.4.0

Drop Linux capabilities. Root inside a container is not full root - it is a set of Linux capabilities (like CAP_NET_RAW, CAP_CHOWN). Docker grants a default bundle, most of which your app never uses. Drop them all and add back only what you truly need (usually nothing):

docker run \
  --cap-drop ALL \
  --cap-add NET_BIND_SERVICE \   # only if you must bind a low port
  myapp:1.4.0

Stop privilege escalation. --no-new-privileges prevents a process from gaining more privileges than it started with (it blocks setuid binaries from elevating). Combined with running non-root, it closes a common escalation path:

docker run --security-opt no-new-privileges --user 1001 myapp:1.4.0

Putting the runtime hardening together for a typical service:

docker run -d \
  --user 1001:1001 \
  --read-only --tmpfs /tmp \
  --cap-drop ALL \
  --security-opt no-new-privileges \
  --pids-limit 200 \
  -p 8080:8080 \
  myapp:1.4.0

None of these need code changes for a well-behaved app. If a flag breaks the container, it is telling you the app is doing something (writing to /, using a capability) that is worth knowing about anyway.

Never mount the Docker socket

The Docker daemon runs as root on the host, and /var/run/docker.sock is its control API. Mounting that socket into a container (-v /var/run/docker.sock:/var/run/docker.sock) hands that container full control of the daemon - which means it can start a new container that mounts the host root filesystem and take over the machine. It is root on the host, full stop. Treat socket access as equivalent to giving the container the host.

# Effectively root on the host from inside the container - avoid
docker run -v /var/run/docker.sock:/var/run/docker.sock myapp

Plenty of tools (CI runners, monitoring agents, "container that manages containers" setups) ask for the socket because it is convenient. When you genuinely need one container to talk to Docker, prefer a socket proxy that exposes only the specific, read-only API endpoints required, or run a rootless/daemonless builder instead of handing over the raw socket. The same caution applies to --privileged, which disables most of the isolation at once - reach for specific --cap-add flags rather than the sledgehammer.

Keep base images updated

Security is not a state you reach, it is a habit you keep. The clean image you scanned and shipped last quarter has accumulated CVEs since - not because your code changed, but because vulnerabilities were disclosed in the OS packages baked into your base. An image that is never rebuilt only gets less secure over time.

The practice is simple: rebuild regularly on fresh base images and re-scan.

  • Rebuild on a schedule, not just on code changes. A weekly or nightly rebuild picks up upstream base-image patches even when your source is untouched.
  • Automate the bumps. Tools like Dependabot or Renovate open pull requests when a new base tag or dependency version is available, so patching is a review-and-merge, not a manual hunt.
  • Re-scan every build in CI (the Trivy/Scout step above) so a regression is caught at the door.
  • Keep tags pinned but movable. Pinning to 20.11-slim gives reproducibility; your automation bumps the pin to 20.12-slim when it lands, so you get both determinism and patches.

The digest-pinning from earlier and the rebuild habit are two halves of the same coin: pin so builds are reproducible and cannot drift under you silently, then deliberately move the pin forward on a schedule so you stay patched. Pinning without ever bumping is how images quietly rot.

The shape of it

Container security is defense in depth against one failure mode: an attacker who gets code execution inside the container and tries to expand. Every practice here shortens that path. Run as non-root so a breakout is not an instant host root. Ship a minimal, pinned base so there is little to exploit and few CVEs to chase. Scan in CI so you know what you are shipping. Keep secrets out of layers - build secrets at build time, runtime injection at run time - so an image is safe to leak. Harden the runtime with read-only root, dropped capabilities, and no-new-privileges so the process holds only what it needs. Never hand over the Docker socket, which is the host itself. And rebuild on fresh bases so today's clean image does not become next month's liability. None of it is exotic; it is the difference between a contained incident and a compromised host.