Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

By updated

Improve this page

Logo Docker

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.

Docker security layers: three containers on the same host, A hardened (--read-only, --cap-drop ALL), C minimal (distroless, --memory, --pids-limit) and B started with --privileged, all go through the isolation layer (namespaces, cgroups, seccomp, AppArmor), then through the Docker daemon running as root, down to the shared Linux kernel; container B can escape straight to that kernel.Containers on the same hostescape riskContainer A: hardened--read-only, --cap-drop ALLContainer B: --privilegedroot, no limitsContainer C: distroless--memory, --pids-limitIsolation: namespaces, cgroups,seccomp, AppArmorDocker daemonroot by defaultLinux kernelshared by all
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

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.

Terminal window
# ❌ This command gives root access to the host
docker run -it --rm -v /:/host alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 chroot /host

Measured, 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.

CharacteristicContainerVM
KernelShared with the hostIts own kernel
IsolationNamespaces + cgroupsHypervisor
Attack surfaceHost kernelHypervisor
OverheadMinimalSignificant
EscapeEasierRare

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.

  1. Malicious image: downloading a compromised image
  2. Container escape: exploiting a kernel flaw to break out of the isolation
  3. Misconfiguration: --privileged, exposed socket, secrets in clear text
  4. Application vulnerabilities: CVEs in the image packages
  5. 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
Terminal window
# ❌ FORBIDDEN in production
docker run --privileged nginx:1.31.3-alpine@sha256:4a73073bd557c65b759505da037898b61f1be6cbcc3c2c3aeac22d2a470c1752
# This container can:
# - Load kernel modules
# - Modify iptables rules
# - Mount filesystems
# - Access raw disks

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

NeedSecure alternative
Access a device--device /dev/xxx
Specific capability--cap-add CAP_XXX
Port < 1024Nothing to add: Docker lowers the privileged port threshold to 0 inside the container
Docker-in-DockerSysbox 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.

CapabilityUsage
CHOWNChange file ownership
DAC_OVERRIDEBypass file permissions
FSETIDKeep setuid/setgid bits
FOWNERBypass ownership checks
MKNODCreate special files
NET_RAWUse RAW and PACKET sockets (ping)
SETGIDChange the GID
SETUIDChange the UID
SETFCAPSet file capabilities
SETPCAPModify capabilities
NET_BIND_SERVICEBind ports < 1024
SYS_CHROOTUse chroot
KILLSend signals
AUDIT_WRITEWrite 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.

CapabilityRisk
CAP_SYS_ADMINAlmost equivalent to root (mount, quotas, etc.)
CAP_NET_ADMINModify network rules (iptables)
CAP_SYS_PTRACEDebug other processes
CAP_SYS_MODULELoad kernel modules

Reducing capabilities

Best practice: drop all capabilities, then add only the necessary ones.

Terminal window
# ❌ nginx does not start: it can no longer change the owner of its cache
docker 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 minimum
docker run --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID nginx:1.31.3-alpine@sha256:4a73073bd557c65b759505da037898b61f1be6cbcc3c2c3aeac22d2a470c1752

Measured 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
- SETGID

Example: 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:

Terminal window
docker run -d \
--name app-secure \
--cap-drop ALL \
--security-opt no-new-privileges:true \
--read-only \
--user 1000:1000 \
myapp:1.0.0

Check: inspect the effective capabilities:

Terminal window
# In the container
docker exec app-secure cat /proc/1/status | grep Cap
# CapEff: 0000000000000000 = no capability

Available 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:

CapabilityDescriptionUse case
CAP_NET_BIND_SERVICEBind ports < 1024Outside Docker, or if the privileged port threshold has been raised
CAP_NET_ADMINNetwork administrationVPN containers, load balancers
CAP_NET_RAWRaw packets (ping)Network monitoring
CAP_SYS_PTRACEProcess debuggingProfiling, debugging
CAP_SYS_ADMINSystem administrationAvoid at all costs
CAP_DAC_READ_SEARCHRead all filesBackup tools
CAP_CHOWNChange ownersMulti-user apps
CAP_FOWNERBypass ownerFile 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 syscallRisk if allowed
rebootReboot the host
kexec_loadLoad a new kernel
mount / umountMount filesystems
swapon / swapoffModify swap
init_moduleLoad kernel modules
acctProcess accounting
settimeofdayChange the system time

