Skip to content
DasPeTe Tutorials

Rootless Docker: Run Docker Without Root on Linux

Install Docker on a Linux host so that the daemon and every container run as your own user instead of root, and learn the few things that work differently.

Quest objectives

  • Know why a Docker daemon that runs as root is a risk
  • Install Docker from its official packages on Ubuntu or Debian
  • Set up rootless mode and start it with the machine
  • Check that your containers really run without root
  • Deal with ports below 1024 and files in bind mounts

On almost every Linux server, Docker is installed the same way: as a background program, the Docker daemon, that runs as root, the user who may do anything. Every container it starts runs as root too, and whoever may talk to the daemon can use those powers. That is convenient, but it means that a single mistake, in a container or in your own setup, can hand someone the whole machine.

Rootless mode runs the daemon and every container as your own, ordinary user. What you know about Docker keeps working, from docker run to docker compose, but a container that breaks out only finds the rights you already have. In this tutorial you set it up on Ubuntu or Debian, check that it really runs without root, and learn the few things that work differently.

Why root is a problem

With a normal installation, the docker command sends every order to the daemon through a socket, a special file at /var/run/docker.sock. Whoever may write to it, root or any member of the docker group, can start a container that sees the host's whole disk. One line such as docker run -it -v /:/host alpine chroot /host opens a root shell on the host, without asking for a password. A program in a container that finds a hole in Docker or in the kernel lands on the host as root in the same way.

How rootless mode works

Linux can give a group of processes their own list of users, called a user namespace. Inside it, a process may be root while the kernel treats it as an ordinary user outside. Rootless Docker starts its daemon and all containers in such a namespace, and that namespace belongs to you. Root in a container is you on the host. The other users a container may use, such as user 999 of a database, become numbers from a range the system keeps for you:

text
in a container          on the host
root (0)           ->   robin (1000), that is you
user 1             ->   100000
user 999           ->   100998
user 65536         ->   165535

These spare numbers are called subordinate IDs. Two small helpers, newuidmap and newgidmap, are the only parts with special rights: they check that a range really is yours and hand it to the namespace.

The user in this tutorial is called robin. Where you see that name, you will see your own.

What you need

  • A 64-bit Linux host with Ubuntu 22.04, 24.04 or 26.04, or Debian 12 or 13. That can be a server you rent or your own computer.

  • A normal user account that may use sudo. Do every step as the user who will run Docker, not as root.

  • A real login as that user: at the computer itself or over ssh.

Check your ID range

On Ubuntu and Debian, every user created with adduser gets 65,536 subordinate IDs. Check that yours are there:

~
$ id -u1000$ grep "^$(whoami):" /etc/subuid /etc/subgid/etc/subuid:robin:100000:65536/etc/subgid:robin:100000:65536

Each line reads user:first ID:how many. The one from /etc/subuid is for user IDs, the one from /etc/subgid for group IDs.

Install Docker's packages

Docker has its own package repository. It is newer than the docker.io package of Ubuntu and Debian, and it has the parts that rootless mode needs. First tell apt where it is, and give it Docker's signing key, so it can check that the packages really come from Docker. The line . /etc/os-release reads your system's name and version into variables such as $ID, which is ubuntu or debian, so the same commands work on both:

bash
# Docker's signing key, so apt can check where the packages come from
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
. /etc/os-release
sudo curl -fsSL "https://download.docker.com/linux/$ID/gpg" \
  -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

# Docker's repository, for your distribution and its version
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/$ID
Suites: ${UBUNTU_CODENAME:-$VERSION_CODENAME}
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update

Then install Docker together with what rootless mode needs:

bash
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin \
  docker-compose-plugin docker-ce-rootless-extras uidmap dbus-user-session
  • docker-ce is the Docker Engine with its daemon, and docker-ce-cli the docker command.

  • containerd.io runs the containers for the daemon.

  • docker-buildx-plugin and docker-compose-plugin add docker build and docker compose.

  • docker-ce-rootless-extras brings RootlessKit, which builds the namespace, and the setup tool you run in a moment.

  • uidmap holds newuidmap and newgidmap.

  • dbus-user-session lets systemd look after programs of your user, not only those of the whole system.

Switch off the root daemon

Installing docker-ce also started the usual daemon that runs as root. You don't want it, so stop it and keep it from starting at boot. disable takes it out of the boot, and --now stops it right away. The setup tool refuses to work while the root daemon's socket is there, so the second line deletes it:

~
$ sudo systemctl disable --now docker.service docker.socket$ sudo rm -f /var/run/docker.sock

