All posts
5 min read

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.

dockerisolationsecurityadvanced

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"

  1. Non-root inside the container — USER app in 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.
  2. 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=host exceptions).

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.
  • chown volumes 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