Check that seccomp is active:

Terminal window
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 --privileged

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

Terminal window
# Trace the syscalls of a representative run of the application
strace -f -c -U name -o /tmp/syscalls.txt ./mon-application
# Extract the list of names, ready to paste into the profile
grep -vE '^(syscall|-+|total)$' /tmp/syscalls.txt | sort -u

The -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:

Terminal window
docker run --security-opt seccomp=strict-seccomp.json myapp:1.0.0

AppArmor 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:

Terminal window
# Loaded profiles
sudo aa-status
# Profile of the container
docker inspect --format '{{.AppArmorProfile}}' my-container

Default profile (docker-default):

  • Forbids writing to /proc and /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:

Terminal window
# Load the profile
sudo apparmor_parser -r /etc/apparmor.d/docker-nginx
# Start the container with this profile
docker run --security-opt apparmor=docker-nginx nginx:1.31.3-alpine@sha256:4a73073bd557c65b759505da037898b61f1be6cbcc3c2c3aeac22d2a470c1752

Rootless 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)
  • newuidmap and newgidmap installed
  • cgroup v2 delegation
Terminal window
# Check the prerequisites
dockerd-rootless-setuptool.sh check
# Install the tools
sudo apt install uidmap dbus-user-session

Rootless installation

The installation happens as a regular user, with the dockerd-rootless-setuptool.sh script, then a systemd user service keeps the daemon running.

  1. Install rootless mode

    Terminal window
    # As a regular user (not root)
    dockerd-rootless-setuptool.sh install
  2. Configure the environment

    Add to your ~/.bashrc:

    Terminal window
    export PATH=/usr/bin:$PATH
    export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock
  3. Enable automatic startup

    Terminal window
    systemctl --user enable docker
    loginctl enable-linger $(whoami)
  4. 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.

FeatureRootlessAlternative solution
Ports < 1024No, by defaultUse ports > 1024 + reverse proxy
Overlay networkLimitedVPN or external network
Cgroup v1NoMigrate to cgroup v2
AppArmor/SELinuxLimitedUser profiles
--privilegedNoNot 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 containerOn the host
UID 0 (root)UID 100000
UID 1UID 100001
......
UID 65535UID 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.

  1. Create the mapping user

    Terminal window
    # Create a dedicated user
    sudo useradd -r -s /bin/false dockremap
  2. Configure the UID/GID mappings

    /etc/subuid
    echo "dockremap:100000:65536" | sudo tee -a /etc/subuid
    # /etc/subgid
    echo "dockremap:100000:65536" | sudo tee -a /etc/subgid
  3. Enable in daemon.json

    {
    "userns-remap": "dockremap"
    }
  4. Restart Docker

    Terminal window
    sudo systemctl restart docker
  5. Check the mapping

    Terminal window
    # Start a container
    docker run -d --name test alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 sleep 3600
    # Check the UID on the host
    ps 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:

Terminal window
IMG="nginx:1.31.3-alpine@sha256:4a73073bd557c65b759505da037898b61f1be6cbcc3c2c3aeac22d2a470c1752"
# Scan an image
trivy image "$IMG"
# Scan with a severity threshold, and make the command fail
trivy image --severity HIGH,CRITICAL --exit-code 1 "$IMG"
# Ignore vulnerabilities without an available fix
trivy image --ignore-unfixed "$IMG"
# JSON export for CI/CD
trivy 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:

ToolVendorDistinctive feature
GrypeAnchoreLight, fast
SnykSnykIDE integration
ClairQuayRegistry-native
Docker ScoutDockerBuilt 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:

Terminal window
COSIGN_VERSION=v3.1.3
BASE=https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}
curl -sSLO ${BASE}/cosign-linux-amd64
curl -sSLO ${BASE}/cosign_checksums.txt
# Keep only the line of the downloaded binary, then check the hash
grep ' cosign-linux-amd64$' cosign_checksums.txt | sha256sum --check -
sudo install -m 0755 cosign-linux-amd64 /usr/local/bin/cosign
cosign version

