guest@6admin.io:~$

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.