Skip to main content
intermediatePart 8

Move the Docker daemon off root so a container escape lands on an ordinary user

· 14 min read
Rafael Fernandes
NLP Engineer & Tech Writer at WiLine
Share:
+

Here is the part nobody says out loud when they tell you to add yourself to the docker group so you can stop typing sudo.

That group is root. Not "close to root", not "root for Docker things". If you can run a container, you can read, change or delete any file on the machine — including the password file, including other people's home directories, including the files the administrator deliberately kept away from you.

No exploit required. It is one command, and it takes about four seconds.

Reproducibility

One WEC Instance: 8 vCPU AMD EPYC 7601, 15 GB RAM, Ubuntu 22.04, kernel 5.15. System daemon is Ubuntu's docker.io 29.1.3; the rootless daemon installed here is 29.8.1 with rootlesskit 3.1.0. Test image is alpine:3.20. The host was running 20 containers under the system daemon throughout, and none of them stopped.

Every command output and timing below is from that run.

Proving it on your own machine​

Run this as a normal user. There is no sudo anywhere in it.

docker run --rm -v /:/host alpine:3.20 \
sh -c 'touch /host/root/rootless-demo && echo WROTE as uid $(id -u)'
sudo ls -l /root/rootless-demo

An unprivileged user runs a container that creates a file inside /root, and the host listing confirms the file is owned by root

WROTE as uid 0
-rw-r--r-- 1 root root 0 Sep 16 20:58 /root/rootless-demo

A user who cannot so much as list /root just created a file in it, owned by root.

Nothing malfunctioned. That is the design. The daemon runs as root, you asked it to mount the whole filesystem, and it did — because a root process is allowed to. The container's "root" is the host's root. They are the same thing.

Docker documents this plainly, and it is worth reading their wording rather than mine. From Docker daemon attack surface:

only trusted users should be allowed to control your Docker daemon

and, on the same page:

Docker allows you to share a directory between the Docker host and a guest container; and it allows you to do so without limiting the access rights of the container.

That is not a warning about a flaw. It is a description of the feature we just used.

Clean it up before moving on:

sudo rm -f /root/rootless-demo

Why this is worse on a host running agents​

An AI agent that executes code it generated is, in security terms, remote code execution that you installed on purpose. That is not a criticism of agents — it is what they are for.

So the question stops being "could an attacker run code here?" — something already does, constantly — and becomes "what can that code reach?"

If it can reach the Docker socket, the answer is everything, via the command above. Your API keys. Your SSH keys. The database volume. All of it, regardless of how carefully the agent's own container was configured.

What Part 2 did and did not fix​

Part 2 found two of six Langfuse containers running as root and fixed them with user: "999:999". That was worth doing and it is still worth doing.

But it changes who the process is inside the container. The daemon that starts it is still root, and the socket is still a door to the whole machine. Part 2 hardened the passenger. This post goes after the driver.

What rootless mode actually does​

The daemon stops running as root and runs as you.

Containers still think they have root — software inside them keeps working normally. But that "root" is now translated, by the kernel, into an ordinary unprivileged account that owns nothing important. Escape the container and you are not the administrator. You are ubuntu.

The translation uses a block of user IDs the system set aside for you. Here is the whole idea in one picture — the same container, under each daemon:

The left half is why the first command worked. The right half is what we are about to build, and the arithmetic in it is something you can verify yourself in a minute.

This translation is a Linux kernel feature called a user namespace, not a Docker invention — Docker is just asking the kernel for one. The block of IDs it maps into is declared in /etc/subuid:

grep '^ubuntu:' /etc/subuid /etc/subgid
/etc/subuid:ubuntu:100000:65536
/etc/subgid:ubuntu:100000:65536

65,536 IDs starting at 100000, yours alone. Hold onto that number — the mapping becomes checkable arithmetic in a moment.

Installing it: four walls​

The documentation assumes a setup this machine does not have. Here is each wall in the order you hit it.

Wall 1 — the package does not exist​

Every guide opens with the same line. On Ubuntu's Docker:

sudo apt-get install -y docker-ce-rootless-extras
dpkg -L docker.io | grep -iE 'rootless|rootlesskit'

apt reports it cannot locate package docker-ce-rootless-extras, and listing the docker.io package contents for rootless files returns nothing

