Securing Docker in production means reducing what a compromised container
can reach: the daemon that runs as root, the kernel shared with the host,
the privileges Docker grants by default. This guide is for a DevOps engineer
who already runs containers and wants to apply least privilege at every
layer. Every setting is measured on Docker Engine 29.1.3: 14
capabilities granted by default and 41 with --privileged, an nginx that
starts with only three capabilities, a --read-only that brings it down as
long as three folders are not on tmpfs.
What you will learn
- Threat model: understand the Docker attack vectors
- Isolation: namespaces, cgroups, capabilities
- Rootless mode: Docker without root privileges
- Security profiles: seccomp, AppArmor, SELinux
- Secure images: vulnerability scanning, provenance, signatures
- Runtime: read-only filesystem, no-new-privileges, user namespaces
Why does a container not isolate like a VM?
Containers are not VMs: a container shares the host's kernel, which makes it light and quick to start, but creates a different attack surface. All the protections in this guide, capabilities, seccomp, AppArmor, rootless, serve to narrow down what this shared kernel exposes to the container process.
Prerequisites
This guide assumes a working Docker installation and some familiarity with its daemon configuration.
The installation guide (in French) gets Docker running, and the daemon configuration guide (in French) explains daemon.json, the file through which most of the hardening settings shown here apply at the level of the daemon rather than of each container.
- Basic Linux knowledge (users, permissions)
- Root or sudo access on the system
- A recent Docker Engine: the measurements in this guide were made on version 29.1.3
Understanding the Docker threat model
Before securing, understand what you are protecting and against what. The
diagram stacks the layers a process goes through, from the container
down to the shared kernel: a container started with --privileged
bypasses the isolation and targets the host directly.
The Docker daemon runs as root
By default, the Docker daemon (dockerd) runs as root. Anyone who can talk to the Docker socket (/var/run/docker.sock) can:
- Start containers with
--privileged - Mount any directory of the host
- Access all the data on the system
Consequence: adding a user to the docker group is equivalent to giving them root privileges.
# ❌ This command gives root access to the hostdocker run -it --rm -v /:/host alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 chroot /hostMeasured, even read-only (-v /:/host:ro): the container process runs as
root and the host's /etc/shadow file, which holds the password
hashes, is readable from the container. Anyone who can run
docker run has this access.
Containers ≠ VMs
The table compares the two isolation models on the criteria that matter for security: what is shared, what separates the workloads, and how likely an escape is.
| Characteristic | Container | VM |
|---|---|---|
| Kernel | Shared with the host | Its own kernel |
| Isolation | Namespaces + cgroups | Hypervisor |
| Attack surface | Host kernel | Hypervisor |
| Overhead | Minimal | Significant |
| Escape | Easier | Rare |
A container is only a Linux process isolated by namespaces (a restricted view of the system) and cgroups (resource limits). This isolation is software-based, unlike the hardware virtualisation of VMs.
Main attack vectors
These five vectors do not demand the same effort from an attacker, and that is what should guide your priorities. Points 1, 3 and 4 (image, misconfiguration, application CVE) trigger without any kernel flaw: they are supply chain or configuration mistakes, and they account for the overwhelming majority of real compromises. Point 2 (kernel escape) is the most spectacular but also the rarest, it requires an unpatched vulnerability. In other words, hardening your images and your launch options protects you from more scenarios than chasing the latest kernel flaw.
- Malicious image: downloading a compromised image
- Container escape: exploiting a kernel flaw to break out of the isolation
- Misconfiguration:
--privileged, exposed socket, secrets in clear text - Application vulnerabilities: CVEs in the image packages
- Denial of service: a container consuming all the resources
Never use --privileged
The --privileged option turns off all the protections of the
container at once:
- Access to all the host's devices
- All Linux capabilities
- No seccomp/AppArmor profile
- Ability to modify the kernel
# ❌ FORBIDDEN in productiondocker run --privileged nginx:1.31.3-alpine@sha256:4a73073bd557c65b759505da037898b61f1be6cbcc3c2c3aeac22d2a470c1752
# This container can:# - Load kernel modules# - Modify iptables rules# - Mount filesystems# - Access raw disksAlternatives to --privileged
In almost every case, --privileged is used because a single thing was
missing in the container, and the flag "solved the problem". The approach to
follow is therefore to start again from the real need: access a specific
device, open a low port, mount a file system. The table below makes that
translation. To find which capability is missing, start the container
without --privileged and read the error: an Operation not permitted
almost always names the refused system call, which leads straight to the
capability to add.
| Need | Secure alternative |
|---|---|
| Access a device | --device /dev/xxx |
| Specific capability | --cap-add CAP_XXX |
| Port < 1024 | Nothing to add: Docker lowers the privileged port threshold to 0 inside the container |
| Docker-in-Docker | Sysbox runtime or Podman |
Linux Capabilities
Capabilities split root privileges into granular permissions: 41 on the measured kernel, all of which --privileged grants. Instead of giving "all powers" with root, Docker keeps only 14 by default and removes the most dangerous ones.
Docker default capabilities
Docker grants these capabilities by default. Measured by decoding the CapEff field of /proc/1/status with capsh --decode: they are exactly these 14, and CapEff is 00000000a80425fb.
| Capability | Usage |
|---|---|
CHOWN | Change file ownership |
DAC_OVERRIDE | Bypass file permissions |
FSETID | Keep setuid/setgid bits |
FOWNER | Bypass ownership checks |
MKNOD | Create special files |
NET_RAW | Use RAW and PACKET sockets (ping) |
SETGID | Change the GID |
SETUID | Change the UID |
SETFCAP | Set file capabilities |
SETPCAP | Modify capabilities |
NET_BIND_SERVICE | Bind ports < 1024 |
SYS_CHROOT | Use chroot |
KILL | Send signals |
AUDIT_WRITE | Write to the kernel audit log |
Dangerous capabilities (removed by default)
Some capabilities are so powerful that they allow an escape from the container isolation. Docker removes them by default, but some badly configured images turn them back on.
| Capability | Risk |
|---|---|
CAP_SYS_ADMIN | Almost equivalent to root (mount, quotas, etc.) |
CAP_NET_ADMIN | Modify network rules (iptables) |
CAP_SYS_PTRACE | Debug other processes |
CAP_SYS_MODULE | Load kernel modules |
Reducing capabilities
Best practice: drop all capabilities, then add only the necessary ones.
# ❌ nginx does not start: it can no longer change the owner of its cachedocker run --cap-drop ALL nginx:1.31.3-alpine@sha256:4a73073bd557c65b759505da037898b61f1be6cbcc3c2c3aeac22d2a470c1752# [emerg] chown("/var/cache/nginx/client_temp", 101) failed (1: Operation not permitted)
# Drop everything, then add the measured minimumdocker run --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID nginx:1.31.3-alpine@sha256:4a73073bd557c65b759505da037898b61f1be6cbcc3c2c3aeac22d2a470c1752Measured with nginx:1.31.3-alpine: without any capability, nginx stops at
startup; with CHOWN, SETUID and SETGID, it runs and answers on
port 80. NET_BIND_SERVICE is useless here, because Docker allows listening on
low ports inside the container. The method generalises: drop everything,
start, and read the Operation not permitted that names what is missing.
In Docker Compose:
services: web: image: nginx:1.31.3-alpine@sha256:4a73073bd557c65b759505da037898b61f1be6cbcc3c2c3aeac22d2a470c1752 cap_drop: - ALL cap_add: - CHOWN - SETUID - SETGIDExample: container with minimal capabilities
For an application that needs no special privilege, the command below
combines --cap-drop ALL, no-new-privileges, a read-only file system
and a non-root user:
docker run -d \ --name app-secure \ --cap-drop ALL \ --security-opt no-new-privileges:true \ --read-only \ --user 1000:1000 \ myapp:1.0.0Check: inspect the effective capabilities:
# In the containerdocker exec app-secure cat /proc/1/status | grep Cap
# CapEff: 0000000000000000 = no capabilityAvailable capabilities
Full list of the Linux capabilities useful for Docker, with the use case that justifies each one and the one to avoid at all costs:
| Capability | Description | Use case |
|---|---|---|
CAP_NET_BIND_SERVICE | Bind ports < 1024 | Outside Docker, or if the privileged port threshold has been raised |
CAP_NET_ADMIN | Network administration | VPN containers, load balancers |
CAP_NET_RAW | Raw packets (ping) | Network monitoring |
CAP_SYS_PTRACE | Process debugging | Profiling, debugging |
CAP_SYS_ADMIN | System administration | Avoid at all costs |
CAP_DAC_READ_SEARCH | Read all files | Backup tools |
CAP_CHOWN | Change owners | Multi-user apps |
CAP_FOWNER | Bypass owner | File managers |
Seccomp profiles
Seccomp (Secure Computing Mode) filters system calls (syscalls) at kernel level. With Docker's default profile, a refused call fails with Operation not permitted: the process carries on, but the operation does not happen. Measured: unshare -U true answers unshare(0x10000000): Operation not permitted.
Default profile
Docker automatically applies a seccomp profile that blocks around forty dangerous system calls, according to its documentation:
| Blocked syscall | Risk if allowed |
|---|---|
reboot | Reboot the host |
kexec_load | Load a new kernel |
mount / umount | Mount filesystems |
swapon / swapoff | Modify swap |
init_module | Load kernel modules |
acct | Process accounting |
settimeofday | Change the system time |
Check that seccomp is active:
docker run --rm alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 grep Seccomp /proc/1/status# Seccomp: 2 (2 = filter mode, 0 = disabled)# Measured: 2 by default, 0 with --privilegedCustom profiles
For sensitive applications, create a more restrictive profile that only allows the syscalls the application really uses.
Step 1: record the system calls the application really uses. There is no official generator on the Docker side: the reliable method is to trace a full startup with strace, then to deduplicate the list obtained.
# Trace the syscalls of a representative run of the applicationstrace -f -c -U name -o /tmp/syscalls.txt ./mon-application
# Extract the list of names, ready to paste into the profilegrep -vE '^(syscall|-+|total)$' /tmp/syscalls.txt | sort -uThe -U name option reduces the summary report to the single column of
system call names, the grep removes the header and the separators. Trace
a representative run: a profile built on a simple startup will miss the
syscalls that the application only issues under load, and the container
will be killed in production.
Step 2: create a restrictive JSON profile (strict-seccomp.json) that
refuses everything by default and allows only the listed calls:
{ "defaultAction": "SCMP_ACT_ERRNO", "architectures": ["SCMP_ARCH_X86_64"], "syscalls": [ { "names": [ "read", "write", "close", "fstat", "mmap", "mprotect", "munmap", "brk", "access", "getpid", "openat", "exit_group", "arch_prctl", "set_tid_address", "set_robust_list", "futex", "nanosleep", "clock_gettime", "sigaltstack", "rt_sigaction", "rt_sigprocmask", "getuid", "getgid", "geteuid", "getegid", "getcwd", "readlink", "getrandom" ], "action": "SCMP_ACT_ALLOW" } ]}Step 3: apply the profile with the --security-opt seccomp=
option at container launch:
docker run --security-opt seccomp=strict-seccomp.json myapp:1.0.0AppArmor and SELinux
These Mandatory Access Control (MAC) systems restrict what processes can do beyond the traditional Unix permissions, with AppArmor on Debian-based systems and SELinux on the Red Hat family.
AppArmor is active by default on Ubuntu/Debian. Docker automatically applies the docker-default profile to every container.
Check the AppArmor status:
# Loaded profilessudo aa-status
# Profile of the containerdocker inspect --format '{{.AppArmorProfile}}' my-containerDefault profile (docker-default):
- Forbids writing to
/procand/sys - Restricts mounts
- Blocks access to certain devices
Create a custom profile (/etc/apparmor.d/docker-nginx):
#include <tunables/global>
profile docker-nginx flags=(attach_disconnected,mediate_deleted) { #include <abstractions/base>
# Read access to web files /var/www/** r, /etc/nginx/** r,
# Writing logs /var/log/nginx/** rw,
# Socket and PID /var/run/nginx.pid rw, /var/run/nginx/*.sock rw,
# Deny everything else deny /proc/** w, deny /sys/** w,}Apply the profile:
# Load the profilesudo apparmor_parser -r /etc/apparmor.d/docker-nginx
# Start the container with this profiledocker run --security-opt apparmor=docker-nginx nginx:1.31.3-alpine@sha256:4a73073bd557c65b759505da037898b61f1be6cbcc3c2c3aeac22d2a470c1752SELinux uses security contexts (labels) to control access.
Check the SELinux status:
# Current modegetenforce# Enforcing, Permissive, or Disabled
# Context of a containerdocker inspect --format '{{.ProcessLabel}}' my-containerDocker SELinux labels:
container_t: default type of containerscontainer_file_t: files accessible to containerscontainer_var_run_t: runtime files
Configure SELinux for Docker:
# Enable SELinux support in daemon.json{ "selinux-enabled": true}
# Restart Dockersudo systemctl restart dockerMount a volume with the right context:
# :z = context shared between containersdocker run -v /data:/data:z myapp:1.0.0
# :Z = private context (this container only)docker run -v /data:/data:Z myapp:1.0.0Rootless mode
Rootless mode runs the Docker daemon and all containers as a non-root user. Even a container escape does not give root access to the host.
Prerequisites
Rootless mode relies on recent kernel features and on the uidmap tools that map identifiers. Check these points before installing:
- Kernel 5.11+ (or 4.18+ with configuration)
newuidmapandnewgidmapinstalled- cgroup v2 delegation
# Check the prerequisitesdockerd-rootless-setuptool.sh check
# Install the toolssudo apt install uidmap dbus-user-sessionRootless installation
The installation happens as a regular user, with the
dockerd-rootless-setuptool.sh script, then a systemd user service
keeps the daemon running.
-
Install rootless mode
Terminal window # As a regular user (not root)dockerd-rootless-setuptool.sh install -
Configure the environment
Add to your
~/.bashrc:Terminal window export PATH=/usr/bin:$PATHexport DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock -
Enable automatic startup
Terminal window systemctl --user enable dockerloginctl enable-linger $(whoami) -
Check that it works
Terminal window docker run --rm hello-world
Rootless mode limitations
Rootless mode brings increased security, but with some constraints. Knowing these limitations will help you decide whether this mode fits your use case.
| Feature | Rootless | Alternative solution |
|---|---|---|
| Ports < 1024 | No, by default | Use ports > 1024 + reverse proxy |
| Overlay network | Limited | VPN or external network |
| Cgroup v1 | No | Migrate to cgroup v2 |
| AppArmor/SELinux | Limited | User profiles |
--privileged | No | Not needed |
User namespaces
User namespaces map UID 0 (root) of the container to an unprivileged UID on the host. Even if a process is root in the container, it has no privilege on the host.
Mapping principle
The mapping translates the container's UIDs into unprivileged UIDs on the host. That way, even a complete compromise of the container gives no privileged access to the host system.
| In the container | On the host |
|---|---|
| UID 0 (root) | UID 100000 |
| UID 1 | UID 100001 |
| ... | ... |
| UID 65535 | UID 165535 |
userns-remap configuration
The userns-remap option of the daemon enables this mapping for every
container, with a dedicated user and the ranges declared in
/etc/subuid and /etc/subgid.
-
Create the mapping user
Terminal window # Create a dedicated usersudo useradd -r -s /bin/false dockremap -
Configure the UID/GID mappings
/etc/subuid echo "dockremap:100000:65536" | sudo tee -a /etc/subuid# /etc/subgidecho "dockremap:100000:65536" | sudo tee -a /etc/subgid -
Enable in daemon.json
{"userns-remap": "dockremap"} -
Restart Docker
Terminal window sudo systemctl restart docker -
Check the mapping
Terminal window # Start a containerdocker run -d --name test alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 sleep 3600# Check the UID on the hostps aux | grep "sleep 3600"# The UID should be 100000, not 0
Securing images
An image is the first attack vector in the list above: securing it means knowing what it contains, where it comes from, and keeping it as small as possible.
Scanning for vulnerabilities
Trivy is the reference open source scanner, covered in detail in the Trivy lesson. The commands below show its main options:
IMG="nginx:1.31.3-alpine@sha256:4a73073bd557c65b759505da037898b61f1be6cbcc3c2c3aeac22d2a470c1752"
# Scan an imagetrivy image "$IMG"
# Scan with a severity threshold, and make the command failtrivy image --severity HIGH,CRITICAL --exit-code 1 "$IMG"
# Ignore vulnerabilities without an available fixtrivy image --ignore-unfixed "$IMG"
# JSON export for CI/CDtrivy image -f json -o results.json "$IMG"CI/CD integration (GitHub Actions):
- name: Scan image uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: 'myapp:${{ github.sha }}' format: 'sarif' output: 'trivy-results.sarif' severity: 'CRITICAL,HIGH'Alternatives to Trivy:
| Tool | Vendor | Distinctive feature |
|---|---|---|
| Grype | Anchore | Light, fast |
| Snyk | Snyk | IDE integration |
| Clair | Quay | Registry-native |
| Docker Scout | Docker | Built into Docker Desktop |
Verifying provenance
Scanning an image tells you what it contains, not where it comes from. Provenance verification answers the other question: was this image really produced by the expected build chain, and has nobody replaced it in the registry?
Sigstore/cosign is the recommended path. The binary is downloaded from the project's releases page, together with its checksum file, and is verified before installation:
COSIGN_VERSION=v3.1.3BASE=https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}
curl -sSLO ${BASE}/cosign-linux-amd64curl -sSLO ${BASE}/cosign_checksums.txt
# Keep only the line of the downloaded binary, then check the hashgrep ' cosign-linux-amd64$' cosign_checksums.txt | sha256sum --check -
sudo install -m 0755 cosign-linux-amd64 /usr/local/bin/cosigncosign versionVerifying an image signed in keyless mode requires two elements: the identity that signed and the OIDC issuer that certified it. Both must be provided, cosign refuses to verify "blindly".
# Example: an image signed by a GitHub Actions workflow of your organisationcosign verify ghcr.io/myorg/myapp:v1.0 \ --certificate-identity-regexp "^https://github.com/myorg/myapp/" \ --certificate-oidc-issuer https://token.actions.githubusercontent.comSecure base images
The choice of base image directly affects your attack surface. The fewer packages the image contains, the fewer potential vulnerabilities there are.
| Measured image | Size | Shell | Package manager | Potential CVEs |
|---|---|---|---|---|
| ubuntu:24.04 | 80.7 MB | Yes | apt | High |
| debian:12-slim | 77.9 MB | Yes | apt | Medium |
| alpine:3.22 | 8.6 MB | Yes | apk | Low |
| distroless static-debian12 | 3.1 MB | No | No | Very low |
| scratch | 0 MB | No | No | Application only |
The sizes are those of the exported file system (docker export), on
Docker 29.1.3. The column that matters most for security is the third
one: without a shell or a package manager, an attacker who gains a
foothold in the container has no tool at hand.
Recommendations:
- Production: Distroless or Alpine
- Debugging needed: Alpine
- Static binaries: Scratch
# Multi-stage with distrolessFROM golang:1.25 AS builderWORKDIR /appCOPY . .RUN CGO_ENABLED=0 go build -o /myapp
FROM gcr.io/distroless/static:nonroot@sha256:f7f8f729987ad0fdf6b05eeeae94b26e6a0f613bdf46feea7fc40f7bd72953e6COPY --from=builder /myapp /myappUSER nonroot:nonrootENTRYPOINT ["/myapp"]Runtime best practices
Once the image is built, the launch options decide what a running container is allowed to do: write to its file system, gain privileges, consume resources or reach the network.
Read-only filesystem
A read-only filesystem prevents malware or backdoors from being written into the container:
# ❌ nginx stops: it writes its cache and its PID file at startupdocker run --read-only nginx:1.31.3-alpine@sha256:4a73073bd557c65b759505da037898b61f1be6cbcc3c2c3aeac22d2a470c1752# [emerg] mkdir() "/var/cache/nginx/client_temp" failed (30: Read-only file system)
# Read-only, with tmpfs only for the folders it writes todocker run --read-only \ --tmpfs /tmp:rw,noexec,nosuid,size=100m \ --tmpfs /var/cache/nginx:rw,size=50m \ --tmpfs /var/run:rw,size=1m \ nginx:1.31.3-alpine@sha256:4a73073bd557c65b759505da037898b61f1be6cbcc3c2c3aeac22d2a470c1752Measured on nginx:1.31.3-alpine, in three steps: --read-only alone makes
the startup fail; with /tmp and /var/cache/nginx on tmpfs, nginx still
fails on open() "/run/nginx.pid"; with /var/run as well, it runs and
answers on port 80. A write elsewhere, in /etc for example, is refused:
Read-only file system.
Docker Compose:
services: web: image: nginx:1.31.3-alpine@sha256:4a73073bd557c65b759505da037898b61f1be6cbcc3c2c3aeac22d2a470c1752 read_only: true tmpfs: - /tmp:size=100m - /var/cache/nginx - /var/runNo-new-privileges
Prevents the acquisition of new privileges through setuid/setgid. Measured: the NoNewPrivs field of /proc/1/status goes from 0 to 1, without touching the seccomp profile (Seccomp: 2 in both cases).
docker run --security-opt no-new-privileges:true myapp:1.0.0Docker Compose:
services: app: image: myapp:1.0.0 security_opt: - no-new-privileges:trueLimiting resources
Without limits, a single container can exhaust the memory, the CPU or the process table of the host, a denial of service for all its neighbours.
Memory:
# Limit to 256 MB RAMdocker run --memory 256m myapp:1.0.0
# RAM + swap limitdocker run --memory 256m --memory-swap 512m myapp:1.0.0CPU:
# Limit to 0.5 CPUdocker run --cpus 0.5 myapp:1.0.0
# CPU affinitydocker run --cpuset-cpus "0,1" myapp:1.0.0Measured: with --memory 32m --memory-swap 32m, a process that consumes memory endlessly is stopped by the kernel, and docker inspect shows OOMKilled=true with exit code 137.
Processes (fork bomb protection):
docker run --pids-limit 100 myapp:1.0.0Measured with --pids-limit 20: a shell that tries to start 40
processes gets sh: can't fork: Resource temporarily unavailable, and the
host stays intact.
Complete example:
docker run -d \ --name app-secure \ --memory 256m \ --memory-swap 256m \ --cpus 0.5 \ --pids-limit 100 \ --read-only \ --tmpfs /tmp:size=50m \ --security-opt no-new-privileges:true \ --cap-drop ALL \ --user 1000:1000 \ myapp:1.0.0No network if not needed
For compute tasks with no network need, the --network none
option removes every interface except loopback:
# No network interface (except loopback)docker run --network none myapp:1.0.0 process-files.shMeasured: the container only sees the lo interface, and an outgoing
request fails as early as name resolution (bad address).
Audit and compliance
Hardening is only worth what you verify: an audit compares the real configuration of the host and its containers to a reference benchmark.
Docker Bench Security
An official script that checks compliance with the CIS Docker
Benchmark (version 1.6.0 in the repository). The image published on Docker
Hub (docker/docker-bench-security) is no longer maintained since 2019:
the project explicitly asks you to clone the repository and build the image
locally.
git clone https://github.com/docker/docker-bench-security.gitcd docker-bench-securitydocker build --no-cache -t docker-bench-security .
# Run the benchmark (broad access to the host is required by the benchmark)docker run --rm --net host --pid host --userns host \ --cap-add audit_control \ -e DOCKER_CONTENT_TRUST=$DOCKER_CONTENT_TRUST \ -v /etc:/etc:ro \ -v /usr/bin/containerd:/usr/bin/containerd:ro \ -v /usr/bin/runc:/usr/bin/runc:ro \ -v /usr/lib/systemd:/usr/lib/systemd:ro \ -v /var/lib:/var/lib:ro \ -v /var/run/docker.sock:/var/run/docker.sock:ro \ --label docker_bench_security \ docker-bench-securityCategories checked:
| Section | Contents |
|---|---|
| 1 | Host configuration |
| 2 | Daemon configuration |
| 3 | Configuration files |
| 4 | Images and Dockerfiles |
| 5 | Container runtime |
| 6 | Security operations |
| 7 | Docker Swarm |
Priority CIS recommendations
Among the dozens of CIS rules, here are the ones that bring the biggest security gain for the effort invested.
| ID | Recommendation | Impact |
|---|---|---|
| 2.1 | Enable --live-restore | Service continuity |
| 2.2 | Disable userland-proxy | Performance + security |
| 4.1 | Create a non-root user in images | Isolation |
| 5.1 | Do not use --privileged | Critical |
| 5.2 | Limit capabilities | Least privilege |
| 5.4 | Restrict mounts | Host integrity |
| 5.10 | Limit memory | DoS prevention |
Security troubleshooting
Security settings fail in recognisable ways: an error message that names a refused operation, a container killed by a limit, a volume that becomes unreadable.
Common problems and solutions
Security measures can sometimes block legitimate features. Here are the most frequent errors and how to solve them without compromising security.
| Symptom | Probable cause | Solution |
|---|---|---|
permission denied in the container | Read-only filesystem | Add --tmpfs /path for temporary files |
operation not permitted | Missing capability | --cap-add the required capability |
| Container killed by OOM | Memory limit reached | Increase --memory or optimise the app |
| Port binding failed | Rootless + port < 1024 | Use port > 1024 or add a capability |
| Volume permission denied | User namespace active | Adjust UID/GID or --userns=host |
Diagnostic commands
These four commands answer a precise question: is the hardening really
applied? Many teams write the right options in a Compose file and never
check the real state of the running container. The most telling reading is
CapEff: a default Docker container shows 00000000a80425fb, a container
started with --cap-drop ALL shows 0000000000000000. Any other value means
that privileges have been added somewhere, often by a cap_add forgotten in
an override file.
# Check the effective capabilitiesdocker exec mycontainer cat /proc/1/status | grep -E '^Cap'
# Check the seccomp profiledocker inspect --format '{{.HostConfig.SecurityOpt}}' mycontainer
# List blocked syscalls (debug)docker run --security-opt seccomp=unconfined myapp-debug:1.0.0 strace -f /app
# Check the resource limitsdocker stats --no-stream mycontainerKey points
- The Docker daemon runs as root: protect the socket, use rootless if possible
- Containers ≠ VMs: the shared kernel is an attack vector
- Never
--privileged: use--cap-addfor specific capabilities - Minimal capabilities:
--cap-drop ALLthen--cap-addonly what is needed; measured, nginx runs with CHOWN, SETUID and SETGID - Seccomp active: the default profile refuses around forty dangerous system calls, and
--privilegeddisables it - Minimal images: Distroless or Alpine reduce the attack surface
- Resource limits:
--memory,--cpus,--pids-limitprotect the host - Regular audit: Docker Bench for Security checks CIS compliance
Knowledge check
This quiz only covers what this guide has just explained: threat model, capabilities, seccomp, rootless mode, runtime hardening and CIS audit. Take it without going back, a hesitant answer points to the section to read again.
Knowledge check
Check what you have learned with this interactive quiz
How it works
- The timer starts when you click Start
- Multiple choice, true/false and short answer questions
- You can move between questions
- Detailed results are shown at the end
Starts the quiz and the timer
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: Container registries: understanding image storage and distribution
A new full quiz with random questions
Work again only on the questions you missed