Container Security
Running containers safely: rootless containers and user namespaces, dropping capabilities, read-only file systems, image scanning and signing, secrets, and keeping images patched.
Where the risk is
A container is a set of ordinary processes on the host, fenced in by kernel features (namespaces and cgroups, see What Are Containers?). All containers on a host share that one kernel. A flaw in the kernel or a careless setting can let a process break out of its container, and what it can do next depends on the privileges it had inside. NIST's Application Container Security Guide (SP 800-190, 2017) is still the standard reference; this page covers the practical measures for Docker hosts. Kubernetes adds its own controls on top, described in Namespaces, RBAC and Kubernetes Security.
The Docker daemon is root
The Docker daemon, dockerd, runs as root unless you
choose rootless mode. Anyone who can send it commands can start a
container that mounts the host's root file system, runs privileged,
or joins the host's namespaces. Docker's own documentation says only
trusted users should be allowed to control the daemon, and that the
docker group grants root-level privileges.
Run as a non-root user
Unless the image or the command line says otherwise, the process in a container runs as root (user ID 0):
$ docker run --rm --name sec alpine:3.22 id
$ docker run --rm --name sec --user 10001:10001 alpine:3.22 id
$ docker run --rm --name sec --user 10001:10001 alpine:3.22 sh -c 'touch /etc/kestrel.conf'
uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video) uid=10001 gid=10001 groups=10001 touch: /etc/kestrel.conf: Permission denied
Root in a container is not the full root of the host, because Docker
removes most privileges (see
capabilities below), but it is still
user ID 0: it owns the files of the image, it can change any file on
a bind mount, and after a breakout it is root on the host unless
user namespaces are in use. A non-root user cannot even write to
/etc.
The best place to set the user is the image, with a
USER instruction (see
Building Good Images).
docker inspect shows what an image uses:
$ docker inspect --format 'User={{.Config.User}}' kestrel-meter-api:1.1 nginx:alpine
$ docker run --rm --name sec kestrel-meter-api:1.1 sh -c 'id; whoami'
User=10001 User= uid=10001 gid=0(root) groups=0(root) whoami: cannot find name for user ID 10001
The Kestrel meter-api image sets USER 10001; the stock
nginx:alpine image sets nothing, so it starts as root
(its worker processes then switch to a user of their own). Two
details in the second command: gid=0(root) because the
Dockerfile named only a user, not a group (USER 10001:10001
fixes that), and whoami
fails because user 10001 has no entry in the image's
/etc/passwd. That is harmless for most programs; a few
tools that need a home folder or a user name do complain.
Rootless mode and user namespaces
A non-root user inside the container helps, but the daemon itself still runs as root, and root inside a container is root on the host. Two features change that mapping.
Rootless mode
In rootless mode the Docker
daemon and all its containers run as an ordinary, unprivileged user.
Root inside a container is then that user on the host, and other
user IDs in the container map to a range of "subordinate" IDs
reserved for that user in /etc/subuid and
/etc/subgid. A breakout lands in an unprivileged
account. The set-up needs the newuidmap and
newgidmap helpers (the uidmap package) and
at least 65,536 subordinate IDs per user; Docker ships a
dockerd-rootless-setuptool.sh install script for it.
The price: binding ports below 1024 and some network and storage
features need extra configuration.
User namespace remapping
User namespace remapping
(userns-remap) keeps a root daemon but runs the
containers in a user namespace: user ID 0 inside a container is
mapped to an unprivileged, high user ID on the host. It is switched
on in /etc/docker/daemon.json (for example
"userns-remap": "default"), applies to all containers,
and is off by default. The containerd image store, the default for
new Docker Engine 29 installations, is (for now) not available with
it. Kubernetes offers the same protection per pod (hostUsers: false, stable since Kubernetes 1.36).
Where a container user lands on the host, for a user
tomasz (host user ID 1000) with the subordinate range
tomasz:100000:65536 (with
"userns-remap": "default" Docker uses the range of its
own dockremap user; the model assumes that range also
starts at 100000):
OWN_UID = 1000 # the user who runs rootless Docker
SUB_START, SUB_COUNT = 100000, 65536 # /etc/subuid line tomasz:100000:65536
def rootless(uid):
"""Host UID for a container UID under rootless Docker."""
if uid == 0:
return OWN_UID # root in the container = you
if uid <= SUB_COUNT:
return SUB_START + uid - 1
return None # outside the mapped range
def remap(uid):
"""Host UID for a container UID with userns-remap."""
return SUB_START + uid if uid < SUB_COUNT else None
print(f"{'container UID':>13} {'rootless':>9} {'userns-remap':>12}")
for uid in [0, 1, 101, 10001, 70000]:
a, b = rootless(uid), remap(uid)
print(f"{uid:>13} {str(a or 'not mapped'):>9} {str(b or 'not mapped'):>12}")
container UID rootless userns-remap
0 1000 100000
1 100000 100001
101 100100 100101
10001 110000 110001
70000 not mapped not mapped
In both cases no container user becomes host root. A user ID outside the range cannot exist in the container at all, which is why an image with files owned by a very high user ID fails to start in these modes. Files on bind mounts appear with the mapped IDs, so plan ownership before switching (see permissions and user IDs).
Capabilities
Linux splits the power of root into separate privileges. A
Linux capability is one such
privilege, for example CHOWN (change the owner of any
file), NET_BIND_SERVICE (listen on a port below 1024)
or SYS_ADMIN (a long list of administrative actions,
including mounting file systems). The kernel checks the capability,
not the user ID, for each of these actions. Docker starts containers
with a short allowlist and drops everything else. The current set
shows up as a hexadecimal mask in /proc/self/status:
$ docker run --rm --name sec alpine:3.22 grep CapEff /proc/self/status
$ docker run --rm --name sec --cap-drop ALL alpine:3.22 grep CapEff /proc/self/status
CapEff: 00000000a80425fb CapEff: 0000000000000000
Each bit of the mask is one capability, numbered as in the kernel. Decoding Docker's default:
CAPS = [
"CHOWN", "DAC_OVERRIDE", "DAC_READ_SEARCH", "FOWNER", "FSETID", "KILL",
"SETGID", "SETUID", "SETPCAP", "LINUX_IMMUTABLE", "NET_BIND_SERVICE",
"NET_BROADCAST", "NET_ADMIN", "NET_RAW", "IPC_LOCK", "IPC_OWNER",
"SYS_MODULE", "SYS_RAWIO", "SYS_CHROOT", "SYS_PTRACE", "SYS_PACCT",
"SYS_ADMIN", "SYS_BOOT", "SYS_NICE", "SYS_RESOURCE", "SYS_TIME",
"SYS_TTY_CONFIG", "MKNOD", "LEASE", "AUDIT_WRITE", "AUDIT_CONTROL",
"SETFCAP", "MAC_OVERRIDE", "MAC_ADMIN", "SYSLOG", "WAKE_ALARM",
"BLOCK_SUSPEND", "AUDIT_READ", "PERFMON", "BPF", "CHECKPOINT_RESTORE",
] # position in the list = capability number in the kernel
def decode(cap_eff):
mask = int(cap_eff, 16)
return [name for bit, name in enumerate(CAPS) if mask & (1 << bit)]
for label, value in [("default", "00000000a80425fb"),
("--cap-drop ALL", "0000000000000000")]:
names = decode(value)
print(f"{label}: {len(names)} capabilities")
for i in range(0, len(names), 5):
print(" ", " ".join(names[i:i + 5]))
default: 14 capabilities
CHOWN DAC_OVERRIDE FOWNER FSETID KILL
SETGID SETUID SETPCAP NET_BIND_SERVICE NET_RAW
SYS_CHROOT MKNOD AUDIT_WRITE SETFCAP
--cap-drop ALL: 0 capabilities
Fourteen of the kernel's 41 capabilities; dangerous ones such as
SYS_ADMIN, SYS_MODULE (load kernel
modules) and NET_ADMIN are not among them. Most
applications need none of the fourteen either. Dropping all of them
makes root in the container much weaker:
$ docker run --rm --name sec --cap-drop ALL alpine:3.22 sh -c 'ping -c 1 -W 1 127.0.0.1; chown nobody /etc/motd'
PING 127.0.0.1 (127.0.0.1): 56 data bytes 64 bytes from 127.0.0.1: seq=0 ttl=42 time=0.064 ms --- 127.0.0.1 ping statistics --- 1 packets transmitted, 1 packets received, 0% packet loss round-trip min/avg/max = 0.064/0.064/0.064 ms chown: /etc/motd: Operation not permitted
chown fails although the process is root, because
CHOWN is gone. ping still works, which
surprises people who learned that it needs NET_RAW:
busybox's ping falls back to the kernel's unprivileged "ping
sockets", which Docker allows for containers.
Adding back only what is needed
Some images expect a few capabilities. The stock
nginx:alpine image starts as root, changes the owner of
its cache folders and then switches to its own user. With all
capabilities dropped it stops at the first step:
$ docker run -d --name capdrop-nginx --cap-drop ALL nginx:alpine
$ docker logs capdrop-nginx 2>&1 | tail -3
$ docker ps -a --filter name=capdrop-nginx --format '{{.Names}} {{.Status}}'
8ce3793ca7eaa61dff69c833a31f44a2fcf81068234ae6f42ead6da8972f3297
/docker-entrypoint.sh: Configuration complete; ready for start up
2026/10/06 12:24:07 [emerg] 1#1: chown("/var/cache/nginx/client_temp", 101) failed (1: Operation not permitted)
nginx: [emerg] chown("/var/cache/nginx/client_temp", 101) failed (1: Operation not permitted)
capdrop-nginx Exited (1) 2 seconds ago
The log names the failing operation, and that tells you which capability to add back. Four were enough here:
$ docker run -d --name capadd-nginx --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add NET_BIND_SERVICE nginx:alpine
$ docker ps -a --filter name=capadd-nginx --format '{{.Names}} {{.Status}}'
7fead451045cc198626ec49559fbaf0b26b4d76ce8d03e334e8734c1cd1c4ef0 capadd-nginx Up 2 seconds
Start from --cap-drop ALL and add capabilities one at a
time, guided by the errors. An image built to run as non-root on a
high port, like the Kestrel meter-api, usually needs none at all.
This is
least privilege
applied to a process.
A read-only root file system
--read-only mounts the container's own file system
read-only. An attacker who gets code execution cannot drop tools
into it or change the application, and a bug cannot fill the
writable layer. Programs that need a scratch folder get a
tmpfs mount:
$ docker run --rm --name sec --read-only alpine:3.22 sh -c 'echo cache > /tmp/cache.txt'
$ docker run --rm --name sec --read-only --tmpfs /tmp alpine:3.22 sh -c 'echo cache > /tmp/cache.txt && cat /tmp/cache.txt'
sh: can't create /tmp/cache.txt: Read-only file system cache
Again, stock images may need some help. nginx writes temporary files
under /var/cache/nginx:
$ docker run -d --name ro-nginx --read-only nginx:alpine
$ docker logs ro-nginx 2>&1 | tail -3
275cd4d744fb591c07e846c3d68c287501426553ae33a5bbbc7273ac208f000c /docker-entrypoint.sh: Configuration complete; ready for start up 2026/10/06 12:24:04 [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)
The fix is a tmpfs for each folder the program writes to (for nginx
at least --tmpfs /var/cache/nginx --tmpfs /run), not
dropping --read-only. The error message names the
folder.
seccomp, no-new-privileges and --privileged
seccomp
seccomp (secure computing mode) is a
Linux kernel feature that restricts which
system calls
a process may make. Docker applies a default seccomp profile to
every container that blocks dozens of rarely needed and risky system
calls, such as those for loading kernel modules or rebooting, while
ordinary applications never notice it. Leave it on:
--security-opt seccomp=unconfined removes a layer that
has blocked real container escapes. Docker also confines containers
with its AppArmor profile docker-default on hosts that
use AppArmor, and with SELinux labels when the daemon runs with
SELinux enabled (see
mandatory access control).
no-new-privileges
--security-opt no-new-privileges tells the kernel that
no process in the container may gain privileges it did not start
with, so a
setuid
program such as su cannot raise a user to root:
$ docker run --rm --name sec --security-opt no-new-privileges alpine:3.22 grep NoNewPrivs /proc/self/status
NoNewPrivs: 1
Never --privileged as a fix
--privileged gives a container all capabilities, access
to all host devices, and no seccomp or AppArmor restrictions. From
inside such a container, taking over the host is routine. It exists
for a few special cases such as running Docker inside Docker in a
disposable CI machine.
Putting it together
A hardened start of the Kestrel meter-api combines the measures on this page:
$ docker run -d --name meter-api \
--user 10001:10001 \
--read-only --tmpfs /tmp:size=16m \
--cap-drop ALL \
--security-opt no-new-privileges \
--memory 256m --cpus 0.5 --pids-limit 200 \
-p 127.0.0.1:8000:8000 \
registry.example.com/kestrel/meter-api:2.3.0
| Option | Protects against |
|---|---|
--user 10001:10001 |
Root privileges inside the container |
--read-only, --tmpfs /tmp |
Changed binaries, dropped tools, a full writable layer |
--cap-drop ALL |
Kernel privileges the program never needed |
no-new-privileges |
Privilege escalation through setuid programs |
--memory, --cpus,
--pids-limit
|
One container starving the host (a memory leak, a fork bomb) |
-p 127.0.0.1:… |
Exposure on every network interface (see publishing ports) |
Check each new option in a test environment first, one at a time: the error messages are clear when you change one thing, and confusing when you change six. Resource limits only work when the kernel's memory controller is enabled; see Debugging Containers for a host where it was not. Benchmarks such as the CIS Docker Benchmark turn these measures into a checklist for audits; see system hardening.
Images: scanning, signing and patching
Scanning
An image contains an operating system's packages and the application's libraries, frozen at build time. Every week new vulnerabilities (CVEs) are published for some of them. An image vulnerability scanner lists the packages in an image, often as a software bill of materials, and matches them against vulnerability databases. Common tools are Trivy, Grype and Docker Scout:
$ trivy image --severity HIGH,CRITICAL --ignore-unfixed registry.example.com/kestrel/meter-api:2.3.0
$ grype registry.example.com/kestrel/meter-api:2.3.0 --fail-on high
$ docker scout cves registry.example.com/kestrel/meter-api:2.3.0
In a pipeline, the scanner's exit code stops a build that has serious, fixable findings. For the ticket queue, the JSON report is more useful than the screen output. A summary in Python of a trimmed report in Trivy's JSON layout:
import json
from collections import Counter
# A trimmed scan report in Trivy's JSON layout. IDs, packages and versions
# are made up for this example.
report = json.loads("""
{
"ArtifactName": "registry.example.com/kestrel/meter-api:2.3.0",
"Results": [
{"Target": "registry.example.com/kestrel/meter-api:2.3.0 (debian)",
"Vulnerabilities": [
{"VulnerabilityID": "CVE-2026-EXAMPLE-1", "PkgName": "libssl3",
"InstalledVersion": "3.0.17-1", "FixedVersion": "3.0.18-1", "Severity": "HIGH"},
{"VulnerabilityID": "CVE-2026-EXAMPLE-2", "PkgName": "zlib1g",
"InstalledVersion": "1:1.2.13-1", "Severity": "MEDIUM"},
{"VulnerabilityID": "CVE-2026-EXAMPLE-3", "PkgName": "libc6",
"InstalledVersion": "2.36-9", "FixedVersion": "2.36-10", "Severity": "CRITICAL"}]},
{"Target": "Python",
"Vulnerabilities": [
{"VulnerabilityID": "CVE-2026-EXAMPLE-4", "PkgName": "gunicorn",
"InstalledVersion": "23.0.0", "FixedVersion": "23.0.1", "Severity": "HIGH"}]}
]
}
""")
order = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN"]
vulns = [v for r in report["Results"] for v in r.get("Vulnerabilities", [])]
counts = Counter(v["Severity"] for v in vulns)
print(report["ArtifactName"])
print(" " + ", ".join(f"{s} {counts[s]}" for s in order if counts[s]))
print(" fixable now:")
for v in sorted(vulns, key=lambda v: order.index(v["Severity"])):
if v.get("FixedVersion"):
print(f" {v['Severity']:8} {v['PkgName']} {v['InstalledVersion']} -> {v['FixedVersion']}")
registry.example.com/kestrel/meter-api:2.3.0
CRITICAL 1, HIGH 2, MEDIUM 1
fixable now:
CRITICAL libc6 2.36-9 -> 2.36-10
HIGH libssl3 3.0.17-1 -> 3.0.18-1
HIGH gunicorn 23.0.0 -> 23.0.1
Patching means rebuilding
You do not patch a running container. The fix for the findings above is a rebuild: on a freshly pulled base image (for the operating-system packages) and with updated dependencies (for gunicorn), followed by a new deployment. Scan again on a schedule, not only at build time: an image that was clean last month may have three critical findings today without a single byte changing. Tools such as Dependabot or Renovate open the pull requests for new base image versions; see Building Good Images.
Signing
A scan says what is in an image, not who built it.
Image signing
attaches a cryptographic signature to an image digest, so that a
deployment can verify that the image was built by a trusted party
and has not been changed since (see
digital signatures). The common tools today are Sigstore's cosign, which
can sign "keyless" with the identity of a CI pipeline, and Notation.
Docker Content Trust (DOCKER_CONTENT_TRUST=1), found in
older guides, was removed from the Docker command line in Docker
Engine 29.
$ cosign verify \
--certificate-identity-regexp '^https://github.com/kestrel-energy/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
registry.example.com/kestrel/meter-api:2.3.0
This checks that the image was signed by a GitHub Actions workflow
of the (fictional) kestrel-energy organization. In
Kubernetes, an admission policy can refuse every image without such
a signature.
A supply-chain lesson from March 2026
Security tools are part of the supply chain too. Between 19 and 23
March 2026, attackers used stolen credentials to publish malicious
Trivy releases (starting with v0.69.4) and to force-push 76 of the
77 version tags of the aquasecurity/trivy-action GitHub
Action, and all tags of setup-trivy, to code that stole
secrets from the pipelines that ran it. Every workflow that referred
to the action by a tag such as @v0.34.0
silently ran the attackers' code; workflows pinned to a full commit
hash did not. The project's advisory told users to pin actions to
full commit hashes and to rotate any secrets the affected versions
could have read (see
supply chain attacks
and
pinning actions).
# The 40-character hash below is a placeholder: look up the real commit
# of the release you reviewed, and keep the version as a comment.
- name: Scan the image
uses: aquasecurity/trivy-action@0123456789abcdef0123456789abcdef01234567 # v0.35.0
with:
image-ref: registry.example.com/kestrel/meter-api:2.3.0
severity: HIGH,CRITICAL
exit-code: "1"
The same reasoning applies to images: a tag such as
postgres:17 can be moved to other content, a digest
(postgres:17@sha256:…) cannot (see
Images, Layers and Registries). Pin by digest where it matters, and let a bot propose the
updates so pinned versions do not grow old.
Secrets
Passwords, API keys and certificates must never be built into an image: anyone who can pull the image can read every layer, and a file deleted in a later layer is still in the earlier one. Build tools have a dedicated mechanism for secrets needed during a build (see Building Good Images).
At run time, environment variables are better than a baked-in
password but still leak easily: docker inspect shows
them to anyone with daemon access, child processes inherit them, and
crash reports and debug pages print them. Prefer files: a read-only
bind mount of a file with tight permissions, Compose
secrets,
or, in Kubernetes, Secrets mounted as files (see
ConfigMaps, Secrets and Configuration). Larger organizations fetch them from a central
secrets manager. For secrets on Linux hosts in general, see
Linux Security.