Docker security checklist

Docker Container Hardening Checklist: 5 Checks (2026)

Docker container hardening is the set of runtime flags that decide how much of the host a compromised container can reach. This page documents all 5 checks in the CtrlOps Container Hardening audit: what each one reads, the exact value that passes, the values that warn or fail, and the command to fix it.

Hiren KalariyaLast reviewed: Aug 22, 202613 min read
5
checks in this audit
3
rated high severity
10s
automated run time
5
need root or sudo

Every pass, warning and failure threshold on this page is transcribed from the docker-security-container-hardening audit script that CtrlOps runs, and a build check fails if the two ever disagree. See how the audit runs.

A single --privileged flag hands every device and every kernel capability to one workload. One compromised process inside it is one step away from owning the host, and nothing in the Docker logs will tell you the flag is there. This page documents the 5 checks in the CtrlOps Container Hardening audit, in the order the audit runs them.

This audit reads the runtime configuration of running containers: privileged mode, privilege escalation, the container user, Linux capabilities and root filesystem mutability. Every threshold below is transcribed from the audit script itself, not from general advice. Where the script warns rather than fails, this page says so and explains why the distinction matters.

Key takeaways

Five checks, three of them HIGH, and all five read the same thing: what docker inspect says about containers that are running right now.

  • Three are rated HIGH: privileged mode, setuid escalation and the container user. Capabilities is MEDIUM, read-only filesystem is LOW.
  • All five need root, or membership of the docker group, because every one of them queries the Docker daemon.
  • Nothing here is about your image. These are runtime flags. An image with a perfect USER directive still reports as root here if someone started it with -u 0.
  • The whole audit takes about 10 seconds when CtrlOps runs it over your existing SSH connection, against roughly 3 minutes of docker inspect per host by hand.
#CheckSeverityWhat it catchesRoot needed
1Privileged containersHIGH--privileged, which grants every capability and deviceYes
2No new privilegesHIGHA missing no-new-privileges security optionYes
3Container userHIGHContainers running as UID 0 with no user namespaceYes
4CapabilitiesMEDIUMSYS_ADMIN, NET_ADMIN or ALL added, or nothing droppedYes
5Read-only filesystemLOWA writable container root filesystemYes
What "no running containers" means here. Every check in this audit inspects running containers only. On a host where the daemon is up but nothing is running, all five report SKIP rather than PASS. A skip means the audit found nothing to judge, which is deliberately not the same statement as "your containers are hardened".

Check 1: Are any containers running in privileged mode?

High severityNeeds sudo

The --privileged flag disables every isolation boundary Docker provides. It grants every Linux capability, exposes every host device under /dev, and lets the container write kernel parameters through /proc and /sys. A privileged container is not meaningfully contained: it has root-equivalent access to the host.

The audit reads HostConfig.Privileged for every running container and names the ones that have it set. The only setting that passes is none of them.

How to check manually

docker ps -q | xargs docker inspect --format '{{if .HostConfig.Privileged}}{{.Name}}{{end}}' | tr -d '/'
Result thresholds this check applies
ResultWhenWhat it means
PASSNone of the running containers use --privilegedThe count of containers inspected is named in the result.
FAILOne or more containers have Privileged: trueThose containers are listed by name. Drop the flag and add back only the specific capabilities the workload needs.
SKIPThe Docker daemon is not reachable, or no containers are runningNothing to inspect. Check daemon state and your socket access before trusting this result.

There is no WARN branch on this check. Privileged mode is either on or it is not, and there is no configuration that makes it safe on a production host.

How to fix it

Remove --privileged and grant only what the workload actually uses:

# Instead of --privileged, grant only what the container requires
docker run --cap-drop ALL --cap-add NET_BIND_SERVICE --cap-add CHOWN your-image
services:
  app:
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
      - CHOWN

Most workloads need two or three capabilities, not all forty-odd. Start from --cap-drop ALL and add one back at a time until the application starts.

Inherited privilege is the common case. Almost nobody sets --privileged deliberately on a production service. It arrives from a tutorial, a debugging session or a CI runner config that got copied, and it never gets removed because nothing breaks and nothing warns. The flag produces no Docker log entry at all.

Check 2: Do containers block setuid privilege escalation?

High severityNeeds sudo

The no-new-privileges security option stops a process inside a container from gaining privileges it was not started with. Without it, a compromised unprivileged process can escalate to root inside the container by executing a setuid binary, and setuid binaries exist in almost every base image.

