Best practices

Docker Security: A Practical Hardening Guide for 2026

Published August 1, 2026 · 11 min read

Docker security is not one setting — it is a stack of small, cheap defaults that each shrink what a compromised container can do. This guide walks through the changes that matter most, with runnable configuration for each, and shows where scanning the image itself fits alongside hardening how it runs.

Why containers need hardening at all

A container is not a virtual machine. It is a normal Linux process wrapped in namespaces (which give it its own view of the process table, network, and mounts) and cgroups (which cap its resources). Isolation is real but it is enforced by the shared host kernel, not by a hypervisor. That has two consequences. First, a kernel-level bug or a misconfiguration can let a process escape the container onto the host. Second — and this is the part teams underestimate — the Docker daemon runs as root, so a container that starts with root privileges and then breaks out arrives on the host as root.

The goal of hardening is defense in depth: assume any single layer can fail and make sure the next one still limits the blast radius. None of the steps below is exotic, and most are one line. Together they turn “a bug in my app is a bug on the host” into “a bug in my app is a bug in an unprivileged, capability-stripped, read-only sandbox.”

Step 1: Start from a minimal, patched base

Every package in your base image is attack surface and a potential CVE. A smaller base means fewer libraries to exploit and fewer advisories to triage. Prefer a slim or distroless base over a full distribution, and always pull fresh so you get the latest security updates rather than a cached, stale layer.

# Pull the latest patched base rather than a cached layer
docker build --pull -t myapp:latest .

# In the Dockerfile, pin to a specific minimal tag
# (a digest pin is even stronger against tag mutation)
FROM debian:12-slim

Which base is “minimal” depends on your language and tolerance for debugging without a shell. We compare the common options in Alpine vs Debian vs Distroless and the size-focused minimal Docker image guide.

Step 2: Run as a non-root user

This is the highest-leverage change in the whole guide. By default a container's main process runs as root. Create an unprivileged user in the image and switch to it with a USER directive so a break-out lands as nobody-in-particular rather than root.

FROM debian:12-slim

# Create a dedicated, unprivileged user
RUN groupadd --system --gid 10001 app \
 && useradd --system --uid 10001 --gid app app

WORKDIR /app
COPY --chown=app:app . .

# Everything from here runs as an unprivileged user
USER 10001

ENTRYPOINT ["/app/server"]

Use a numeric UID (here 10001) so Kubernetes can enforce runAsNonRoot reliably — it can only verify a non-root user when the UID is numeric, not a name. If a process genuinely needs to bind a low port, do it with a capability (Step 3), not by staying root.

Step 3: Drop capabilities and block privilege escalation

Linux capabilities split root's powers into discrete units. Docker hands every container a default set of about 14 of them; most applications need zero. Drop them all and re-add only what the workload actually uses. Pair that with no-new-privileges, which stops a process from gaining more privileges through setuid binaries.

docker run \
  --cap-drop ALL \
  --cap-add NET_BIND_SERVICE \
  --security-opt no-new-privileges \
  myapp:latest

Docker also applies a default seccomp profile that blocks dozens of dangerous system calls (around 44) unless you opt out with --security-opt seccomp=unconfined. Do not disable it. And never run with --privileged unless you fully understand that it removes almost every isolation control at once.

Step 4: Make the filesystem read-only

Most application containers never need to write to their own image filesystem at runtime. Mounting it read-only stops an attacker from dropping a payload, tampering with binaries, or persisting between restarts. Give the process writable tmpfs mounts only where it genuinely needs scratch space.

docker run \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --cap-drop ALL \
  --security-opt no-new-privileges \
  myapp:latest

The noexec flag on the tmpfs is worth adding: it prevents executing anything written to that scratch directory, closing a common post-exploitation step.

Step 5: Do not mount the socket or bake in secrets

Two mistakes account for a large share of real-world container compromises. The first is mounting /var/run/docker.sock into a container — that hands the container control of the daemon, which is equivalent to root on the host. The second is baking credentials into image layers, where they persist in the image history even if a later layer deletes them.

# Pass secrets at build time without persisting them in a layer
# (requires DOCKER_BUILDKIT=1)
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci

# At runtime, inject secrets as env vars or mounted files,
# never with COPY or ENV baked into the image
docker run --env-file ./secrets.env myapp:latest

Multi-stage builds keep build-time tooling and credentials out of the final image entirely — the pattern is covered in multi-stage Docker builds for security.

Step 6: Harden the daemon

