What Is a Rootless Container? Hardening an Image from Root to Non-Root, Measured

What a rootless container is and why root in a container is root on the host. One Flask app, four images: 3998 CVEs down to 329, and what user namespaces stop.

By Mirac Can Yılmaz 17 min read
What Is a Rootless Container? Hardening an Image from Root to Non-Root, Measured - Security yazısının kapak görseli

When somebody says "we run it in a container, so it is isolated", they usually mean two things they have not checked: that the process inside cannot touch the host, and that the image is clean. I wanted numbers for both. I took one small Flask service and built it four ways, from the full python:3.13 image down to distroless, scanned each with Trivy, and then let a container with the host filesystem mounted in try to read a file it should never see. The full image carried 3998 known CVEs; distroless carried 329 but is 71 MB with no shell. A root container read the host's shadow file in one command; the same image under a user namespace could not. This post is what a rootless container actually is, why root in a container is root on the host, and what the image and the runtime each let through, measured.

What is a rootless container?

A container is a normal Linux process with a few kernel features wrapped around it: namespaces give it its own view of the filesystem, network, process list and host name; cgroups limit how much CPU and memory it can use; capabilities split root's powers into about 40 pieces (bind a low port, change file owners, mount a filesystem) so a process can hold some and not others; and a seccomp profile blocks system calls it should never make. None of these change who the process is. If the process inside runs as uid 0, the kernel sees uid 0, the same number as the root user on the host.

That is the whole point of this post. "Root in a container" is not a weaker kind of root. It is root behind a fence. The fence is good, but every hole in it, a bind mount, a leaked socket, a kernel bug, hands a real root to whoever is inside.

Rootless is used for two different things, and both matter:

  1. A rootless container (non-root container): the process inside runs as an unprivileged user. You get this with USER app in the Dockerfile, --user on docker run, or runAsNonRoot / runAsUser in a Kubernetes securityContext. Inside, id says uid=10001. On the host, the same.
  2. A rootless container engine (user namespaces): the engine itself, or at least the container, runs inside a Linux user namespace. Inside the container id still says uid=0, but the kernel maps that 0 to an unprivileged uid on the host, for example 100000. Root inside, nobody outside. This is what Podman does by default, what rootless Docker does, what Docker's userns-remap daemon option does, and what Kubernetes calls hostUsers: false.

The first one is about your image. The second one is about your runtime. The lab below does both, and they protect against different things.

What problem do rootless containers and image hardening solve?

Root inside is root outside. Any path from the container to the host filesystem, most often a volume or bind mount, is used with the container's uid. A root container that mounts a host directory can read /etc/shadow, create files that the host sees as owned by root, and leave a setuid binary behind. This is not an exploit. It is how uids work. Dropping capabilities does not change it, because file ownership is decided by uid, not by a capability.

Kernel bugs are the second path. Container escapes such as Dirty Pipe (CVE-2022-0847) or the runc file descriptor leak (CVE-2024-21626) start from a process in the container and end with code running on the host. The harm they do depends on who that process is. A non-root process that escapes is a non-root process on the host.

Big images carry bugs you never use. python:3.13 is a full Debian system with compilers, curl, git, Perl, mercurial and hundreds of libraries, because somebody might need them to build something. Your Flask app needs the interpreter and two packages. Every extra package is a CVE waiting for the next scan, a shell for the attacker who gets in, and megabytes to pull on every node. Image hardening means shipping only what runs.

Kubernetes will eventually say no. The restricted Pod Security Standard requires runAsNonRoot: true. An image whose last USER is root fails to start under it with CreateContainerConfigError. Teams who never set a user in their Dockerfiles discover this on the day the cluster admin turns the policy on.

Root vs non-root vs user namespace: what you gain and what you pay