Set up rootless mode

Log out and back in first, or close your ssh connection and open a new one, so that your session picks up the packages you just installed. Then run the setup tool as your normal user, without sudo. Its check command tells you if anything is still missing, and install sets everything up:

~
$ dockerd-rootless-setuptool.sh check[INFO] Requirements are satisfied$ dockerd-rootless-setuptool.sh install[INFO] Creating /home/robin/.config/systemd/user/docker.service[INFO] starting systemd service docker.service+ systemctl --user start docker.service+ sleep 3+ systemctl --user --no-pager --full status docker.service● docker.service - Docker Application Container Engine (Rootless)     Loaded: loaded (/home/robin/.config/systemd/user/docker.service; disabled; preset: enabled)     Active: active (running) since Tue 2026-11-03 10:12:41 CET; 3s ago# more of the status, and what docker version prints+ systemctl --user enable docker.serviceCreated symlink '/home/robin/.config/systemd/user/default.target.wants/docker.service' → '/home/robin/.config/systemd/user/docker.service'.[INFO] Installed docker.service successfully.[INFO] To control docker.service, run: `systemctl --user (start|stop|restart) docker.service`[INFO] To run docker.service on system startup, run: `sudo loginctl enable-linger robin`[INFO] Creating CLI context "rootless"[INFO] Using CLI context "rootless"[INFO] Make sure the following environment variable(s) are set (or add them to ~/.bashrc):export PATH=/usr/bin:$PATH[INFO] Some applications may require the following environment variable too:export DOCKER_HOST=unix:///run/user/1000/docker.sock

The tool did three things:

  • It wrote a user service, ~/.config/systemd/user/docker.service, and started it. systemd now runs the daemon as your user, and starts it again if it crashes.

  • It created a context called rootless and switched to it. A context tells the docker command which daemon to talk to, here the socket in /run/user/1000 instead of /var/run/docker.sock.

  • It printed two lines for your shell. You can skip the one with PATH, because Docker's packages already put docker into /usr/bin.

Start Docker with the machine

A user service runs while you are logged in, which is all you need on your own computer. On a server, Docker should keep running after you close ssh, and start again after a reboot. For that, let your user's services linger:

~
$ sudo loginctl enable-linger $(whoami)$ loginctl show-user $(whoami) --property=LingerLinger=yes

From now on, you control Docker with systemctl --user, without sudo:

bash
systemctl --user status docker     # is it running?
systemctl --user restart docker    # restart it, for example after changing its settings
journalctl --user -u docker        # its log, for when something goes wrong

Check that it works

docker info tells you which daemon you are talking to and how it runs. It prints a lot, so grep picks out the lines that matter:

~
$ docker info | grep -E "Context|rootless|Root Dir" Context:    rootless  rootless Docker Root Dir: /home/robin/.local/share/docker
  • Context: rootless says that the docker command talks to your own daemon.

  • rootless on its own is one of the daemon's security options: it confirms that it runs in rootless mode.

  • Docker Root Dir is where your images, containers and volumes are kept now: in your home folder.

Then start your first container:

~
$ docker run --rm hello-worldUnable to find image 'hello-world:latest' locallylatest: Pulling from library/hello-world4f55086f7dd0: Pull completeDigest: sha256:c567b231f65614688711a75f31518a064258a51d442117a3e771a3a8fdba78efStatus: Downloaded newer image for hello-world:latestHello from Docker!This message shows that your installation appears to be working correctly.

See the difference

Now for the proof. Inside a container, you are still root:

~
$ docker run --rm alpine idUnable to find image 'alpine:latest' locallylatest: Pulling from library/alpineDigest: sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9aStatus: Downloaded newer image for alpine:latestuid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)

Start a container that keeps running for a while, and look at it from the host. ps lists running programs with the user they run as:

~
$ docker run -d --name sleeper alpine sleep 300cc5ff079109a24e95f7c1704fe7b2804bacd0f27514f9c81b71888e83f79136a$ ps -o user,pid,cmd -C sleepUSER         PID CMDrobin       4821 sleep 300$ docker rm -f sleepersleeper

The container's root is robin on the host. With a root daemon, that column would say root. Now try to read /etc/shadow, the file with the password hashes of every user on the machine, which only root may read:

~
$ docker run --rm -v /etc/shadow:/shadow:ro alpine cat /shadowcat: can't open '/shadow': Permission denied

With a root daemon, this prints every hash. Here the kernel says no, because to the host, the container's root is just you.

What works differently

Almost everything works as before. These are the differences you are most likely to run into.

