Rootless Containers and User Namespaces: What They Actually Protect
Root in a container is root on the host unless something remaps it. How user namespaces work, subuid/subgid ranges, Docker userns-remap vs rootless mode vs Podman, Kubernetes hostUsers: false, the file-ownership and networking costs, and where rootless fits.
A process running as UID 0 inside a standard Docker container is UID 0 on the host kernel. Namespaces hide the host's filesystem and processes, capabilities are trimmed, and seccomp filters syscalls — but if any of those layers fails (a kernel bug, a misconfiguration, a mounted socket), the attacker lands on the host as root. User namespaces change that: root in the container maps to an unprivileged UID on the host. This is one of the highest-value hardening steps for multi-tenant and untrusted workloads. (Docker vs Linux Users for Multi-Tenant Isolation and Container Hardening.)
Two different things people call "rootless"
- Non-root inside the container —
USER appin the Dockerfile. The process runs as, say, UID 1000 in the container, which is UID 1000 on the host. Good practice, but UID 1000 on the host may still own things, and a privilege escalation to root inside is root outside. - User-namespaced containers — the container's UID range (including 0) is remapped to a range of unprivileged host UIDs. Root inside is, e.g., UID 100000 outside: it owns nothing on the host and has no host privileges.
You want both: a non-root process inside a user-namespaced container.
How user namespaces work
A user namespace has a UID map and GID map:
# /proc/<pid>/uid_map — inside-start outside-start length
0 100000 65536
UIDs 0–65535 inside map to 100000–165535 on the host. Capabilities granted inside the namespace (a container's "root" can still chown files it owns, bind low ports in its network namespace, and so on) apply only to resources owned by that namespace. To the host kernel, the process is an unprivileged user.
The host allocates ranges per user in /etc/subuid and /etc/subgid:
dockremap:100000:65536
alice:165536:65536
Give each tenant or workload a non-overlapping range if you want them isolated from each other at the UID level, not just from the host.
The options
Docker userns-remap
The daemon still runs as root, but containers run in a user namespace with a remapped range:
// /etc/docker/daemon.json
{ "userns-remap": "default" }
- All containers share one remapped range (so containers aren't UID-isolated from each other, only from the host).
- Image layers are stored separately per remapping.
- Some features are incompatible (e.g. sharing the host's PID or network namespace with
--userns=hostexceptions).
Docker rootless mode
The daemon itself runs as an unprivileged user, inside a user namespace. A compromise of the daemon — or a container escape — yields that user, not root.
- Networking goes through a user-space network stack (slirp4netns, or pasta/RootlessKit), with a performance cost and some limitations (source IP visibility, privileged ports need configuration).
- Some storage drivers, cgroup controls, and features depend on kernel and systemd configuration (cgroup v2 with delegation is needed for resource limits).
- Each user runs their own daemon.
Podman
Daemonless, and rootless by default for non-root users. Supports --userns=auto to allocate a distinct range per container from the user's subordinate IDs — true per-container UID isolation.
Kubernetes
Pods can opt into user namespaces with hostUsers: false, available in recent Kubernetes releases (check your version's feature status and runtime support — it requires a compatible container runtime and kernel, and idmapped mounts on the volumes in use). Each pod gets its own mapping.
The costs
File ownership on volumes. Files written by container root appear on the host as UID 100000. Bind-mounting a host directory owned by your user into a remapped container gives "permission denied" in both directions. Solutions:
- Idmapped mounts (Linux 5.12+, filesystem support varies) map ownership at mount time without
chown-ing data — the modern answer, used by container runtimes where available. chownvolumes to the mapped range (slow for large trees, and ties data to a mapping).- Named volumes managed by the runtime rather than host bind mounts. (Docker Volumes vs Bind Mounts.)
Networking. Rootless daemons need user-space networking; throughput and latency are lower than kernel bridge networking, and published ports behave differently. (Container Networking Internals.)
Compatibility. Anything that genuinely needs host privileges — managing the host network, loading kernel modules, some FUSE and device access — won't work (by design). Nested containers (Docker-in-Docker) need extra configuration.
Kernel attack surface isn't gone. Unprivileged user namespaces themselves expose kernel code paths to unprivileged users, and have been a recurring source of kernel privilege-escalation bugs. Some distributions restrict unprivileged user namespace creation for that reason. Keep kernels patched, and keep seccomp on — Docker's default profile blocks nested namespace creation (unshare/clone with CLONE_NEWUSER) inside containers.
Where it sits in defence in depth
| Layer | Stops |
|---|---|
Non-root USER |
Accidental privileged operations; many escalation paths |
Dropped capabilities, no-new-privileges |
setuid escalation, dangerous privileged operations |
| Seccomp, AppArmor/SELinux | Rare/dangerous syscalls and file access patterns |
| User namespaces | Escapes landing as host root; cross-tenant UID collisions (with distinct ranges) |
| cgroup limits | Resource exhaustion (How Container CPU and Memory Limits Actually Work) |
| gVisor / microVMs | Most kernel-exploit escapes, by not sharing the host kernel directly (Firecracker vs gVisor vs Containers) |
For running AI-agent-generated or user-submitted code, user namespaces are a strong, cheap layer — but they don't replace a sandboxed kernel boundary when the threat model includes kernel exploits. (How to Run AI-Generated Code Safely.)
Verify it
# Inside the container
id # uid=0(root) …
cat /proc/self/uid_map # 0 100000 65536 → remapped
# 0 0 4294967295 → NOT remapped
# On the host
ps -o user,pid,cmd -C your-process # should show the high UID, not root
And never mount the Docker socket into a container: access to /var/run/docker.sock is root-equivalent on the host regardless of namespaces.
EasySpawn runs each workspace as a non-root user in its own Docker container, with CPU, memory, and process limits and no access to the host filesystem or Docker socket — layers that keep one project's agent contained to that project. See how it works or join the waitlist.
Related: Container Hardening: Seccomp and AppArmor · Containers vs Virtual Machines · The Sandbox Is the Wrong Abstraction
Keep reading
Hardening Containers With Capabilities, seccomp, AppArmor, and User Namespaces
A default container shares the host kernel and starts with more privilege than most workloads need. A layer-by-layer guide: dropping capabilities, no-new-privileges, seccomp, AppArmor and SELinux, read-only filesystems, and user namespaces — and how to verify each.
Prompt Injection in Coding Agents: A Threat Model
A coding agent with a shell, credentials, and network access reads text written by strangers all day. A threat model — sources, capabilities, sinks — why detection-based defences fail, and the architectural controls that actually bound the damage.