Root container (default) Non-root process (USER, runAsNonRoot) User namespace (userns-remap, rootless Podman/Docker, hostUsers: false)
id inside uid 0 uid 10001 uid 0
uid on the host 0 10001 100000 + n (unprivileged)
Reads host /etc/shadow through a bind mount yes no no
Files it writes on a shared volume owned by root owned by 10001 owned by 100000
Needs the Dockerfile to change no yes (USER, file permissions) no, the image can stay as it is
Ports below 1024 yes no (bind 8080 and let the proxy map it) no inside, the engine forwards
Installing packages at runtime works fails (good) works inside, harmless outside
Cost none permission errors on volumes and /tmp while you fix the image existing volumes change owner, --privileged and some drivers do not work, slight overhead on file ownership
Who controls it image author image author or pod spec cluster or daemon admin

The two right columns are not alternatives. The non-root process protects you even when the runtime is misconfigured; the user namespace protects you even when the image is somebody else's and runs as root. Use both when you can.

How it works: uid 0 inside, who on the outside?

Diagram: three containers side by side, each running the same process, and the uid the host kernel sees. Root container: uid 0 inside, uid 0 on the host, a bind mount leads straight to the host filesystem, shadow file readable, files created owned by root. Non-root container: uid 10001 inside and on the host, the same bind mount, permission denied. User namespace: uid 0 inside maps through uid_map 0 100000 65536 to uid 100000 on the host, the kernel checks permissions with 100000, permission denied.

A user namespace is a translation table the kernel keeps per container, visible in /proc/self/uid_map. The line 0 100000 65536 means "uid 0 inside is uid 100000 outside, and so on for 65536 ids". Every permission check that involves the host, opening a file, sending a signal, mounting, uses the outside number. Inside, the process still owns its files, still binds port 80, still installs packages, because inside the namespace it really is uid 0 over the things that belong to the namespace. The moment it touches something that belongs to the host, it is 100000.

Without a user namespace there is no table. Uid 0 is uid 0, and the only things stopping it are capabilities (what root is allowed to do), seccomp (which system calls it can make) and the mount namespace (what it can see). All three are configurable per container and all three can be weakened by a flag.

Dockerfiles: from A to D

The lab uses one ~30 line Flask service and four Dockerfiles. These are the two that matter.

C: slim, multi-stage, non-root

FROM python:3.13-slim AS build
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --target /build/deps -r requirements.txt

FROM python:3.13-slim
RUN groupadd --system --gid 10001 app \
 && useradd --system --uid 10001 --gid app --home /app --shell /usr/sbin/nologin app
WORKDIR /app
COPY --from=build /build/deps /app/deps
COPY --chown=app:app app.py .
ENV PYTHONPATH=/app/deps PYTHONDONTWRITEBYTECODE=1
USER app
EXPOSE 8080
CMD ["python", "app.py"]

A fixed numeric uid (10001) and not just a name, because Kubernetes runAsNonRoot can only verify a numeric uid; a USER app whose uid it cannot resolve from the image is reported as "image has non-numeric user". Dependencies are installed in a build stage and copied over, so pip, its cache and the build tools never reach the runtime image. The service listens on 8080, not 80, because a non-root process cannot bind below 1024 without CAP_NET_BIND_SERVICE.

D: distroless

FROM python:3.11-slim AS build
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --target /build/deps -r requirements.txt

FROM gcr.io/distroless/python3-debian12:nonroot
WORKDIR /app
COPY --from=build /build/deps /app/deps
COPY app.py .
ENV PYTHONPATH=/app/deps
CMD ["app.py"]

Distroless images have no shell, no package manager and no root user; the :nonroot tag runs as uid 65532. There is nothing for docker exec -it ... sh to run, which is the point. Debugging happens with an ephemeral container (kubectl debug) or by looking at the logs. The Python version is whatever Debian 12 ships (3.11), so the dependency stage uses the same minor version.

The lab: one app, four images, three ways to run it

