CoursesAdvanced container securityRootless Docker & Podman

Rootless Docker & Podman

Remove the root daemon from the picture entirely.

Advanced14 min · lesson 15 of 25

In 2019 one container turned into a full host takeover, and the bug was filed as CVE-2019-5736 (a CVE, short for Common Vulnerabilities and Exposures, is the public ID a security flaw gets registered under). A process running as root inside a container overwrote runc on the host. runc is the low-level program that actually starts every container, so the next container to launch ran the attacker's code as real root on the machine. One assumption made that possible: container root was host root. Same UID 0 (UID means user ID, and zero is the number the kernel treats as all-powerful), same kernel, nothing translating between the two. Rootless mode throws that assumption out. It runs the whole Docker daemon, and every container that daemon starts, as your ordinary unprivileged login account. Inside the container you still look like root. On the host you are you and nothing more. The runc overwrite fails under rootless for a boring reason: your account cannot write to /usr/bin/runc. A daemon compromise stops being the loss of a machine and becomes a problem with one person's stuff.

The user namespace does the real work

A theatre hands every actor a name tag for the stage. Onstage you are the king. At the stage door, security holds a list saying the king is employee 1000, and that list is what decides which rooms you can open. The kernel keeps a list like that for every user namespace (userns, a kernel feature that renumbers user IDs for one group of processes). File permissions get checked against the outside number, never the stage name. Rootless Docker builds that namespace with a helper program called RootlessKit when the daemon starts. Your host user ID, say 1000, wears the root name tag inside. The block of spare IDs your account was allocated, its sub-IDs, covers everybody else. So container root maps back to you, and a container process running as UID 1000 maps to some harmless five-digit host number you will never think about again.

Compare that with userns-remap on a rooted daemon, which you saw earlier in this course. There, container root turned into a dedicated dockremap account parked at host UID 100000. Under rootless, container root is the actual human running the daemon. The daemon never held root in the first place, so there is nothing left to remap away.

terminal
$ docker context use rootless
rootless
$ docker info -f '{{println .SecurityOptions}}{{.DockerRootDir}}'
[name=seccomp,profile=builtin name=rootless name=cgroupns]
/home/alice/.local/share/docker
$ ps -o user= -p "$(pgrep -x dockerd)" # who owns the daemon process?
alice

Three lines in that output tell the whole story. The security options list carries a rootless tag, so the API (application programming interface, the socket your tools talk to) knows the daemon is unprivileged. The daemon keeps its data under your home directory instead of /var/lib/docker, because it cannot write to system paths. And the dockerd process belongs to alice, not to root. There is no privileged service sitting on this host for an attacker to hijack. That is the structural win, and one command proves it.

terminal
$ cat /etc/subuid # the host ID range handed to your user
alice:100000:65536
$ docker run -d --user 0 alpine sleep 300 # container root
$ docker run -d --user 101 alpine sleep 300 # container UID 101
$ ps -o user,args -C sleep # who owns these on the HOST?
USER COMMAND
alice sleep 300 # container root -> host user alice (UID 1000)
100100 sleep 300 # container 101 -> host 100000 + 100 = 100100

That ps output is your detection check and your reassurance in one breath. Every process a rootless container spawns shows on the host as either you or a five-digit sub-ID. Run the same ps against a rooted daemon and the container's root process reads root, which is the exact danger rootless takes away. If you ever see a container process owned by real root on a host you built as rootless, stop and investigate. Something has walked around the model.

The first wall you hit: ports below 1024