E: Unable to locate package docker-ce-rootless-extras

That package belongs to Docker's own repository. This host runs Ubuntu's docker.io, and the second command returns nothing at all — the distribution package ships no rootless tooling whatsoever.

That matters because it forces a choice. You can replace docker.io with Docker CE, which means swapping out the daemon currently running your containers. Or you can install a second, user-owned daemon beside the first. With 20 services running, the second option is the only sane one, and it is what the rest of this post does.

What you do need from apt is the ID-mapping helpers:

sudo apt-get install -y uidmap
If apt offers to restart services

On Ubuntu 22.04 this may trigger a "Daemons using outdated libraries" prompt with containerd.service and your database pre-selected. uidmap does not need any of them restarted. Deselect them, or cancel the prompt — the install still completes.

Wall 2 — the installer refuses to run​

curl -fsSL https://get.docker.com/rootless | sh
# Installing stable version 29.8.1
Aborting because rootful Docker is running and accessible.
Set FORCE_ROOTLESS_INSTALL=1 to ignore.

The guard assumes you are migrating — that a running root daemon means you are about to make a mess. Running both side by side on purpose is never mentioned, and the way to proceed appears only inside the error text.

Note the version it names: 29.8.1, while the system daemon is 29.1.3. You will end up with two different Docker versions on one host. That is expected here, and worth knowing.

curl -fsSL https://get.docker.com/rootless | FORCE_ROOTLESS_INSTALL=1 sh

Wall 3 — it moves your CLI without telling you​

Read the last lines of the installer output:

[INFO] Creating CLI context "rootless"
[INFO] Using CLI context "rootless"
Current context is now "rootless"

Now ask Docker what is running:

docker ps
docker context ls
docker -c default ps -q | wc -l

docker ps lists no containers at all, docker context ls shows the star on rootless, and querying the default context reports 20 running containers

CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES

NAME DESCRIPTION DOCKER ENDPOINT ERROR
default Current DOCKER_HOST based configuration unix:///var/run/docker.sock
rootless * Rootless mode unix:///run/user/1000/docker.sock

20

An empty list, while twenty containers run perfectly well. Nothing broke and nothing warned you — the CLI is answering a different question than the one you think you asked, because the installer repointed it on the way out.

Put it back, and opt in per command instead:

docker context use default # everyday work, unchanged
docker -c rootless ps # explicit when you want the new one

Start the daemon and confirm what you have:

export XDG_RUNTIME_DIR=/run/user/$(id -u)
systemctl --user start docker.service
docker -c rootless info --format '{{.ServerVersion}} {{.SecurityOptions}}'
29.8.1 [name=seccomp,profile=builtin name=rootless name=cgroupns]

name=rootless is the daemon confirming what it is.

The PATH advice is only half needed

The installer tells you to add ~/bin to your PATH. For day-to-day use you do not have to: the system docker client talks to the rootless daemon perfectly well through the context, and every command in this post was run that way. You need the PATH only for the setup and uninstall scripts — and without it they fail with dockerd-rootless.sh needs to be present under $PATH, which tells you nothing about what is actually wrong.

Checking that the mapping is real​

Ask a container who it is, then look at that same process from outside:

docker -c rootless run --rm alpine:3.20 id
docker -c rootless run -d --name u0 alpine:3.20 sleep 60
ps -o user=,uid=,cmd= -C sleep | grep 'sleep 60'

docker -c rootless run -d --name u1000 --user 1000:1000 alpine:3.20 sleep 120
ps -o uid=,cmd= -C sleep | grep 'sleep 120'

The container reports uid 0 root; the host shows that process owned by ubuntu uid 1000; a container running as uid 1000 appears on the host as uid 100999

uid=0(root) gid=0(root) groups=0(root),...
ubuntu 1000 sleep 60
100999 sleep 120

Read those three lines together, because they are the entire mechanism:

  • The container believes it is root.
  • The host says that process belongs to ubuntu.
  • A container running as ID 1000 shows up on the host as 100999.

That last number is 100000 + 1000 - 1 — the start of your reserved block, offset by the container's own ID, because container root already took the first slot by mapping to you.

Which gives you a test rather than a promise. Work it out from your own /etc/subuid line and see whether the number matches. If you instead see plain 1000, the translation is not happening and something is wrong with the setup.