The service is about 30 lines of Flask behind waitress, returning its own uid and whether it can write to /. The four Dockerfiles are A python:3.13, B python:3.13-slim, C slim with a multi-stage build and USER 10001, D distroless. Everything ran locally on Colima (a Lima VM, the Docker host in the escape tests below), Trivy 0.65.0 and kind 0.33.0. No registry, no cloud.

A. Four images: size, user, shell

Terminal: make build. Four images with size, user and whether a shell exists. full python:3.13 is 1141 MB and runs as root with a shell; slim is 157 MB, root, shell; nonroot is 149 MB, runs as uid 10001, shell; distroless is 71 MB, runs as uid 65532, no shell. Python is 3.13.18 for the first three and 3.11.2 for distroless.

The full image is 1141 MB; the slim one is 157 MB for the same app, because python:3.13 ships a whole Debian with compilers and tools the app never calls. The non-root image (C) is 149 MB and runs as uid 10001. Distroless (D) is 71 MB, runs as uid 65532, and has no shell at all: docker run --entrypoint sh fails because there is nothing to run. It serves the same JSON ({"uid":65532,"user":"nonroot","writable_root":false}), on Python 3.11 because that is what Debian 12 distroless ships.

B. Trivy: the CVE count per image

Terminal: make scan with Trivy 0.65.0. full python:3.13 has 494 packages on Debian 13 and 3998 CVEs, 16 CRITICAL and 460 HIGH. slim has 114 packages, 194 CVEs, 0 CRITICAL. nonroot has the same 114 packages and 194 CVEs. distroless has 42 packages on Debian 12 and 329 CVEs, 2 CRITICAL. Note: full to slim drops 3998 CVEs to 194, a 95 percent fall; distroless has the fewest packages but more CVEs than slim because its base is Debian 12.

Matrix: one app, four images, with size, packages, base Debian version, total CVEs, CRITICAL count, the user it runs as and whether it has a shell. full python:3.13: 1141 MB, 494 packages, Debian 13, 3998 CVEs, 16 CRITICAL, root, shell. slim: 157 MB, 114 packages, Debian 13, 194 CVEs, 0 CRITICAL, root, shell. slim plus USER 10001: 149 MB, 114 packages, 194 CVEs, 0 CRITICAL, uid 10001, shell. distroless: 71 MB, 42 packages, Debian 12, 329 CVEs, 2 CRITICAL, uid 65532, no shell.

The biggest win is the cheapest one: moving from python:3.13 to python:3.13-slim dropped the package count from 494 to 114 and the CVE count from 3998 to 194, a 95% fall, with a one line change to the base. Zero CRITICAL remained on slim.

Then a surprise I kept, because it is the honest result: distroless has fewer packages than slim (42 vs 114) but more CVEs (329 vs 194), including 2 CRITICAL. The reason is in the "base" column: the distroless python3-debian12 image tracks Debian 12 and Python 3.11, one release behind slim's Debian 13 and Python 3.13. Fewer packages, but older ones, with more unpatched CVEs in libraries like libsqlite3 and the Python standard library. Distroless still wins where it counts, 71 MB, no shell, no package manager, non-root by default, a read-only root filesystem, but not on the raw CVE number. More on this in Where it broke #1.

C. Root in a container is root on the host

The clearest way to see what "root in a container" means is to give the container the host and watch. Each run below mounts the host filesystem at /host (-v /:/host), the way a careless volume or a compromised job might.

Terminal: a root container with the host mounted at /host. id shows uid 0. /proc/self/uid_map shows 0 0 4294967295, meaning no user namespace, so uid 0 inside is uid 0 outside. head of /host/etc/shadow succeeds and prints root and daemon lines, a root-only file on the host read from inside the container. Writing a file to /host/tmp succeeds, and on the host it is listed as owned by uid 0, root.

The root container read /host/etc/shadow, a file only root can read, and wrote a file the host then owns as uid 0. Its uid_map is 0 0 4294967295: the identity map, meaning there is no user namespace and uid 0 inside is uid 0 on the host. This is not an exploit; it is a volume and a uid behaving exactly as defined.