Rootless costs you something, and the bill usually arrives on day one when you try to bind a low port. On Linux, ports below 1024 are reserved. Grabbing one has always required a specific capability, CAP_NET_BIND_SERVICE. (A capability is one of roughly forty separate keys that root's power was chopped into, so a process can be handed one of them without being handed all of them.) A rootless daemon holds no capabilities against the real host, so the kernel refuses the bind. Ask it to publish port 80 and the run dies before the container is even wired up.

terminal
$ docker run -d -p 80:80 nginx
docker: Error response from daemon: driver failed programming external
connectivity on endpoint nginx: Error starting userland proxy: error while
calling PortManager.AddPort(): cannot expose privileged port 80, you can add
'net.ipv4.ip_unprivileged_port_start=80' to /etc/sysctl.conf (currently 1024),
or set CAP_NET_BIND_SERVICE on rootlesskit binary, or choose a larger port
number (>= 1024): listen tcp4 0.0.0.0:80: bind: permission denied.

Here is where people get stuck. Adding --cap-add NET_BIND_SERVICE to the container changes nothing. The container was never the process the kernel blocked. RootlessKit is the one reaching out for the host port, and RootlessKit is the one missing the key. The error text is unusually honest about the three real fixes, and choosing between them is a security decision rather than a convenience one.

Serving a port below 1024 under rootless
Container must bind a port below 1024
the rootless daemon holds no CAP_NET_BIND_SERVICE on the host
one service, touch nothing on the host
Publish high, proxy low
run -p 8080:80 and put a system nginx or HAProxy on :80 in front. No host tuning, smallest blast radius.
Docker itself should own low ports
setcap on rootlesskit
setcap cap_net_bind_service=ep on the rootlesskit binary. Privilege scoped to one file, not the whole box.
every user on the host may bind low
sysctl port floor
net.ipv4.ip_unprivileged_port_start=80 lowers the reserved-port line host-wide. Broadest reach, use last.
Pick the tightest scope that works: reverse proxy first, setcap on rootlesskit second, the host-wide sysctl only as a fallback.

The middle path pins the new privilege to a single binary instead of loosening the rule for every process on the machine. Grant RootlessKit the bind capability, restart your own daemon, and the same run goes through.

terminal
$ sudo setcap cap_net_bind_service=ep "$(which rootlesskit)"
$ getcap "$(which rootlesskit)" # verify exactly what you granted
/usr/bin/rootlesskit cap_net_bind_service=ep
$ systemctl --user restart docker
$ docker run -d -p 80:80 nginx
b91f4c2d8e7a...
$ curl -sI localhost:80 | head -1
HTTP/1.1 200 OK

Ports are not the only rough edge, and naming the rest now saves you a 2am surprise. Rootless stores images through fuse-overlayfs or native overlay depending on your kernel, so builds can run a little slower on older hosts. There is no --net host that behaves the way the rooted version does, because the daemon sits inside its own network namespace. Even ping needs a sysctl (a kernel tuning knob), net.ipv4.ping_group_range, because raw sockets are gated too. None of this breaks a normal web service. It only wants to be known before it bites.

Podman: same model, no daemon at all

Podman starts where rootless ends and takes one more step. It runs rootless by default and has no central daemon whatsoever. Each container is a direct child of the podman command you typed, so there is no long-lived root service on the box waiting to be attacked. The command line copies Docker closely enough that alias docker=podman carries you through most days. And you can read the user namespace underneath straight out of the kernel's mapping table with one command.

terminal
$ podman run --rm alpine id # who am I inside the container?
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)
$ podman unshare cat /proc/self/uid_map # the kernel's translation sheet
0 1000 1 # container root -> host UID 1000 (you)
1 100000 65536 # everyone else -> your sub-ID range
A rootless socket is still your entire account
Rootless shrinks the host-root blast radius. It does not turn the API socket into something safe to hand around. Anyone who can reach your rootless docker socket runs containers as you, so they can read your SSH keys (SSH, secure shell, the keys you log into other machines with), your cloud credentials, and anything else your login can touch. They can bind-mount your home directory into a container and walk off with it. Do not mount the socket into a container on the grounds that it is "only rootless", and do not expose it over TCP (transmission control protocol, meaning out on the network). The container also still shares the host kernel, so a kernel bug can still bite. Keep a non-root USER, cap-drop ALL, a seccomp profile (seccomp, secure computing mode, is a syscall allow-list), and a read-only root filesystem layered on top. Rootless is the floor you build on. It is not the finished wall.

Treat those first three checks as a ritual, not a one-off. After any change window that touched the daemon, the host packages or the systemd units, run them again: dockerd still owned by your user, security options still reporting rootless, data directory still under your home. Paste the command and its output into the ticket. If a reading has drifted, the change is not finished, whatever the deploy log claims.

Rootless is per person, and that catches teams out. Every user who sets it up gets their own daemon, their own image store, their own containers and their own sub-ID range. Nothing is shared. Two engineers pulling the same base image onto the same host pull it twice, into two directories under two home folders. Fine on a laptop. Worth measuring on a shared build box with a small disk.

The second operational surprise is lifetime. A rootless daemon lives inside your user session rather than as a system service, so it can stop when your last login goes away and it will not necessarily come back on its own after a reboot. Linux can be told to keep one user's services running with nobody logged in, and on any host serving real traffic you want that turned on and proven with an actual reboot, not assumed.