Docker does not set this option by default. The audit reads HostConfig.SecurityOpt for each running container and accepts either spelling the runtime emits, no-new-privileges:true or no-new-privileges=true.

How to check manually

docker ps -q | xargs -I{} docker inspect --format '{{.Name}} {{.HostConfig.SecurityOpt}}' {} | tr -d '/'
Result thresholds this check applies
ResultWhenWhat it means
PASSEvery running container sets no-new-privilegesSetuid escalation inside the container is blocked.
WARNOne or more containers do not set itThe count and up to five example container names are listed. Add --security-opt=no-new-privileges.
SKIPNo running containers to evaluateNothing to inspect.

This is a HIGH severity check that warns rather than fails, and the reason is base rate: on a typical host, no container sets it. A failure on the overwhelming majority of real servers would train people to ignore the finding, so the audit reports it as a warning with the specific container names attached.

How to fix it

docker run --security-opt=no-new-privileges your-image
services:
  app:
    security_opt:
      - no-new-privileges:true

It has no measurable performance cost and breaks very little. The exception is a container that intentionally relies on a setuid binary, such as ping on older images, where the binary needs its file capabilities set instead.

Bottom line: this is the cheapest hardening control in the audit. One line per container, no runtime cost, and it removes an entire class of in-container privilege escalation.

Check 3: Are containers running as a non-root user?

High severityNeeds sudo

When a container runs as root and no user namespace remapping is configured, container root is host root. An escape through a kernel bug, a misconfigured mount or a runc vulnerability such as CVE-2024-21626 lands the attacker directly as root on the host.

The audit reads Config.User for each running container and treats an empty value, 0, root, 0:... and root:... as root. It then checks docker info for user namespace remapping, which is what separates the warning branch from the failure branch.

How to check manually

docker ps -q | xargs -I{} docker inspect --format '{{.Name}} User={{.Config.User}}' {} | tr -d '/'

# and whether the daemon remaps container root to an unprivileged host uid
docker info --format '{{.SecurityOptions}}' | grep -o userns
Result thresholds this check applies
ResultWhenWhat it means
PASSEvery running container declares a non-root userThe count of containers is named in the result.
WARNContainers run as root but userns-remap is enabled on the daemonContainer root maps to an unprivileged host uid, so an escape does not land as host root. The containers are still listed, because a USER directive is better than relying on the remap.
FAILContainers run as root with no user namespace remappingContainer root is host root the moment anything escapes. Set USER in the image or pass -u at runtime.
SKIPNo running containers to evaluateNothing to inspect.

The WARN branch is the one worth understanding. userns-remap is a daemon-level setting, covered by check 7 of the Daemon and Socket audit. If it is on, a root container is a much smaller problem, which is why the same configuration produces a warning on one host and a failure on another.

How to fix it

# Create a dedicated user and switch to it before CMD
RUN groupadd -r appuser && useradd -r -g appuser appuser
USER appuser
# Or override at runtime for images you do not control
docker run -u 1000:1000 your-image

If the application needs to bind to a port below 1024, add --cap-add NET_BIND_SERVICE rather than running the whole process as root.

Official images default to root. nginx, redis and postgres all start as root unless you set a user, and several of them need to, because they drop privileges themselves after startup. Check what the process actually runs as with docker top before assuming the image author handled it.

Check 4: Are dangerous capabilities added or default capabilities retained?

Medium severityNeeds sudo

Docker containers start with a default set of fourteen Linux capabilities, which is already more than most workloads need. Adding SYS_ADMIN or NET_ADMIN on top of that is close to running --privileged, and keeping the default set without dropping anything leaves a wider attack surface than the workload requires.

The audit reads HostConfig.CapAdd and HostConfig.CapDrop per container. ALL, SYS_ADMIN or NET_ADMIN in CapAdd is the failing case. An empty CapDrop is the warning case.

How to check manually

docker ps -q | xargs -I{} docker inspect --format '{{.Name}} CapAdd={{.HostConfig.CapAdd}} CapDrop={{.HostConfig.CapDrop}}' {} | tr -d '/'
Result thresholds this check applies
ResultWhenWhat it means
PASSEvery running container drops capabilitiesCapDrop is non-empty everywhere and no dangerous capability was added.
WARNContainers keep Docker default capability set with no --cap-dropUp to five container names are listed. The fix is --cap-drop ALL followed by the specific capabilities the workload needs.
FAILA container adds ALL, SYS_ADMIN or NET_ADMINThose containers are named. This is near-root access, granted explicitly.
SKIPNo running containers to evaluateNothing to inspect.