Verifying 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".

Terminal window
# Example: an image signed by a GitHub Actions workflow of your organisation
cosign verify ghcr.io/myorg/myapp:v1.0 \
--certificate-identity-regexp "^https://github.com/myorg/myapp/" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com

Secure 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 imageSizeShellPackage managerPotential CVEs
ubuntu:24.0480.7 MBYesaptHigh
debian:12-slim77.9 MBYesaptMedium
alpine:3.228.6 MBYesapkLow
distroless static-debian123.1 MBNoNoVery low
scratch0 MBNoNoApplication 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 distroless
FROM golang:1.25 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o /myapp
FROM gcr.io/distroless/static:nonroot@sha256:f7f8f729987ad0fdf6b05eeeae94b26e6a0f613bdf46feea7fc40f7bd72953e6
COPY --from=builder /myapp /myapp
USER nonroot:nonroot
ENTRYPOINT ["/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:

Terminal window
# ❌ nginx stops: it writes its cache and its PID file at startup
docker 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 to
docker 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:4a73073bd557c65b759505da037898b61f1be6cbcc3c2c3aeac22d2a470c1752

Measured 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/run

No-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).

Terminal window
docker run --security-opt no-new-privileges:true myapp:1.0.0

Docker Compose:

services:
app:
image: myapp:1.0.0
security_opt:
- no-new-privileges:true

Limiting 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:

Terminal window
# Limit to 256 MB RAM
docker run --memory 256m myapp:1.0.0
# RAM + swap limit
docker run --memory 256m --memory-swap 512m myapp:1.0.0

CPU:

Terminal window
# Limit to 0.5 CPU
docker run --cpus 0.5 myapp:1.0.0
# CPU affinity
docker run --cpuset-cpus "0,1" myapp:1.0.0

Measured: 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):

Terminal window
docker run --pids-limit 100 myapp:1.0.0

Measured 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:

Terminal window
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.0

No network if not needed

For compute tasks with no network need, the --network none option removes every interface except loopback:

Terminal window
# No network interface (except loopback)
docker run --network none myapp:1.0.0 process-files.sh

Measured: 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.

Terminal window
git clone https://github.com/docker/docker-bench-security.git
cd docker-bench-security
docker 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-security

Categories checked:

SectionContents
1Host configuration
2Daemon configuration
3Configuration files
4Images and Dockerfiles
5Container runtime
6Security operations
7Docker Swarm

Priority CIS recommendations

Among the dozens of CIS rules, here are the ones that bring the biggest security gain for the effort invested.

IDRecommendationImpact
2.1Enable --live-restoreService continuity
2.2Disable userland-proxyPerformance + security
4.1Create a non-root user in imagesIsolation
5.1Do not use --privilegedCritical
5.2Limit capabilitiesLeast privilege
5.4Restrict mountsHost integrity
5.10Limit memoryDoS 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.

SymptomProbable causeSolution
permission denied in the containerRead-only filesystemAdd --tmpfs /path for temporary files
operation not permittedMissing capability--cap-add the required capability
Container killed by OOMMemory limit reachedIncrease --memory or optimise the app
Port binding failedRootless + port < 1024Use port > 1024 or add a capability
Volume permission deniedUser namespace activeAdjust 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.

Terminal window
# Check the effective capabilities
docker exec mycontainer cat /proc/1/status | grep -E '^Cap'
# Check the seccomp profile
docker 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 limits
docker stats --no-stream mycontainer

Key points

  1. The Docker daemon runs as root: protect the socket, use rootless if possible
  2. Containers ≠ VMs: the shared kernel is an attack vector
  3. Never --privileged: use --cap-add for specific capabilities
  4. Minimal capabilities: --cap-drop ALL then --cap-add only what is needed; measured, nginx runs with CHOWN, SETUID and SETGID
  5. Seccomp active: the default profile refuses around forty dangerous system calls, and --privileged disables it
  6. Minimal images: Distroless or Alpine reduce the attack surface
  7. Resource limits: --memory, --cpus, --pids-limit protect the host
  8. 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

8 questions
8 min
70% to pass

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

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn