Skip to main content
Cloud Security · lesson 3 of 8
Cloud Security intermediate Lesson 3 of 8

Also part of: Docker

CIS Docker Benchmark Checks with docker-bench-security

Run the CIS Docker Benchmark's container checks against a container you start, read each WARN, fix privileged mode and missing limits, and re-run to PASS.

By SkillByExample · Published

cloud-security docker cis-benchmark hardening

The CIS Docker Benchmark is the hardening baseline most audits use for containers. docker-bench-security checks a host and its containers against it and prints PASS or WARN per recommendation. The host and daemon sections describe whatever machine Docker runs on, which on Docker Desktop is a hidden Linux VM, so this lesson uses the part that means the same everywhere: section 5, the runtime settings of containers you start.

Tested with docker-bench-security v1.6.1 (its banner says v1.6.0), CIS Docker Benchmark 1.6.0, nginx:1.29-alpine and Docker Engine 29.8.2 on Docker Desktop. The project’s own image is years old and its Docker client is too old for Engine 29, so build a runner on the current CLI:

# Dockerfile: docker-bench-security v1.6.1 with a current Docker CLI
FROM docker:29-cli
RUN apk add --no-cache bash git procps
RUN git clone --depth 1 --branch v1.6.1 \
      https://github.com/docker/docker-bench-security.git /bench
WORKDIR /bench
ENTRYPOINT ["bash", "docker-bench-security.sh"]
$ docker build -q -t local/docker-bench:1.6.1 .
sha256:a3f32510f0630bde8f71824e0a4b73b6915a03db1a2ae1500519019ea64c13ca

The runner only needs the Docker socket, read-only, to inspect containers. On Git Bash for Windows, prefix the docker run commands below with MSYS_NO_PATHCONV=1 so the socket and tmpfs paths aren’t rewritten.

Scan a container started the careless way

--privileged is the classic shortcut when something “needs permissions”:

$ docker run -d --name shop --privileged nginx:1.29-alpine
fae189bb2d9eceddb8f57de77e7cc0fd336d28106f7377d779a81eb9d5bd45f7
$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock:ro local/docker-bench:1.6.1 -b -i shop -c check_5_5,check_5_11,check_5_26,check_5_29 2>/dev/null
# --------------------------------------------------------------------------------------------
# Docker Bench for Security v1.6.0
#
# Docker, Inc. (c) 2015-2026
#
# Checks for dozens of common best-practices around deploying Docker containers in production.
# Based on the CIS Docker Benchmark 1.6.0.
# --------------------------------------------------------------------------------------------

Initializing 2026-10-14T15:03:45


Section A - Check results
[WARN] 5.5 - Ensure that privileged containers are not used (Automated)
[WARN]      * Container running in Privileged mode: shop
[WARN] 5.11 - Ensure that the memory usage for containers is limited (Automated)
[WARN]       * Container running without memory restrictions: shop
[WARN] 5.26 - Ensure that the container is restricted from acquiring additional privileges (Automated)
[WARN]       * Privileges not restricted: shop
[WARN] 5.29 - Ensure that the PIDs cgroup limit is used (Automated)
[WARN]       * PIDs limit not set: shop


Section C - Score

[INFO] Checks: 4
[INFO] Score: -4

-b turns off colour, -i shop limits the scan to containers whose name contains shop, and -c picks four checks by id. All four warn:

  • 5.5: privileged mode gives the container every capability and the host’s devices.
  • 5.11: with no memory limit, one leaking container can exhaust the host.
  • 5.26: without no-new-privileges, a setuid binary inside can still raise privileges.
  • 5.29: with no PID limit, a fork bomb in one container takes down the others.

The score is +1 per PASS and -1 per WARN. The 2>/dev/null hides a harmless quirk: v1.6.1 parses docker images output, Docker 29 changed its columns, and the script prints error: no such object: 98.4MB lines for each image size before the checks run. The checks themselves use docker inspect and are unaffected.

Fix the run flags and re-scan

None of the four needs code changes, only how the container is started:

$ docker rm -f shop
shop
$ docker run -d --name shop --memory 256m --pids-limit 100 --security-opt no-new-privileges nginx:1.29-alpine
f8c78168dfa101b73490f5dbfddef348fcbae6dde982fc866522f9547d911100
$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock:ro local/docker-bench:1.6.1 -b -i shop -c check_5_5,check_5_11,check_5_26,check_5_29 2>/dev/null | sed -n '/Section A/,$p'
Section A - Check results
[PASS] 5.5 - Ensure that privileged containers are not used (Automated)
[PASS] 5.11 - Ensure that the memory usage for containers is limited (Automated)
[PASS] 5.26 - Ensure that the container is restricted from acquiring additional privileges (Automated)
[PASS] 5.29 - Ensure that the PIDs cgroup limit is used (Automated)


Section C - Score

[INFO] Checks: 4
[INFO] Score: 4

$ docker exec shop wget -qO- http://127.0.0.1/ | grep -o "<title>.*</title>"
<title>Welcome to nginx!</title>

Four PASSes, score 4, and nginx still serves its page. It never needed --privileged: it binds port 80 as root inside its own namespace and then drops to the nginx user, which the default capability set allows. That is the usual story with privileged mode. It is added to make an error go away, and the real requirement is one capability or none.

The whole runtime section

Four checks were the ones that mattered most. The full section shows what is left:

$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock:ro local/docker-bench:1.6.1 -b -i shop -c container_runtime 2>/dev/null | grep -E '^\[(WARN|NOTE)\] [0-9]|^\[INFO\] (Checks|Score)'
[WARN] 5.2 - Ensure that, if applicable, an AppArmor Profile is enabled (Automated)
[WARN] 5.12 - Ensure that CPU priority is set appropriately on containers (Automated)
[WARN] 5.13 - Ensure that the container's root filesystem is mounted as read only (Automated)
[WARN] 5.15 - Ensure that the 'on-failure' container restart policy is set to '5' (Automated)
[NOTE] 5.23 - Ensure that docker exec commands are not used with the privileged option (Automated)
[NOTE] 5.24 - Ensure that docker exec commands are not used with the user=root option (Manual)
[WARN] 5.27 - Ensure that container health is checked at runtime (Automated)
[INFO] Checks: 32
[INFO] Score: 16

Of 32 checks, five still warn. They aren’t equal. A read-only root filesystem (5.13) is a real reduction in what an attacker can change. A health check (5.27) and CPU shares (5.12) are operational hygiene. The restart policy (5.15) is a judgement call. AppArmor (5.2) can’t pass on Docker Desktop at all: its VM offers seccomp but no AppArmor, so the container has no AppArmor profile to report. On an Ubuntu server, Docker applies its docker-default profile and 5.2 passes. Run the container section locally and the host sections on the real servers.

Practice

  1. Start a second container with --read-only alone to clear 5.13. What happens to nginx?
Solution
$ docker run -d --name shop-ro --read-only nginx:1.29-alpine >/dev/null
$ docker ps -a --filter name=shop-ro --format '{{.Status}}'
Exited (1) 2 seconds ago
$ docker logs shop-ro 2>&1 | tail -2
2026/10/14 15:05:45 [emerg] 1#1: mkdir() "/var/cache/nginx/client_temp" failed (30: Read-only file system)
nginx: [emerg] mkdir() "/var/cache/nginx/client_temp" failed (30: Read-only file system)

nginx needs to write its cache and PID file. A read-only root filesystem breaks it until you give it writable scratch space for exactly those paths, which is the next task.

  1. Clear 5.12, 5.13 and 5.27 on shop-ro while keeping the four fixes from the lesson.
Solution

Mount tmpfs for the two paths nginx writes, set CPU shares and add a health check:

