Rootless Docker is one of those upgrades that everyone agrees is a good idea and almost nobody sets up by hand, because the setup is genuinely fiddly. You need the uidmap package, subordinate UID and GID ranges in two /etc/sub* files, a systemd user service, lingering enabled so that service survives logout, and a DOCKER_HOST variable pointing at a socket most tutorials forget to mention. Miss one step and docker ps just hangs or errors, with no obvious hint why.
So I wrote a script that does the whole thing in one command. It lives in my open-source WDM repo as scripts/ops/provision-rootless-docker-user.sh, and this post walks through what rootless Docker is, why it matters for security, and exactly what the script automates.
What rootless Docker actually is
Normal Docker runs a daemon as root. That daemon owns a Unix socket, and anything that can talk to the socket can tell root to do things. Containers run as processes managed by that root daemon.
Rootless Docker changes the ownership model. The daemon runs as an ordinary user. Your containers run as that ordinary user. It uses Linux user namespaces so that UID 0 inside a container is not real root on the host. It maps to a harmless subordinate UID, somewhere up in the 200,000+ range, that owns nothing important.
That mapping is the whole point. Inside the container, a process thinks it is root. Outside, on the host, it is a nobody.
Why rootless Docker matters for security
Here is the part that made me switch every self-hosted box I run.
With classic Docker, two things are root-equivalent. First, the daemon itself: a container escape or a daemon exploit gives an attacker root on the host, full stop. Second, the docker group. People add their user to the docker group to skip typing sudo, and most of them do not realise they just handed that user root. Anyone in the docker group can run docker run -v /:/host and mount the entire host filesystem into a container they control. Read /etc/shadow, drop a cron job, rewrite sudoers. Game over, no password needed.
Rootless Docker deletes that whole category. The daemon is not root, so escaping a container drops you into an unprivileged account. The docker group is not involved, so there is nothing to abuse. You trade a bit of setup complexity for a much smaller blast radius.
Why installing rootless Docker by hand is a pain
The rootless mode is well documented, but the happy path has more moving parts than people expect. To do it properly on a fresh Debian or Ubuntu host, you have to:
- install
uidmap,dbus-user-session,iproute2, and a few others so the user session and network plumbing exist - add a
username:start:65536line to/etc/subuidand another to/etc/subgid, without colliding with any range already in use - enable
net.ipv4.ip_forwardso container networking works - run
loginctl enable-lingerfor the user, or the systemd user service dies the moment you log out - start the user manager, run
dockerd-rootless-setuptool.sh install, and enable thedocker.serviceuser unit - export
XDG_RUNTIME_DIRandDOCKER_HOST=unix://.../docker.sockin the user’s shell profile, or the client cannot find the daemon
None of these steps is hard. The problem is that there are six of them, they have to be in the right order, and a single wrong subuid range or a forgotten linger call leaves you debugging a daemon that silently refuses to start. I got tired of doing it from memory on every new server.
What the provisioning script automates
The script’s job is to turn that checklist into one command. You run it as root on the target server, hand it a username, and it does the rest.
Here is what it handles end to end:
- Creates a dedicated user. A fresh
useradd --create-homeaccount just for Docker. It validates the name, refuses reserved system names likerootandwww-data, and refuses any existing account with a UID below 1000. - Installs the prerequisites. Eight apt packages:
ca-certificates,curl,dbus-user-session,iproute2,procps,sudo,tar, anduidmap. Ifapt-getis not present, it says so instead of guessing. - Writes the subuid and subgid maps. It scans the existing files, finds the next free start point, and appends a 65536-wide range for the user. No manual arithmetic, no collisions.
- Turns on IP forwarding with a dedicated
sysctl.ddrop-in, so the change survives reboots. - Enables lingering with
loginctl enable-lingerand starts the user’s systemd manager, so the Docker user service runs whether or not the user is logged in. - Installs a pinned, verified Docker. It downloads the Docker 29.6.0 static release and the rootless extras, checks both tarballs against known SHA256 sums, and only then unpacks them into
~/bin. Same for the Docker Compose plugin (pinned tov5.1.2). Nothing runs if a checksum does not match. - Wires up the shell. It appends a block to
.profileand.bashrcthat setsPATH,XDG_RUNTIME_DIR, andDOCKER_HOSTso thedockerCLI finds the rootless socket automatically. - Runs the setup tool and verifies. It calls
dockerd-rootless-setuptool.sh install, enables the userdocker.service, points therootlesscontext at the socket, and finishes withdocker run --rm hello-worldso you know it actually works before it hands the box back to you.
It is also idempotent. Run it twice and it detects the existing Docker and Compose versions, confirms they match the pinned ones, and skips the download instead of clobbering anything.
How do you run the rootless Docker script?
Grab it from the repo and run it as root, passing the user you want provisioned:
sudo ./provision-rootless-docker-user.sh --user dockeruser
If you leave off --user, it prompts for the name. When it finishes, log in as that user and rootless Docker is ready:
sudo -iu dockeruser
docker run --rm hello-world
docker compose version
Before I run it on a host I have not touched before, I do a dry run first. This prints every command it would execute and changes nothing:
sudo ./provision-rootless-docker-user.sh --user dockeruser --dry-run
There is one more flag worth knowing. The script runs a precheck to confirm the host actually allows the unprivileged proc mount that rootless Docker depends on. Some hardened kernels and nested container hosts block it. If the precheck fails, the script stops with a clear message instead of leaving you a half-installed mess. If you already know your host is fine and want to skip that check, pass --skip-host-check.
What it deliberately refuses to do
The refusals matter as much as the installs, so I want to be explicit about them.
It will not provision root or any reserved system account, and it rejects any existing user with a UID under 1000. Rootless Docker on a system account defeats the purpose.
It will not touch a user who is already in the docker group. If it finds that membership, it stops and tells you to remove it first. Mixing rootless Docker with root-equivalent group access would be the worst of both worlds.
It pins one architecture and one version on purpose. The release is x86_64 only, and if the machine reports a different architecture, it refuses rather than pulling an artifact whose checksum it cannot vouch for. Every download is SHA256-checked. If a checksum is wrong, nothing installs.
Where this fits, and where it does not
This script is the boring foundation step. It gets a hardened host to the point where you have a dedicated, rootless Docker user that can run containers safely. After that, the rest of my stack slots in on top.
It pairs naturally with a reverse proxy. My usual pattern is to bind apps to localhost and put Nginx Proxy Manager, Caddy, or Traefik in front to decide what is actually reachable. It also pairs with WDM, which installs curated Compose stacks for that user once Docker is running. And it assumes you have already done the host basics: SSH keys, no password auth, a firewall, and updates, which I cover in Linux server security fundamentals.
What it is not: a cross-platform installer. It targets x86_64 Linux with apt, it pins specific versions, and it expects a systemd host. That narrowness is the point. I would rather ship a script with clear edges than a clever one that tries to handle every distro and quietly gets one of them wrong.
Closing the loop
Rootless Docker is worth the effort because it removes the two most common paths to root on a container host: the root daemon and the docker group. The only real reason people skip it is that the manual setup has too many steps to remember correctly at the end of a long day.
So I stopped remembering them. One command, a dedicated user, verified downloads, and a hello-world check that proves it works before you walk away. Read the script first, run it with --dry-run if you want to see it think, then let it do the fiddly part for you.