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.