The failure branch takes priority: if any container adds a dangerous capability, the check fails and reports those containers rather than reporting the much longer list of containers that simply never dropped anything.

How to fix it

docker run --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID your-image
WorkloadTypically needs
Web server (nginx, Apache)NET_BIND_SERVICE, CHOWN, SETUID, SETGID
Application (Node.js, Python)CHOWN, SETUID, SETGID
Networking toolNET_BIND_SERVICE, NET_RAW

If the application breaks after dropping everything, add capabilities back one at a time. Most need three or fewer.

Bottom line: SYS_ADMIN alone grants mount, namespace manipulation, BPF and dozens of other operations. It is the single most dangerous capability. If a container asks for it, the real question is whether Docker is the right isolation boundary for that workload at all.

Check 5: Does the container use a read-only root filesystem?

Low severityNeeds sudo

A read-only root filesystem stops a process inside the container from writing to the image filesystem: no dropping binaries, no rewriting configuration, no planting persistence. It does not prevent compromise, it narrows what a compromise can do next.

The audit reads HostConfig.ReadonlyRootfs for every running container and reports how many are writable.

How to check manually

docker ps -q | xargs -I{} docker inspect --format '{{.Name}} ReadonlyRootfs={{.HostConfig.ReadonlyRootfs}}' {} | tr -d '/'
Result thresholds this check applies
ResultWhenWhat it means
PASSEvery running container uses a read-only root filesystemWrites are confined to the paths you mounted deliberately.
WARNOne or more containers have a writable root filesystemThe count and up to five examples are listed. Add --read-only plus --tmpfs for scratch space.
SKIPNo running containers to evaluateNothing to inspect.

This is rated LOW because it is defence in depth rather than a boundary. It is also the check most likely to need testing before it ships, which is why it warns rather than fails.

How to fix it

docker run --read-only --tmpfs /tmp --tmpfs /var/run your-image
services:
  app:
    read_only: true
    tmpfs:
      - /tmp
      - /var/run

The directories that usually need a tmpfs mount are /tmp, /var/run and /var/cache, plus any application-specific temp directory. If the application writes logs to disk, give the log directory a named volume rather than reopening the whole filesystem.

Bottom line: --read-only plus two or three --tmpfs mounts covers most stateless web applications. Test it in staging first: the application tells you immediately if it needs a write path you missed.

What this audit does not cover

This audit reads the runtime configuration of running containers. It does not check:

  • Image-level security. Base image CVEs, embedded secrets and missing USER directives are the Image Vulnerabilities audit.
  • Daemon-level controls. Socket exposure, rootless mode and user namespace remapping at the daemon level are the Daemon and Socket audit.
  • Network isolation. Port publishing, inter-container connectivity and secrets in environment variables are the Network and Supply Chain audit.
  • Resource limits, seccomp and AppArmor. Those are the Runtime and Resources audit.
  • Kubernetes pod security. Pod security standards, admission controllers and cluster-level policy are a different surface with different tooling.

The manual audit problem at scale

Running these five checks by hand on one host is about three minutes of docker inspect invocations and reading Go template output. On ten servers it is half an hour. On fifty it is most of a day, and the results live in terminal scrollback where nobody compares them.

The harder problem is consistency. Manual audits drift: you check slightly different things each time, you miss the container that restarted between commands, and there is no record of what the host looked like last month.

TaskBy hand, 8 hostsCtrlOps, 8 hosts
Run all 5 checksAbout 25 minutesAbout 80 seconds
Get a scored reportNot availableAutomatic
Export a PDFNot availableOne click
Fix what failedCopy-paste from your notesAI-generated commands behind an approval gate
Re-audit after fixingAnother 25 minutesAnother 80 seconds

How CtrlOps runs all 5 checks in one click

Instead of running these commands on every server by hand, CtrlOps runs the whole Container Hardening audit over the SSH connection you already have open. Nothing is installed on the server: no agent, no daemon, no package, no new credentials. The checks are read-only POSIX shell that executes, streams its results back, and leaves nothing behind.

Watch it run

The recording below walks through the SSH and Access audit rather than this one, but the flow is identical for every audit in the catalog: pick it, watch the checks stream in, read the score, send the failures to the AI Terminal.

Step 1: Open the Audit tab

Connect to your server in CtrlOps and open the Audit tab in the left sidebar.