Clean up:

docker -c rootless rm -f u0 u1000

The same command, the other daemon​

Back to the command this post opened with. Same image, same mount of the entire filesystem, same flags. Only the daemon differs:

docker -c rootless run --rm -v /:/host alpine:3.20 \
sh -c 'touch /host/root/rootless-demo || echo DENIED'

The identical command that previously wrote into /root now fails with permission denied under the rootless daemon

touch: /host/root/rootless-demo: Permission denied
DENIED

The container still believes it is root. The kernel disagrees, because the process behind it belongs to ubuntu, and ubuntu cannot write to /root.

That is the post, in two outputs.

What it costs you​

Four things, all measured here rather than quoted.

Containers do not start noticeably slower. Three consecutive runs:

for i in 1 2 3; do /usr/bin/time -f "run+exit: %es" \
docker -c rootless run --rm alpine:3.20 true; done
docker -c rootless run --rm -p 80:80 alpine:3.20 true

Three container runs completing in 1.19, 0.96 and 0.97 seconds, followed by the privileged port error refusing to expose port 80

run+exit: 1.19s
run+exit: 0.96s
run+exit: 0.97s

Ports below 1024 are refused, and unusually, the error tells you every way out:

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)

-p 18080:80 binds without complaint. Behind a reverse proxy this rarely matters.

Disk throughput limits stop working. The daemon says so at startup, and it is easy to miss:

WARNING: No io.max (rbps) support
WARNING: No io.max (wbps) support

So --device-read-bps and --device-write-bps silently do nothing. If you were relying on those to stop one container starving the others, rootless takes that away.

Capability dropping still works, which matters because these are often treated as alternatives:

docker -c rootless run --rm --cap-drop=ALL alpine:3.20 sh -c 'echo cap-drop ok'
cap-drop ok

They stack. Keep using user: and --cap-drop=ALL — each layer assumes the one before it failed.

Shared folders behave differently​

This is the part that catches people moving a real service across. Host permissions apply as you, not as root:

D=/tmp/rl-vol; mkdir -p $D; echo hello > $D/mine.txt
sudo sh -c "echo secret > $D/theirs.txt; chmod 600 $D/theirs.txt"
docker -c rootless run --rm -v $D:/data alpine:3.20 sh -c \
'cat /data/mine.txt; cat /data/theirs.txt 2>&1 || echo DENIED'
docker -c rootless run --rm -v $D:/data alpine:3.20 stat -c '%u:%g %n' /data/mine.txt

The container reads the user-owned file, is denied on the root-owned file, and reports the user-owned file as belonging to 0:0 inside the container

hello
cat: can't open '/data/theirs.txt': Permission denied
DENIED
0:0 /data/mine.txt

Your own file reads fine. The root-owned one is refused — even though the container thinks it is root, because the kernel knows better. And your file appears inside the container as owned by 0:0, the same translation running in reverse.

Worth internalising before you move a database over and spend an hour wondering why it cannot open its own data directory.

One more: the rootless daemon keeps a separate image store at ~/.local/share/docker. Images your system daemon already has are invisible to it, and everything gets pulled again. On a tight disk, budget for it.

Wall 4 — removing it refuses too​

For symmetry, the uninstall has the same guard:

PATH=/home/ubuntu/bin:$PATH ~/bin/dockerd-rootless-setuptool.sh uninstall

The uninstall script aborts because rootful Docker is running and accessible, instructing the user to set --force

[ERROR] Aborting because rootful Docker (/var/run/docker.sock) is running and accessible.
Set --force to ignore.

Add --force when you genuinely want it gone.

What this does and does not buy you​

It changes the worst case from "an attacker owns the machine" to "an attacker owns this user account". On a host where every service runs as ubuntu, that is a large move and not a complete one — your SSH keys, your .env files and your source code all still sit inside that account.

So the honest summary: rootless raises the floor. It does not make the room safe. Narrowing what the account itself can reach is the next rung, and that is where system-call filtering and image supply chain come in.

But the specific thing that was true at the top of this post — that anyone able to run a container could take the whole machine — is no longer true, and you can prove it in one command.

Finished this tutorial?
Mark it complete to earn Move the daemon off root on your skill path.

Further reading​

Comments & questions

Hit an error, spotted a typo, or have a question? Leave a note below.