Terminal: the same image run with --user 10001. id shows uid 10001. Reading /host/etc/shadow returns permission denied. Writing to /host/tmp returns permission denied. Below, the same root container with --cap-drop ALL, --security-opt no-new-privileges and seccomp on still reads /host/etc/shadow, because file access is decided by uid, not by a capability.

Run the same image with --user 10001 and both the read and the write fail with permission denied. Only the uid changed. The lower half of that shot is the point people miss: a root container with --cap-drop ALL, --security-opt no-new-privileges and the default seccomp profile still read the shadow file, because reading a root-owned file needs uid 0, and dropping capabilities does not change your uid.

D. The same root image, made harmless by a user namespace

Non-root means changing the image. A user namespace protects you without touching the image: I turned on userns-remap in the Docker daemon and ran the original root image again.

Terminal: daemon.json set to userns-remap default, then docker restarted. docker info shows name=userns in SecurityOptions. The same root image: id still shows uid 0 inside, the image is unchanged. But /proc/self/uid_map now reads 0 100000 65536, so uid 0 inside maps to uid 100000 on the host. Reading /host/etc/shadow returns permission denied, and writing to /host/tmp returns permission denied.

Inside, id still says uid 0 and the image is byte for byte the same. But uid_map now reads 0 100000 65536: the kernel maps the container's root to host uid 100000, and every check against the host uses that number. The shadow read and the host write both fail. The image stayed root; the runtime made it safe. The cost is real and covered in the table above: existing volumes change owner, --privileged stops working, and this is a daemon-wide setting an admin controls.

E. Kubernetes: the cluster can insist on non-root

A cluster enforces the same idea with runAsNonRoot: true. I gave every pod that securityContext and changed only the image.

Terminal: kubectl get pods, every pod has runAsNonRoot true. default-root with no securityContext runs. full (the root image) is CreateContainerConfigError, container has runAsNonRoot and image will run as root. nonroot whose image USER is the name app is also CreateContainerConfigError, because Kubernetes verifies only a numeric uid. distroless whose image USER is 65532 runs. After changing the Dockerfile to USER 10001, the nonroot pod runs. hostUsers false fails on kind's default node with a sysfs mount error.

  • A plain root pod with no securityContext runs; the cluster does not stop root by default.
  • The full root image under runAsNonRoot: true is refused with CreateContainerConfigError: "container has runAsNonRoot and image will run as root". It never starts.
  • Distroless (image user 65532) starts. So did the non-root image, but only after I fixed a real trap, in Where it broke #2.
  • hostUsers: false (Kubernetes user namespaces) needs node support kind's default node does not have here; it failed with a sysfs mount error. The daemon userns-remap demo in section D is the same kernel mechanism, and it ran.

Where it broke: real surprises

1. Distroless had more CVEs than slim, because its base is a release behind

