Docker rootless: the daemon without privileges
Docker runs as root by default. The daemon (dockerd) has access to ALL the filesystem, ALL the network, ALL the processes. A container that escapes the daemon gets root on the host. Rootless mode moves the daemon to a normal user: containers run in a user namespace and an escape only yields that user’s rights, not root.
The problem
A Docker daemon running as root is a service with all the rights on the machine, accessible via a Unix socket (and sometimes a TCP port). Every container mounting a volume mounts with the daemon’s root rights. Every bug in runc or containerd is a root escape.
The installation
# Create the user
useradd -m -s /bin/bash dockerr
# Install rootless dependencies
apt-get install uidmap dbus-user-session
# Install Docker rootless
curl -fsSL https://get.docker.com/rootless | sh
The script installs the daemon in the user’s home (~/bin/dockerd), configures the subuid/subgid (/etc/subuid, /etc/subgid) and starts the daemon in user mode.
The Salt config
The automated deployment via Salt:
{% set docker_user = salt["pillar.get"]("docker_rootless:user","docker") %}
{% set docker_version = "20.10.12" %}
{% set compose_version = "1.29.2" %}
{% set docker_home = "/home/" ~ docker_user %}
{% set docker_bin_dir = docker_home ~ "/bin" %}
apt_deps:
pkg.installed:
- names:
- curl
- dbus-user-session
- uidmap
- fuse-overlayfs
- slirp4netns
net.ipv4.ip_unprivileged_port_start:
sysctl.present:
- name: net.ipv4.ip_unprivileged_port_start
- value: 53
user.max_user_namespaces:
sysctl.present:
- name: user.max_user_namespaces
- value: 15000
{% if grains.os_family == 'Debian' and grains.osmajorrelease <= 10 %}
kernel.unprivileged_userns_clone:
sysctl.present:
- name: kernel.unprivileged_userns_clone
- value: 1
{% endif %}
{{ docker_user }}:
user.present:
- system: true
- fullname: {{ docker_user }}
- shell: /bin/bash
- home: {{ docker_home }}
- usergroup: true
- createhome: false
systemd_enable_lingering:
cmd.run:
- name: loginctl enable-linger {{ docker_user }}
- require:
- user: {{ docker_user }}
- onchanges:
- user: {{ docker_user }}
/etc/subuid:
file.append:
- text:
- {{ docker_user}}:100000:65536
- require:
- pkg: apt_deps
/etc/subgid:
file.append:
- text:
- {{ docker_user}}:100000:65536
- require:
- pkg: apt_deps
{{ docker_home }}:
file.directory:
- user: {{ docker_user }}
- group: {{ docker_user }}
- mode: '0750'
- require:
- user: {{ docker_user }}
{{ docker_bin_dir }}:
file.directory:
- user: {{ docker_user }}
- group: {{ docker_user }}
- mode: '0750'
- require:
- file: {{ docker_home }}
extract_docker:
archive.extracted:
- name: {{ docker_bin_dir }}
- user: {{ docker_user }}
- group: {{ docker_user }}
- source: salt://docker/files/{{ docker_version }}-docker.tgz
- options: --strip-components=1
- enforce_toplevel: false
- require:
- file : {{ docker_bin_dir }}
extract_docker_rootless:
archive.extracted:
- name: {{ docker_bin_dir }}
- user: {{ docker_user }}
- group: {{ docker_user }}
- source: salt://docker/files/{{ docker_version }}-rootless.tgz
- options: --strip-components=1
- enforce_toplevel: false
- require:
- file : {{ docker_bin_dir }}
{{ docker_bin_dir }}/docker-compose:
file.managed:
- user: {{ docker_user }}
- group: {{ docker_user }}
- mode: '0750'
- source: salt://docker/files/{{ compose_version }}-docker-compose.bin
- require:
- file : {{ docker_bin_dir }}
{{ docker_home }}/.profile:
file.managed:
- user: nobody
- group: nogroup
- mode: '0644'
- attrs: i
- replace: true
- template: jinja
- source: salt://docker/files/profile.j2
- defaults:
docker_bin_dir: {{ docker_bin_dir }}
- require:
- file: {{ docker_bin_dir }}
{{ docker_home }}/.config/systemd/user:
file.directory:
- user: {{ docker_user }}
- group: {{ docker_user }}
- mode: '0750'
- makedirs: true
- require:
- file: {{ docker_bin_dir }}
{{ docker_home }}/.config/systemd/user/docker.service.d:
file.directory:
- user: {{ docker_user }}
- group: {{ docker_user }}
- mode: '0750'
- require:
- file: {{ docker_home }}/.config/systemd/user
{{ docker_home }}/.config/systemd/user/docker.service:
file.managed:
- user: {{ docker_user }}
- group: {{ docker_user }}
- mode: '0640'
- replace: true
- template: jinja
- source: salt://docker/files/systemd_rootless.j2
- defaults:
docker_bin_dir: {{ docker_bin_dir }}
- require:
- file : {{ docker_home }}/.config/systemd/user
- archive: extract_docker
- archive: extract_docker_rootless
{{ docker_home }}/.config/systemd/user/docker.service.d/override.conf:
file.managed:
- user: {{ docker_user }}
- group: {{ docker_user }}
- mode: '0640'
- replace: true
- source: salt://docker/files/docker_service_override.conf
- require:
- file : {{ docker_home }}/.config/systemd/user/docker.service.d
systemd_reload_docker:
cmd.run:
- name: systemctl --user daemon-reload
- runas: {{ docker_user }}
- onchanges:
- file: {{ docker_home }}/.config/systemd/user/docker.service
- file: {{ docker_home }}/.config/systemd/user/docker.service.d/override.conf
- require:
- user: {{ docker_user }}
- file: {{ docker_home }}/.config/systemd/user/docker.service
- file: {{ docker_home }}/.config/systemd/user/docker.service.d/override.conf
{% if not salt["file.file_exists"](docker_home ~ "/.config/systemd/user/default.target.wants/docker.service") %}
systemd_enable_docker:
cmd.run:
- name: systemctl --user enable docker.service
- runas: {{ docker_user }}
- require:
- cmd: systemd_reload_docker
{% endif %}
systemd_restart_docker:
cmd.run:
- name: systemctl --user restart docker.service
- runas: {{ docker_user }}
- onchanges:
- file: /home/{{ docker_user }}/.config/systemd/user/docker.service
- file: /home/{{ docker_user }}/.config/systemd/user/docker.service.d/override.conf
systemd_status_docker:
cmd.run:
- name: systemctl --user status docker.service
- runas: {{ docker_user }}
- onchanges:
- file: /home/{{ docker_user }}/.config/systemd/user/docker.service
- file: /home/{{ docker_user }}/.config/systemd/user/docker.service.d/override.conf
- require:
- cmd: systemd_restart_docker
docker_version:
cmd.run:
- name: docker --version
- runas: {{ docker_user }}
- onchanges:
- archive: extract_docker
- require:
- archive: extract_docker
- archive: extract_docker_rootless
docker-compose_version:
cmd.run:
- name: docker-compose --version
- runas: {{ docker_user }}
- onchanges:
- file: {{ docker_bin_dir }}/docker-compose
- require:
- file: {{ docker_bin_dir }}/docker-compose
- cmd: docker_version
The referenced files
The state drops three files in the user’s home, served from the Salt fileserver (salt://docker/files/).
profile.j2, the account’s ~/.profile (the Docker client environment):
[ -z "${XDG_RUNTIME_DIR}" ] && export XDG_RUNTIME_DIR="/run/user/$UID"
export DOCKER_HOST="unix://$XDG_RUNTIME_DIR/docker.sock"
export PATH="{{ docker_bin_dir }}:$PATH"
systemd_rootless.j2, the user systemd unit (~/.config/systemd/user/docker.service):
[Unit]
Description=Docker Application Container Engine (Rootless)
Documentation=https://docs.docker.com/go/rootless/
[Service]
Environment=PATH={{ docker_bin_dir }}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ExecStart={{ docker_bin_dir }}/dockerd-rootless.sh
ExecReload=/bin/kill -s HUP $MAINPID
TimeoutSec=0
RestartSec=2
Restart=always
StartLimitBurst=3
StartLimitInterval=60s
LimitNOFILE=infinity
LimitNPROC=infinity
LimitCORE=infinity
TasksMax=infinity
Delegate=yes
Type=simple
KillMode=mixed
[Install]
WantedBy=default.target
docker_service_override.conf, forces the slirp4netns port driver to recover the real IP addresses:
[Service]
Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER=slirp4netns"
The binaries (20.10.12-docker.tgz, 20.10.12-rootless.tgz, 1.29.2-docker-compose.bin) are served from the fileserver, not shown here.
The pain points
- cgroup v2: Docker rootless requires cgroup v2, not cgroup v1. On Debian,
GRUB_CMDLINE_LINUX="systemd.unified_cgroup_hierarchy=1"in /etc/default/grub, then reboot. - slirp4netns: rootless networking goes through slirp4netns, a userspace process doing NAT between the container and the host network. Slower than Docker’s native bridge, but functional.
- The volumes: rootless Docker volumes are in the user’s home, not in /var/lib/docker. The paths change and scripts assuming /var/lib/docker break.
What it changes
Before: dockerd as root, every container mounts with root rights, an escape = root on the host.
After: dockerd as user dockerr, every container mounts with dockerr’s rights, an escape = dockerr’s rights (which cannot touch the system).
The attack surface shrinks from full root to a confined user. It is the non-root principle applied to the daemon itself.
Docker rootless became standard in modern distributions and Podman (the daemonless alternative) made it even more natural: no daemon at all. The principle has not moved: a container escape only yields the user’s rights, not the host’s root.
[edit: 2026] The Ansible implementation of all this: the docker and podman roles.