Step 2: Select Container Hardening

Choose it from the Docker category. All 5 checks are listed with their descriptions, severity levels and the estimated run time, about 10 seconds in total. Toggle off any check you want to skip.

Step 3: Run it

The audit runs over your existing SSH session. Results stream in as they complete, so you watch each check pass, warn or fail rather than waiting on a final report.

Step 4: Read the report

You get a summary containing:

  • A hardening score out of 100
  • A severity breakdown: how many HIGH, MEDIUM and LOW findings
  • Pass, warning, failed and skipped counts
  • A findings table you can sort and filter, with the offending container names attached to each finding

Step 5: Fix what failed

Select the failed findings and send them to the AI Terminal. Each proposed fix command appears with an explanation of what it changes. Nothing runs until you review and approve it, which matters here: every fix on this page requires recreating the container, because none of these flags can be changed on a running one.

Step 6: Re-run and compare

Run the same audit again after recreating the containers and watch the score move. This audit is worth re-running after every deployment, because a redeploy is exactly when a --privileged flag or a dropped USER directive comes back.

Conclusion

Container hardening is five flags, and the reason they are missing is almost never disagreement about whether they matter. They are missing because the container was created during an incident, or copied from a quickstart, or started with an extra flag to get past a permissions error at 2am and never cleaned up afterwards.

Three of these five are HIGH severity, and all three describe the same failure: a process inside a container that can reach the host. The CtrlOps Security Audit runs the set in about 10 seconds per server, agentlessly, and names the exact containers.

The neighbouring checklists cover the other container layers: daemon and socket, image vulnerabilities, volume permissions, runtime and resources and network and supply chain.


Frequently asked questions

Docker container hardening is the practice of reducing what a running container can do to its host: disabling privileged mode, running as a non-root user, dropping unnecessary Linux capabilities, blocking setuid escalation with no-new-privileges, and enforcing a read-only root filesystem. Every one of those is a runtime flag, which means hardening is undone as easily as it is applied, by one docker run with an extra argument.

Because without user namespace remapping, container root is host root. An escape through a kernel vulnerability, a bad mount or a runc bug puts the attacker on the host as root rather than as an unprivileged user. Running as a non-root user does not prevent the escape, it makes what follows the escape far less useful.

It removes every Linux capability from the container, so the process cannot perform privileged kernel operations at all. You then add back only what the application needs with --cap-add, typically two or three of the forty-odd available. This is least privilege applied to the kernel interface rather than to the filesystem.

Run docker inspect --format '{{.HostConfig.Privileged}}' <container>. If it returns true, that container has full host access. To check every running container at once: docker ps -q | xargs docker inspect --format '{{.Name}} {{.HostConfig.Privileged}}'.

Because Docker does not set it by default, so on a typical host every container lacks it. A HIGH severity failure on every container of every server trains people to ignore the finding, so the audit reports it as a warning and names the specific containers. The severity still reads HIGH, because what it prevents is serious.

--privileged grants every capability, exposes every host device and disables seccomp and AppArmor. --cap-add SYS_ADMIN grants one capability, but that capability covers mount operations, namespace manipulation and BPF, which is why it is described as near-root. Neither belongs on a production host without a written justification.

When the application writes to its own filesystem as part of normal operation and you cannot enumerate where. Some legacy applications write sessions, caches or temp files to non-standard paths, and finding all of them is more work than the control is worth for that one service. Most modern stateless applications run fine with --read-only plus tmpfs mounts for /tmp and /var/run.

Because all five checks inspect running containers. If the daemon is up but nothing is running, or if the audit account cannot reach the socket, there is nothing to judge and the honest answer is a skip rather than a pass. If you expected results and got skips, check docker ps and whether the account you audited with is in the docker group.

No. Privileged mode, the user, capabilities, security options and the read-only root filesystem are all fixed at creation. Applying any fix on this page means recreating the container, which is why the fixes belong in your Compose file or deployment manifest rather than in a one-off docker run.

Partly. The concepts are identical and the same flags exist, but this audit queries the Docker daemon. On a Podman-only host the Docker checks report skips. Podman's rootless-by-default model already removes the worst case that check 3 is looking for.

Audit your fleet

Run all these checks on every server, in one click

CtrlOps runs this audit over your existing SSH connection - no agents, no scripts to manage. $7/user/month after a 1 month free trial - no credit card required.

Windows

✓ Start instantly·✓ No credit card·✓ No sneaky autorenewals