Ports below 1024

On Linux, only root may open ports below 1024, such as 80 for HTTP and 443 for HTTPS, and your daemon isn't root:

~
$ docker run --rm -p 80:80 nginx:alpineUnable to find image 'nginx:alpine' locallyalpine: Pulling from library/nginxStatus: Downloaded newer image for nginx:alpinedocker: Error response from daemon: failed to set up container networking: driver failed programming external connectivity on endpoint nice_mestorf (dc9f509282865ba169473877ec68d9e5f87fc77b31fcbe2d23b0ff9f76bdb268): error while calling RootlessKit 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

The simple way out is a port from 1024 up. On a server, a reverse proxy then takes the requests on 80 and 443 and passes them on:

~
$ docker run -d --name web -p 8080:80 nginx:alpine3a1f7c2e9b8d4f60a5e2c1b7d9f3e8a6c4b2d0f1e9a7c5b3d1f0e8a6c4b2d9f7$ curl -s localhost:8080 | grep title<title>Welcome to nginx!</title>$ docker rm -f webweb

If a container has to answer on port 80 itself, allow ordinary programs to open ports from 80 up. The file in /etc/sysctl.d keeps the setting after a reboot, and sysctl -p applies it right away:

~
$ echo "net.ipv4.ip_unprivileged_port_start=80" | sudo tee /etc/sysctl.d/99-rootless-ports.confnet.ipv4.ip_unprivileged_port_start=80$ sudo sysctl -p /etc/sysctl.d/99-rootless-ports.confnet.ipv4.ip_unprivileged_port_start = 80

This lets every program of every user on the machine open those ports, which is fine on a server that is yours alone. Then start the container again.

Files in bind mounts

A bind mount, such as -v ./data:/data, shows a folder of the host inside the container. What the container's root writes there belongs to you. What a container writes as another user belongs to one of your spare IDs, which the host shows as a bare number. Databases do this: the official Postgres image, for example, writes its files as user 999. You can see it with a folder that belongs to user 999 in a container:

~
$ mkdir data$ docker run --rm -v ./data:/data alpine touch /data/hello.txt$ docker run --rm -v ./data:/data alpine sh -c 'mkdir /data/db && echo hi > /data/db/file && chown -R 999:999 /data/db && chmod 700 /data/db'$ ls -l datatotal 4drwx------ 2 100998 100998 4096 Nov  3 10:05 db-rw-r--r-- 1 robin  robin     0 Nov  3 10:05 hello.txt$ rm -rf datarm: cannot remove 'data/db': Permission denied$ rootlesskit rm -rf data

db belongs to 100998, the host's number for user 999, and only that user may look inside it, so rm can't empty it. rootlesskit runs a command in the same kind of namespace as Docker, where you are root over all your IDs, and there the folder can be deleted.

~
$ mkdir files$ docker run --rm --user $(id -u):$(id -g) -v ./files:/data alpine touch /data/notes.txttouch: /data/notes.txt: Permission denied

Leave the option out, and the container's root writes your files as you.

Your own images

The rootless daemon keeps its images, containers and volumes in ~/.local/share/docker, so they take up room in your home folder. Every user who sets up rootless mode gets a daemon of their own, and nobody sees anybody else's containers. Images from a root daemon you had before are not there, so pull or build them again.

CPU and memory limits

--memory and --pids-limit work as usual. --cpus also needs systemd to hand the CPU to your user, which recent systems do. This line shows what your user may limit:

~
$ cat /sys/fs/cgroup/user.slice/user-$(id -u).slice/user@$(id -u).service/cgroup.controllerscpu memory pids

If cpu is missing there, Docker's tips page shows how to add it.

Changed your mind?

The setup tool also undoes what it did: it stops and removes the user service and the context. Your images and containers stay, so delete them afterwards, again with rootlesskit, because some of the files belong to your spare IDs:

~
$ dockerd-rootless-setuptool.sh uninstall$ rootlesskit rm -rf ~/.local/share/docker

Where to go next

Your Docker runs without root now, and everything else stays as you know it: docker compose up, docker build and the rest. To put something online with it, the last part of the portfolio series, Put Your Portfolio Online, runs a CMS and a website on a server with Docker Compose. It installs Docker as root to keep things short. With rootless Docker, put the project into your home folder instead of /opt, and allow the ports from 80 up as shown above, because its Caddy container answers on 80 and 443.

Comments

The comments are GitHub Discussions: sign in with GitHub to write one.

The comments come from GitHub, through giscus. Loading them sends your IP address to giscus.app and GitHub.