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:
- A rootless container (non-root container): the process inside runs as an unprivileged user. You get this with
USER appin the Dockerfile,--userondocker run, orrunAsNonRoot/runAsUserin a Kubernetes securityContext. Inside,idsaysuid=10001. On the host, the same. - A rootless container engine (user namespaces): the engine itself, or at least the container, runs inside a Linux user namespace. Inside the container
idstill saysuid=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'suserns-remapdaemon option does, and what Kubernetes callshostUsers: 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?

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

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


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.

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.

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.

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.

- A plain root pod with no securityContext runs; the cluster does not stop root by default.
- The full root image under
runAsNonRoot: trueis refused withCreateContainerConfigError: "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 daemonuserns-remapdemo 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/shadowand wrote files the host owns as root.--cap-drop ALLdid 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.13had 3998 CVEs,slimhad 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
runAsNonRootonly verifies a numeric uid;USER appis treated as root and refused. Put a number inUSER.
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