Docker security checklist

Docker Runtime & Resources Checklist: 6 Checks (2026)

A container with no memory limit can take the whole host down, and a container with seccomp disabled can call syscalls the kernel should never see from inside one. This page documents all 6 checks in the CtrlOps Runtime and Resources 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
6
checks in this audit
0
rated high severity
10s
automated run time
6
need root or sudo

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

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 stats is 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.
#CheckSeverityWhat it catchesRoot needed
1Seccomp profileMEDIUMseccomp=unconfined on a running containerYes
2AppArmor / SELinuxMEDIUMNo MAC on the host, or unconfined containersYes
3Memory limitsMEDIUMContainers that can consume all host RAMYes
4CPU limitsLOWContainers that can monopolise every coreYes
5PID limitsLOWContainers with no fork-bomb containmentYes
6Restart policyLOWrestart=always, which retries foreverYes
Why nothing here is HIGH. These controls do not stop an attacker getting in, they decide what an incident costs once something has already gone wrong: whether one broken container is one broken service or a host that stopped answering. The checks that govern access live in Daemon and Socket and Container Hardening.

Check 1: Does any container disable the default seccomp profile?

Medium severityNeeds sudo

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 thresholds this check applies
ResultWhenWhat it means
PASSNo container disables the default seccomp profileEvery container runs with syscall filtering, whether that is the default profile or a custom one.
FAILOne or more containers run seccomp unconfinedThose containers are named. The default profile blocks around 44 dangerous syscalls, so this should never be set without a per-workload replacement profile.
SKIPNo running containers to evaluateNothing 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 nginx
services:
  app:
    security_opt:
      - seccomp:./custom-seccomp.json

Start from Docker's published default profile and add only the syscalls the workload needs, rather than writing one from scratch.

This flag travels from development to production by copy-paste. 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?

Medium severityNeeds sudo

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 thresholds this check applies
ResultWhenWhat it means
PASSA MAC system is present and every running container carries a profileContainers are confined by AppArmor or SELinux.
WARNNeither AppArmor nor SELinux is enforcing on the host, or containers have no profile appliedTwo 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.
SKIPNo running containers to evaluateNothing 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/config

Then remove any apparmor=unconfined from your run commands and Compose files so the docker-default profile applies again.

Bottom line: the 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?

Medium severityNeeds sudo

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 thresholds this check applies
ResultWhenWhat it means
PASSEvery running container has a memory limitOne leak cannot take the host with it.
WARNOne or more containers have no memory limitThe count out of the total and up to five container names are listed. Set --memory.
SKIPNo running containers to evaluateNothing 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 nginx

Setting --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: 512m

Size 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.

Run 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?

Low severityNeeds sudo

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 thresholds this check applies
ResultWhenWhat it means
PASSEvery running container has a CPU limitNo container can monopolise the host CPU.
WARNOne or more containers have neither NanoCpus nor CpuQuota setThe count out of the total and up to five container names are listed. Set --cpus.
SKIPNo running containers to evaluateNothing to inspect.

How to fix it

docker run --cpus 1.0 nginx     # one core
docker run --cpus 0.5 nginx     # half a core
services:
  app:
    deploy:
      resources:
        limits:
          cpus: "1.0"
    # Compose v2 without deploy:
    cpus: 1.0

Measure 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.

Bottom line: CPU limits are LOW because starvation degrades rather than destroys. Set them anyway. One container consuming eight cores makes every other service on the host unresponsive, and from the outside that is indistinguishable from an outage.

Check 5: Do all containers have PID limits?

Low severityNeeds sudo

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 thresholds this check applies
ResultWhenWhat it means
PASSEvery running container caps its process countA fork bomb hits the container limit rather than the host one.
WARNOne or more containers have no PID limitThe count out of the total and up to five container names are listed. Set --pids-limit.
SKIPNo running containers to evaluateNothing to inspect.

How to fix it

docker run --pids-limit 256 nginx
docker run --pids-limit 512 php:fpm   # process-heavy workloads
services:
  app:
    pids_limit: 256

For 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
}
Bottom line: a typical web server runs 10 to 30 processes and a PHP-FPM pool 50 to 100. Set the limit to two or three times the normal process count. The container then dies on its own when something forks in a loop, and the host stays reachable.

Check 6: Does any container use an unbounded restart policy?

Low severityNeeds sudo

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 thresholds this check applies
ResultWhenWhat it means
PASSNo container uses the unbounded always policyCrash loops are either bounded by a retry count or stop where you stopped them.
WARNOne or more containers use restart=alwaysThe count and up to five container names are listed. Prefer on-failure with a maximum retry count.
SKIPNo running containers to evaluateNothing 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 stop
services:
  app:
    restart: "on-failure:5"

The three policies differ in one sentence each:

  • always restarts forever, including after you stopped it by hand
  • on-failure:5 restarts only on a non-zero exit, up to five times
  • unless-stopped restarts after crashes and reboots, but not after a manual stop
A broken database container restarting a thousand times an hour fills the disk with crash logs. That is also how the container log limits check turns from a LOW finding into an outage: unbounded restarts plus unbounded logs is a full filesystem in hours.

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.

TaskBy hand, 8 hostsCtrlOps, 8 hosts
Run all 6 checksAbout 40 minutesAbout 80 seconds
Seccomp detectionParse SecurityOpt per containerBoth spellings, every container
AppArmor and SELinuxSeparate host and container checksHost state and container profiles in one pass
Resource limitsThree inspections per containerOne scan covers memory, CPU and PID
Re-audit after fixingAnother 40 minutesAnother 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.

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