$ docker rm -f shop-ro
shop-ro
$ docker run -d --name shop-ro --read-only --tmpfs /var/cache/nginx --tmpfs /run --memory 256m --pids-limit 100 --security-opt no-new-privileges --cpu-shares 512 --health-cmd "wget -q -O /dev/null http://127.0.0.1/ || exit 1" nginx:1.29-alpine >/dev/null
$ sleep 35
$ docker ps --filter name=shop-ro --format '{{.Status}}'
Up 35 seconds (healthy)
$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock:ro local/docker-bench:1.6.1 -b -i shop-ro -c check_5_12,check_5_13,check_5_27 2>/dev/null | sed -n '/Section A/,$p'
Section A - Check results
[PASS] 5.12 - Ensure that CPU priority is set appropriately on containers (Automated)
[PASS] 5.13 - Ensure that the container's root filesystem is mounted as read only (Automated)
[PASS] 5.27 - Ensure that container health is checked at runtime (Automated)


Section C - Score

[INFO] Checks: 3
[INFO] Score: 3

The root filesystem is read-only, yet nginx is up and healthy. Anything an attacker writes now lands in a tmpfs that vanishes on restart.

  1. Why can 5.2 pass on an Ubuntu server but not on Docker Desktop? Find the evidence with docker info.
Solution
$ docker info --format '{{.OperatingSystem}} | {{.SecurityOptions}}'
Docker Desktop | [name=seccomp,profile=builtin]

Only seccomp is listed. On an Ubuntu host with AppArmor enabled the list also contains name=apparmor, and Docker attaches the docker-default profile to every container, which is what 5.2 checks for. A benchmark result is always a statement about a particular host.

  1. With both shop and shop-ro running, what does -i shop scan? How do you scan only shop?
Solution

-i is an extended regular expression matched against container names, so shop matches both. Neither container sets a restart policy, so check 5.15 shows it:

$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock:ro local/docker-bench:1.6.1 -b -i shop -c check_5_15 2>/dev/null | grep -E '^\[WARN\]'
[WARN] 5.15 - Ensure that the 'on-failure' container restart policy is set to '5' (Automated)
[WARN]       * MaximumRetryCount is not set to 5: shop-ro
[WARN]       * MaximumRetryCount is not set to 5: shop
$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock:ro local/docker-bench:1.6.1 -b -i '^shop$' -c check_5_15 2>/dev/null | grep -E '^\[WARN\]'
[WARN] 5.15 - Ensure that the 'on-failure' container restart policy is set to '5' (Automated)
[WARN]       * MaximumRetryCount is not set to 5: shop

Anchor the pattern. Adding -x ro doesn’t help: in v1.6.1 the script checks -i first and ignores -x whenever -i is set, so -i shop -x ro still scans both. Read the container names in the WARN lines before trusting a result.

Frequently Asked Questions

What is the CIS Docker Benchmark?
The CIS Docker Benchmark is a free hardening guide from the Center for Internet Security. It lists recommended settings for the Docker host, the daemon, images and running containers, such as not running privileged containers and limiting memory and process counts. Auditors and security teams use it as a common baseline for container hardening.
What does docker-bench-security do?
docker-bench-security is an open-source shell script from Docker that checks a host and its containers against the CIS Docker Benchmark. It reads settings through the Docker API and the host filesystem and prints PASS, WARN, INFO or NOTE per recommendation, plus a score. You can limit it to specific checks with -c and to specific containers with -i.
Why does docker-bench-security warn about privileged containers?
A container started with --privileged gets every Linux capability and access to the host's devices, so a process that escapes or abuses it effectively has root on the host. CIS recommendation 5.5 says not to use privileged containers. The fix is to grant only the specific capabilities or devices the workload needs, which is usually none.
Which docker run flags fix the most common CIS container warnings?
Dropping --privileged, setting --memory and --pids-limit, and adding --security-opt no-new-privileges clear recommendations 5.5, 5.11, 5.29 and 5.26. A read-only root filesystem with --read-only plus tmpfs mounts for paths the app writes, --cpu-shares and a --health-cmd clear 5.13, 5.12 and 5.27.
Can every CIS check pass on Docker Desktop?
No. Some checks depend on the host. Docker Desktop's Linux VM offers seccomp but not AppArmor, so recommendation 5.2 about AppArmor profiles cannot pass there, and many host and daemon checks describe the VM rather than your machine. Run the container checks locally, and the host checks on the real Linux servers that run production.