One container with no memory limit can OOM-kill every other process on the host, your SSH session included. One fork bomb in a container with no PID limit fills the host process table, and nothing can spawn afterwards, including the shell you need to fix it. This page documents the 6 checks in the CtrlOps Runtime and Resources audit, in the order the audit runs them.
Two of these checks are about what a container can ask the kernel to do, and four are about how much of the host one container can consume. Every threshold below is transcribed from the audit script itself, not from general advice.
Key takeaways
Six checks, none rated HIGH, and that severity spread is the point: this audit is about blast radius rather than about access.
- Two are MEDIUM by syscall surface: seccomp and MAC confinement. Memory limits is the third MEDIUM. CPU, PID and restart policy are LOW.
- All six need root, or docker group membership, because all six query the Docker daemon for running containers.
- Only seccomp can fail. Everything else warns, because an unset limit is the Docker default rather than a choice somebody made, and because the right limit depends on the workload.
- The resource checks read the config, not the usage. A container with a 512 MB limit passes whether it uses 20 MB or 511 MB. Sizing the limit is your job,
docker statsis how you do it. - The whole audit takes about 10 seconds when CtrlOps runs it over your existing SSH connection, against roughly 30 inspection commands on a host with 5 containers.
| # | Check | Severity | What it catches | Root needed |
|---|---|---|---|---|
| 1 | Seccomp profile | MEDIUM | seccomp=unconfined on a running container | Yes |
| 2 | AppArmor / SELinux | MEDIUM | No MAC on the host, or unconfined containers | Yes |
| 3 | Memory limits | MEDIUM | Containers that can consume all host RAM | Yes |
| 4 | CPU limits | LOW | Containers that can monopolise every core | Yes |
| 5 | PID limits | LOW | Containers with no fork-bomb containment | Yes |
| 6 | Restart policy | LOW | restart=always, which retries forever | Yes |
Check 1: Does any container disable the default seccomp profile?
Docker's default seccomp profile blocks roughly 44 syscalls that have no legitimate use inside most containers: mount, reboot, keyctl, ptrace and others. Running with --security-opt seccomp=unconfined removes the filter entirely and gives the container process every syscall the host kernel supports.
The audit reads HostConfig.SecurityOpt for each running container and matches both spellings the runtime emits, seccomp=unconfined and seccomp:unconfined.
How to check manually
docker ps -q | while read c; do
name=$(docker inspect --format '{{.Name}}' "$c" | tr -d '/')
so=$(docker inspect --format '{{.HostConfig.SecurityOpt}}' "$c")
echo "$so" | grep -q 'seccomp=unconfined' && echo "$name: seccomp disabled"
done| Result | When | What it means |
|---|---|---|
| PASS | No container disables the default seccomp profile | Every container runs with syscall filtering, whether that is the default profile or a custom one. |
| FAIL | One or more containers run seccomp unconfined | Those containers are named. The default profile blocks around 44 dangerous syscalls, so this should never be set without a per-workload replacement profile. |
| SKIP | No running containers to evaluate | Nothing to inspect. |
This is the only check on the page that fails rather than warns, because unlike an unset resource limit, seccomp=unconfined is not a default. Somebody typed it.
How to fix it
Remove the override. If a workload genuinely needs syscalls the default profile blocks, replace the profile rather than removing it:
# The default profile applies when you pass nothing at all
docker run --rm nginx
# A custom profile for workloads that need specific syscalls
docker run --security-opt seccomp=./custom-seccomp.json nginxservices:
app:
security_opt:
- seccomp:./custom-seccomp.jsonStart from Docker's published default profile and add only the syscalls the workload needs, rather than writing one from scratch.
seccomp=unconfined gets added to make a debugger, a profiler or a container-in-container setup work locally, and the Compose file carries it to the server. Grep your production configs for it now: one unconfined container gives an attacker ptrace to attach to other processes and mount to reach host filesystems.Check 2: Are containers confined by AppArmor or SELinux?
AppArmor and SELinux are mandatory access control systems: they restrict what a process can do even when it runs as root inside its container. Docker applies the docker-default AppArmor profile automatically on distributions where AppArmor is present, and SELinux does the equivalent on RHEL family hosts.
The audit checks two things in order. First, whether the host has AppArmor available or SELinux in enforcing mode. Then, where AppArmor is present, whether each running container has a profile applied, treating an empty profile or unconfined as a finding unless SELinux labelling is in use for that container.
How to check manually
sudo aa-status 2>/dev/null | head -1
getenforce 2>/dev/null
docker ps -q | while read c; do
name=$(docker inspect --format '{{.Name}}' "$c" | tr -d '/')
echo "$name: AppArmor=$(docker inspect --format '{{.AppArmorProfile}}' "$c")"
done| Result | When | What it means |
|---|---|---|
| PASS | A MAC system is present and every running container carries a profile | Containers are confined by AppArmor or SELinux. |
| WARN | Neither AppArmor nor SELinux is enforcing on the host, or containers have no profile applied | Two distinct warnings. The host-level one comes first, because Docker cannot apply container profiles without a host MAC system. The container-level one names the unconfined containers and tells you to keep the docker-default profile. |
| SKIP | No running containers to evaluate | Nothing to inspect. |
The ordering matters when you read a report across a fleet. A host-level warning means no container on that machine is confined, no matter what any individual container specifies, so fix the host before looking at the container list.
How to fix it
# Ubuntu and Debian: AppArmor is usually installed already
sudo aa-status
sudo apt-get install apparmor apparmor-utils -y
sudo systemctl enable --now apparmor# RHEL family: SELinux
getenforce
sudo setenforce 1
sudo sed -i 's/^SELINUX=.*/SELINUX=enforcing/' /etc/selinux/configThen remove any apparmor=unconfined from your run commands and Compose files so the docker-default profile applies again.
docker-default AppArmor profile blocks writes to /proc and /sys, mounting filesystems and raw socket access. Removing it hands those back to a process that may be running as root inside the container. Keep the default unless you have written a replacement.Check 3: Do all containers have memory limits?
A container with no memory limit can consume all available host RAM. When it does, the kernel OOM killer starts terminating processes by its own scoring, which frequently means other containers, system daemons or sshd. You lose access to the host at the moment you most need it.
The audit reads HostConfig.Memory for each running container. A value of 0 means no limit.
How to check manually
docker ps -q | while read c; do
name=$(docker inspect --format '{{.Name}}' "$c" | tr -d '/')
[ "$(docker inspect --format '{{.HostConfig.Memory}}' "$c")" = "0" ] && echo "$name: no memory limit"
done| Result | When | What it means |
|---|---|---|
| PASS | Every running container has a memory limit | One leak cannot take the host with it. |
| WARN | One or more containers have no memory limit | The count out of the total and up to five container names are listed. Set --memory. |
| SKIP | No running containers to evaluate | Nothing to inspect. |
The check reads the configured limit, not the usage. A container capped at 512 MB passes whether it is using 20 MB or bumping against the cap, which means a pass here says the containment exists, not that the number is right.
How to fix it
docker run --memory 512m --memory-swap 512m nginxSetting --memory-swap equal to --memory disables swap inside the container, which stops a leaking process from dragging the host into swap thrash instead of failing fast.
services:
app:
deploy:
resources:
limits:
memory: 512m
# Compose v2 without deploy:
mem_limit: 512mSize it from observed peak usage plus roughly 20 percent headroom. Run docker stats for a week before setting a hard cap on anything you have not measured.
docker stats --no-stream on a production host. Any container showing 0B / 0B in the MEM USAGE / LIMIT column has no cap. A single Node.js leak or an unbounded JVM heap on that container will eat host RAM until the OOM killer picks a victim, and it does not preferentially pick the container that caused it.Check 4: Do all containers have CPU limits?
A container with no CPU limit can consume every cycle on the host. That does not crash the machine the way memory exhaustion does, it starves every other container and host process until the box appears dead while technically working perfectly.
The audit reads HostConfig.NanoCpus and HostConfig.CpuQuota. Both at 0 means no limit, which covers both the --cpus and the older --cpu-quota ways of setting one.
How to check manually
docker ps -q | while read c; do
name=$(docker inspect --format '{{.Name}}' "$c" | tr -d '/')
nano=$(docker inspect --format '{{.HostConfig.NanoCpus}}' "$c")
quota=$(docker inspect --format '{{.HostConfig.CpuQuota}}' "$c")
[ "$nano" = "0" ] && [ "$quota" = "0" ] && echo "$name: no CPU limit"
done| Result | When | What it means |
|---|---|---|
| PASS | Every running container has a CPU limit | No container can monopolise the host CPU. |
| WARN | One or more containers have neither NanoCpus nor CpuQuota set | The count out of the total and up to five container names are listed. Set --cpus. |
| SKIP | No running containers to evaluate | Nothing to inspect. |
How to fix it
docker run --cpus 1.0 nginx # one core
docker run --cpus 0.5 nginx # half a coreservices:
app:
deploy:
resources:
limits:
cpus: "1.0"
# Compose v2 without deploy:
cpus: 1.0Measure first with docker stats --no-stream, then set the limit from observed peak plus headroom. A typical web server needs 0.5 to 1.0 CPUs, a background worker often less.
Check 5: Do all containers have PID limits?
A fork bomb inside a container with no PID limit creates processes until the host PID table is full. Once it is, nothing on the host can spawn a new process: not your SSH login, not systemd, not the Docker daemon. The remediation window closes before you can use it.
The audit reads HostConfig.PidsLimit and treats an unset value or anything at or below zero as no limit.
How to check manually
docker ps -q | while read c; do
name=$(docker inspect --format '{{.Name}}' "$c" | tr -d '/')
pids=$(docker inspect --format '{{.HostConfig.PidsLimit}}' "$c")
case "$pids" in 0|-1|'<nil>'|'') echo "$name: no PID limit" ;; esac
done| Result | When | What it means |
|---|---|---|
| PASS | Every running container caps its process count | A fork bomb hits the container limit rather than the host one. |
| WARN | One or more containers have no PID limit | The count out of the total and up to five container names are listed. Set --pids-limit. |
| SKIP | No running containers to evaluate | Nothing to inspect. |
How to fix it
docker run --pids-limit 256 nginx
docker run --pids-limit 512 php:fpm # process-heavy workloadsservices:
app:
pids_limit: 256For a host-wide default, add this to /etc/docker/daemon.json and restart the daemon. Existing containers keep their current limit until they are recreated:
{
"default-pids-limit": 256
}Check 6: Does any container use an unbounded restart policy?
restart: always tells Docker to restart a container every time it exits, no matter how many times and no matter why. A container in a crash loop burns CPU on repeated startup, floods the logs and can fill the disk, and Docker's backoff is brief enough that a broken container restarts thousands of times an hour.
The audit reads HostConfig.RestartPolicy.Name and flags always specifically. unless-stopped passes, because it respects a manual stop.
How to check manually
docker inspect --format '{{.Name}} {{.HostConfig.RestartPolicy.Name}}' $(docker ps -q) | tr -d '/'| Result | When | What it means |
|---|---|---|
| PASS | No container uses the unbounded always policy | Crash loops are either bounded by a retry count or stop where you stopped them. |
| WARN | One or more containers use restart=always | The count and up to five container names are listed. Prefer on-failure with a maximum retry count. |
| SKIP | No running containers to evaluate | Nothing to inspect. |
This is the check people most often decide to live with, and that is a reasonable call for a service that must come back after a host reboot. The distinction the audit is drawing is between "come back after a reboot", which is unless-stopped, and "retry forever whatever happens", which is always.
How to fix it
docker run --restart on-failure:5 nginx # bounded retries
docker run --restart unless-stopped nginx # survives reboots, respects a manual stopservices:
app:
restart: "on-failure:5"The three policies differ in one sentence each:
alwaysrestarts forever, including after you stopped it by handon-failure:5restarts only on a non-zero exit, up to five timesunless-stoppedrestarts after crashes and reboots, but not after a manual stop
What this audit does not cover
This audit reads syscall filtering, MAC confinement and resource limits on running containers. It does not check:
- Container-level hardening. Privileged mode, capabilities, the runtime user and read-only filesystems are the Container Hardening audit.
- Daemon-level controls. Socket exposure, rootless mode and log rotation are the Daemon and Socket audit.
- Image contents. CVE scanning, embedded secrets and EOL base images are the Image Vulnerabilities audit.
- Network exposure. Port publishing, inter-container connectivity and secrets in environment variables are the Network and Supply Chain audit.
- Volume permissions. Sensitive host mounts, ownership and writable volumes are the Volume Permissions audit.
- Host-level resource pressure. Whether the host has enough RAM and CPU for the limits you set is a capacity question, not a configuration one.
The manual audit problem at scale
These six checks mean inspecting every running container's security options, host config and restart policy. On one host with five containers that is about thirty docker inspect invocations, and you still have to remember which field means "unset" for PidsLimit, NanoCpus and CpuQuota, because all three spell it differently.
| Task | By hand, 8 hosts | CtrlOps, 8 hosts |
|---|---|---|
| Run all 6 checks | About 40 minutes | About 80 seconds |
| Seccomp detection | Parse SecurityOpt per container | Both spellings, every container |
| AppArmor and SELinux | Separate host and container checks | Host state and container profiles in one pass |
| Resource limits | Three inspections per container | One scan covers memory, CPU and PID |
| Re-audit after fixing | Another 40 minutes | Another 80 seconds |
How CtrlOps runs all 6 checks in one click
Instead of running these commands on every server by hand, CtrlOps runs the whole Runtime and Resources 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 Runtime & Resources
Choose it from the Docker category. All 6 checks are listed with their descriptions, severity levels and the estimated run time, about 10 seconds in total. All six need root, and the UI says so before you run anything.
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 naming which containers are missing which limit
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. Every fix on this page needs the container recreated, so the durable change belongs in your Compose file rather than in a docker run.
Step 6: Re-run and compare
Run the same audit again after recreating the containers and watch the score move. This is a good audit to run right after a deployment, because a new service is exactly what arrives without limits.
Conclusion
Nothing on this page stops an intruder. What it decides is what a bad afternoon costs: whether a memory leak takes out one service or the whole host, whether a fork bomb is contained or locks you out, whether a crash-looping container is a notification or a full disk.
Seccomp is the one to fix on sight, because unconfined is never a default and somebody chose it. The rest are warnings because the numbers depend on your workloads, which is exactly why they are still sitting unset. The CtrlOps Security Audit runs the set in about 10 seconds per server and names the containers.
The neighbouring checklists cover the other container layers: container hardening, daemon and socket, image vulnerabilities, volume permissions and network and supply chain.
Frequently asked questions
A seccomp profile is a kernel-level syscall filter applied to the container process. Docker's default profile blocks roughly 44 syscalls that containers have no business making, including mount, reboot, keyctl and ptrace. Because it is enforced by the kernel, the process cannot bypass it. Replace it with a custom profile if a workload needs more, but do not remove it.
Use --memory with docker run, for example docker run --memory 512m nginx, or deploy.resources.limits.memory in Compose. Set --memory-swap to the same value to disable in-container swap, so a leak fails fast instead of dragging the host into swap. Size it from a week of docker stats observations plus about 20 percent headroom.
A fork bomb inside it creates processes until the host PID table is full. After that nothing on the host can spawn a process, including your SSH login, systemd and the Docker daemon itself, so remote remediation stops being possible. --pids-limit 256 contains it while leaving ordinary workloads plenty of room.
Because it restarts a container indefinitely, including after a manual docker stop and after thousands of consecutive failures. A crash loop then burns CPU on repeated startup and floods the logs, which can fill the disk. Use on-failure:5 to bound the retries, or unless-stopped for services that must survive a host reboot but should stay stopped when you stop them.
Yes. It reads every running container's SecurityOpt for seccomp status, matching both the seccomp=unconfined and seccomp:unconfined spellings, and it checks the host MAC system and each container's profile assignment in the same pass. All 6 checks complete in about 10 seconds over SSH with nothing installed on the server.
On Ubuntu and Debian, run sudo aa-status for the host and docker inspect --format '{{.AppArmorProfile}}' <container> per container. On RHEL family, run getenforce. If the host has no enforcing MAC system, Docker cannot apply container profiles at all, so fix the host before looking at individual containers.
Because an unset limit is Docker's default rather than a decision, and the correct value depends on the workload. A failure on every container of every host would be noise. Seccomp is the exception: unconfined is not a default, so somebody set it deliberately and the audit fails it.
No. The check reads whether a limit exists, not whether it is the right size. A container capped at 64 MB that needs 512 MB passes this check and then gets OOM-killed on its own. Set the number from measured peak usage, and treat the pass as confirmation that the containment exists.
In production, always set them. The narrow exception is local development where you are profiling or debugging and need unrestricted resources, and even there generous limits beat none, because a runaway process freezes your workstation. Match production limits in CI so resource problems surface before deployment rather than after.
Memory, CPU, PID limits and the restart policy can all be changed on a running container with docker update, for example docker update --memory 512m --pids-limit 256 <container>. Seccomp and AppArmor profiles cannot: those are fixed at creation. Either way the durable fix belongs in the Compose file, because a docker update is undone by the next redeploy.