Some controls live on the daemon rather than the container. User-namespace remapping maps container root to an unprivileged host UID, so even a process running as root inside the container is unprivileged on the host. Configure it in /etc/docker/daemon.json:

{
  "userns-remap": "default",
  "no-new-privileges": true,
  "live-restore": true,
  "log-driver": "json-file",
  "log-opts": { "max-size": "10m", "max-file": "3" }
}

For a stronger boundary, run the daemon itself unprivileged with rootless mode, so a daemon compromise is not automatically host root. It has real tradeoffs around networking and storage, which is why it deserves its own walkthrough. Also constrain resources per container (--memory, --pids-limit, --cpus) so one container cannot starve the host or fork-bomb it.

Step 7: Scan the image

Every step so far limits what a compromised container can do. Scanning addresses the other half of the problem: the vulnerable software you are shipping before anyone touches the runtime. Export the image and scan it as part of the build.

# Install the scanner
curl -fsSL https://scanrook.io/install.sh | sh

# Export the built image to a tar and scan it
docker save myapp:latest -o myapp.tar
scanrook scan myapp.tar --format json --out report.json

The full workflow — exporting an image, reading the results, and wiring it into CI — is in how to scan a Docker image for vulnerabilities.

Verifying it worked

Do not assume the flags took effect — check them. A few quick inspections confirm the container is actually running the way you configured it:

# Confirm the process is not running as root
docker exec myapp id
# expect uid=10001 gid=10001, not uid=0

# Confirm the root filesystem is read-only
docker inspect --format '{{ .HostConfig.ReadonlyRootfs }}' myapp
# expect true

# Run the CIS-based audit tooling for a broad check
docker run --rm --net host --pid host --cap-add audit_control \
  -v /var/lib:/var/lib:ro -v /var/run/docker.sock:/var/run/docker.sock:ro \
  docker/docker-bench-security

Docker Bench for Security scores your host and containers against the CIS Docker Benchmark and is a good recurring check. For a broader, human-readable pass, work through our container image security checklist and the code-first Docker image hardening checklist.

The same controls in Compose and Kubernetes

Every docker run flag above has a declarative equivalent, so you are not stuck retyping them. In Docker Compose the same hardening lives in the service definition:

services:
  app:
    image: myapp:latest
    user: "10001:10001"
    read_only: true
    cap_drop: ["ALL"]
    security_opt:
      - no-new-privileges:true
    tmpfs:
      - /tmp:rw,noexec,nosuid,size=64m
    pids_limit: 200
    mem_limit: 512m

In Kubernetes the equivalents move into a pod and container securityContext: runAsNonRoot, allowPrivilegeEscalation: false, capabilities.drop: ["ALL"], readOnlyRootFilesystem, and a RuntimeDefault seccomp profile. Better still, Kubernetes can enforce those settings cluster-wide with the built-in Pod Security Standards, so a container that forgets to drop privileges is rejected at admission rather than trusted to configure itself. The lesson is the same everywhere: define the hardening once, declaratively, and let the platform enforce it instead of relying on every developer to remember the flags.

Where ScanRook fits

ScanRook covers the image side of Docker security. It unpacks each layer, reads the actual package-manager databases inside the image, and matches every component against OSV, NVD, and vendor advisory data in parallel, tagging each finding with its source and a confidence tier. That tells you which CVEs are really installed — the input to deciding whether a base image is safe to ship. It does not replace runtime hardening: a read-only, non-root, capability-stripped container running a vulnerable OpenSSL is still vulnerable. Use scanning to reduce what you ship and the hardening steps above to contain what slips through.

Frequently asked questions

What is the most important Docker security setting?

Running as a non-root user. A break-out from a root container lands on the host as root, so a numeric-UID USER directive is the single highest-leverage change.

Is a container as isolated as a VM?

No. Containers share the host kernel, so isolation depends on namespaces, cgroups, capabilities, and seccomp rather than a hypervisor. Treat these as layers, not a single hard boundary.

Why avoid mounting the Docker socket?

The socket is the daemon's control API. A container with access to it can launch privileged containers and mount the host filesystem — effectively host root.

Does scanning replace hardening?

No — they are complementary. Scanning reduces the vulnerabilities you ship; hardening limits what a compromised container can do if one is exploited anyway.

Scan the image, then harden the runtime

Hardening flags contain a compromise; scanning helps prevent one. Upload a container image to ScanRook to see every installed package matched against multiple advisory sources, with the source and confidence shown for each finding.

Related Posts

More on this topic.