Moving an existing rooted setup across is mostly a question of where the data lives. The rooted daemon kept everything under /var/lib/docker, which your unprivileged account cannot read. The rootless daemon starts with an empty store in your home directory, so images get pulled again and named volumes do not follow you over by themselves. Plan that copy, or plan to rebuild from your registry and your backups, before the cutover instead of during it.

Choosing between rootless Docker and Podman is an operations question more than a security one. If your tooling, your CI (continuous integration) runners and your muscle memory all speak Docker, rootless Docker keeps every one of them working. If you are starting fresh, or you want systemd supervising containers directly with nothing long-lived in between, Podman fits better. The isolation underneath is the same user namespace either way, so the image hardening you ship does not change.

Write down what rootless actually bought you, because your incident response leans on it. A daemon compromise now costs one account on one host instead of the machine and everything else running on it. It buys you nothing against a kernel bug, against a cloud credential sitting in that same account, or against a socket you handed to the wrong process. Keeping that line clear is what stops rootless from becoming an excuse to skip the other controls.

When the low-port problem comes back, and it will, work down the same list every time. A reverse proxy in front changes nothing on the host. A setcap on rootlesskit changes one file. The sysctl changes the rule for every process every user runs. Take the smallest option that lets the workload run, and put the reason in the change ticket so the next person does not quietly promote you to the widest one.

One last thing worth wiring into monitoring. On a host you built as rootless, a container process owned by real root is an alert, not a curiosity. So is a docker socket that suddenly answers on a TCP port, and so is a rootlesskit binary carrying capabilities nobody remembers granting. Those three readings are cheap to collect on a schedule, and they catch the exact ways this model comes undone.

Try this

If you have rootless available, switch context and prove two things: dockerd runs as your user, and the security options include rootless. No rootless install handy? Run the block as a checklist against a lab VM (virtual machine) and compare every line with what you get back.

terminal
$ docker context use rootless 2>/dev/null || echo 'use a rootless lab context'
$ docker info -f '{{.SecurityOptions}} | root={{.DockerRootDir}}'
[name=seccomp,profile=builtin name=rootless name=cgroupns] | root=/home/alice/.local/share/docker
$ ps -o user= -p "$(pgrep -x dockerd | head -1)"
alice
$ docker run --rm alpine id
uid=0(root) gid=0(root) ...
$ # host mapping still remaps that "root" — confirm with podman/docker docs for your install

Takeaway

Rootless takes host root out of the daemon equation and leaves account risk exactly where it was. Pair it with a non-root USER, cap-drop, and a socket nobody else can reach. Solve low ports with a proxy before you go loosening host sysctls.

Quick check
01You move a service to rootless Docker and docker run -d -p 80:80 nginx now fails with "cannot expose privileged port 80". Which explanation and fix are both right?
Correct — Low ports are reserved and a rootless daemon carries no host capabilities, so you either hand rootlesskit the bind key or move the reserved-port line down.
Incorrect — No. The container was never the blocked process. RootlessKit reaches for the host port, so the capability has to sit on rootlesskit, not inside the container.
Incorrect — No. Rootless publishes ports fine. Only ports below 1024 are gated, and --network host behaves differently under rootless anyway.
Incorrect — No. The UID mapping is real, but --privileged hands over no host capability to bind a low port under rootless, and it widens your risk for nothing.
02On a rootless host, /etc/subuid shows alice:100000:65536. Alice starts one container with --user 0 and another with --user 101. On the host, ps shows the first container's process owned by alice. Which host user owns the second container's process?
Incorrect — No. Only container UID 0 lands on alice. Non-zero container UIDs map into her allocated sub-ID range instead.
Incorrect — No. 101 is the in-container UID. On the host it goes through the sub-ID translation rather than being used as it stands.
Correct — The subuid range maps container UID 1 to host 100000, so container UID 101 comes out as 100100, exactly what the ps output showed.
Incorrect — No. The range maps container UID 1, not 0, to 100000, so container 101 becomes 100100. Watch the off-by-one.
03A teammate wants to bind-mount the rootless Docker socket into a container, arguing it is safe because the daemon is rootless rather than real root. Does that reasoning hold?
Incorrect — No. Whoever holds the socket runs containers as you and can bind-mount your home directory, so it reaches your files without effort.
Correct — Rootless removes the host-root blast radius, not the account blast radius, so the socket never goes into a container and never onto a TCP port.
Incorrect — No. Seccomp filters the syscalls the container itself makes. It says nothing about what the Docker API does on the requester's behalf.
Incorrect — No. The shared kernel is a real concern, and the socket is far from harmless: it hands over full control of your account's containers.

Related