I expected the smallest image to have the fewest CVEs. It had the fewest packages (42 against slim's 114) but more CVEs: 329 against 194, with 2 CRITICAL against 0. The gcr.io/distroless/python3-debian12 image is pinned to Debian 12 and Python 3.11, while python:3.13-slim is Debian 13 and Python 3.13. The older base carries older libsqlite3, libexpat1 and standard-library builds with unpatched CVEs. The lesson: fewer packages is not automatically fewer CVEs; the freshness of the base distribution matters more than its size. Distroless is still the right call for its attack surface (no shell, no package manager, non-root, read-only root), but if you choose it for the CVE count, scan it and check the base version, do not assume.

2. runAsNonRoot rejected my non-root image, because USER was a name

My first non-root Dockerfile ended with USER app. The container ran fine under Docker (id said uid 10001). Under Kubernetes runAsNonRoot: true it was refused with CreateContainerConfigError: "container has runAsNonRoot and image will run as root". Kubernetes does not read /etc/passwd from the image, so it cannot turn the name app into a uid; it can only verify a numeric uid, and an unverifiable user is treated as root. docker image inspect --format '{{.Config.User}}' showed app, not 10001. Changing the Dockerfile to USER 10001 made Config.User numeric and the pod started. Distroless passed from the start because its :nonroot tag sets USER to 65532. Always put a numeric uid in USER, or set runAsUser in the pod.

3. --cap-drop ALL did not stop the root container reading the host

The instinct when a root container is scary is to drop capabilities. I ran the root image with --cap-drop ALL --security-opt no-new-privileges and the default seccomp profile, and it still read /host/etc/shadow. Reading a file owned by root needs uid 0, and the container was still uid 0; no capability governs "read a file your uid already owns". Capabilities limit special operations (bind a low port, change another file's owner, load a module), not ordinary file access by the owning uid. The fix is to not be uid 0: a non-root user, or a user namespace.

4. Turning on userns-remap hid every existing image

When I set userns-remap and restarted the daemon, docker images was suddenly empty. The daemon stores remapped containers and images under a separate directory (/var/lib/docker/<uid>.<gid>/), so nothing built before the switch is visible, and existing named volumes change owner on first use. I had saved the image to a tar with docker save first and loaded it back, which is the painless way to try userns-remap on an existing host. On a shared host, plan for the storage and volume churn before you flip it.

When to use which

Situation Do Why
Any new Dockerfile multi-stage, slim or distroless base, USER with a numeric uid, listen above 1024 the image is the one thing you fully control
Third party image that insists on root --user / runAsUser if it tolerates it; otherwise run it under a user namespace you cannot rebuild it, you can contain it
Developer laptops, CI runners that build images rootless Podman or rootless Docker the daemon itself stops being a root process on your machine
Shared Docker host userns-remap in daemon.json every container's root becomes an unprivileged host uid, no image changes
Kubernetes cluster restricted Pod Security Standard on namespaces; hostUsers: false where the runtime supports it policy makes the non-root image mandatory; user namespaces cover the ones that slip through
Image scanning Trivy (or Grype) in CI, gate on CRITICAL with a known fix the base image is where most CVEs live, and switching base is a one line fix
You need a shell in production kubectl debug with an ephemeral container the application image stays shell-less

Summary

  • A container is a process with namespaces, cgroups, capabilities and seccomp around it; none of those change its uid. Root inside is uid 0 to the kernel, the same as root on the host.
  • With the host mounted in, a root container read the host's /etc/shadow and wrote files the host owns as root. --cap-drop ALL did not stop it, because file access is decided by uid, not by a capability.
  • Two fixes, for two different problems. A non-root user (USER 10001, runAsNonRoot) fixes the image. A user namespace (userns-remap, rootless Docker or Podman, hostUsers: false) fixes the runtime and leaves a root image unchanged. Both failed the same read that root passed.
  • Image hardening is measured, not assumed: python:3.13 had 3998 CVEs, slim had 194, and the full to slim move is a one line change. Distroless is 71 MB with no shell, but its older Debian 12 base carried 329 CVEs, more than slim, so scan it rather than trust its size.
  • Kubernetes runAsNonRoot only verifies a numeric uid; USER app is treated as root and refused. Put a number in USER.

Next in the series

This is post 4 of Core Skills, Measured. The previous one, What Is the Difference Between SAST and DAST?, counted what a pipeline's scanners catch; this one counted what the image and the runtime let through. Next: CMD vs ENTRYPOINT, ADD vs COPY, the Dockerfile instructions everyone copies and few can explain, measured by what happens to signals, arguments and cache.

The code for this lab (the app, the four Dockerfiles, the build, scan and escape scripts, the kind manifests and the Makefile) is shared with Lab Access members on Buy Me a Coffee; the repo invite comes by email after you join: https://buymeacoffee.com/miraccanyilmaz/membership

Hayatta en hakiki mürşit ilimdir.
Mustafa Kemal Atatürk