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
USERdirective 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 inspectper host by hand.
| # | Check | Severity | What it catches | Root needed |
|---|---|---|---|---|
| 1 | Privileged containers | HIGH | --privileged, which grants every capability and device | Yes |
| 2 | No new privileges | HIGH | A missing no-new-privileges security option | Yes |
| 3 | Container user | HIGH | Containers running as UID 0 with no user namespace | Yes |
| 4 | Capabilities | MEDIUM | SYS_ADMIN, NET_ADMIN or ALL added, or nothing dropped | Yes |
| 5 | Read-only filesystem | LOW | A writable container root filesystem | Yes |
Check 1: Are any containers running in privileged mode?
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 | When | What it means |
|---|---|---|
| PASS | None of the running containers use --privileged | The count of containers inspected is named in the result. |
| FAIL | One or more containers have Privileged: true | Those containers are listed by name. Drop the flag and add back only the specific capabilities the workload needs. |
| SKIP | The Docker daemon is not reachable, or no containers are running | Nothing 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-imageservices:
app:
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
- CHOWNMost 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.
--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?
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 | When | What it means |
|---|---|---|
| PASS | Every running container sets no-new-privileges | Setuid escalation inside the container is blocked. |
| WARN | One or more containers do not set it | The count and up to five example container names are listed. Add --security-opt=no-new-privileges. |
| SKIP | No running containers to evaluate | Nothing 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-imageservices:
app:
security_opt:
- no-new-privileges:trueIt 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.
Check 3: Are containers running as a non-root user?
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 | When | What it means |
|---|---|---|
| PASS | Every running container declares a non-root user | The count of containers is named in the result. |
| WARN | Containers run as root but userns-remap is enabled on the daemon | Container 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. |
| FAIL | Containers run as root with no user namespace remapping | Container root is host root the moment anything escapes. Set USER in the image or pass -u at runtime. |
| SKIP | No running containers to evaluate | Nothing 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-imageIf the application needs to bind to a port below 1024, add --cap-add NET_BIND_SERVICE rather than running the whole process as 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?
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 | When | What it means |
|---|---|---|
| PASS | Every running container drops capabilities | CapDrop is non-empty everywhere and no dangerous capability was added. |
| WARN | Containers keep Docker default capability set with no --cap-drop | Up to five container names are listed. The fix is --cap-drop ALL followed by the specific capabilities the workload needs. |
| FAIL | A container adds ALL, SYS_ADMIN or NET_ADMIN | Those containers are named. This is near-root access, granted explicitly. |
| SKIP | No running containers to evaluate | Nothing 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| Workload | Typically needs |
|---|---|
| Web server (nginx, Apache) | NET_BIND_SERVICE, CHOWN, SETUID, SETGID |
| Application (Node.js, Python) | CHOWN, SETUID, SETGID |
| Networking tool | NET_BIND_SERVICE, NET_RAW |
If the application breaks after dropping everything, add capabilities back one at a time. Most need three or fewer.
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?
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 | When | What it means |
|---|---|---|
| PASS | Every running container uses a read-only root filesystem | Writes are confined to the paths you mounted deliberately. |
| WARN | One or more containers have a writable root filesystem | The count and up to five examples are listed. Add --read-only plus --tmpfs for scratch space. |
| SKIP | No running containers to evaluate | Nothing 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-imageservices:
app:
read_only: true
tmpfs:
- /tmp
- /var/runThe 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.
--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
USERdirectives 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.
| Task | By hand, 8 hosts | CtrlOps, 8 hosts |
|---|---|---|
| Run all 5 checks | About 25 minutes | About 80 seconds |
| Get a scored report | Not available | Automatic |
| Export a PDF | Not available | One click |
| Fix what failed | Copy-paste from your notes | AI-generated commands behind an approval gate |
| Re-audit after fixing | Another 25 minutes | Another 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.