Rootless Docker is a significant leap forward for container security, effectively mitigating the risks of privilege escalation by running the Docker daemon and containers within a user’s namespace. However, this security advantage introduces operational complexity. Standard, system-wide automation tools like Ansible, which are accustomed to managing privileged system services, must be adapted to this user-centric model. Manually SSH-ing into servers to run apt upgrade as a specific user is not a scalable or secure solution.
This guide provides a production-ready Ansible playbook and the expert-level context required to automate rootless Docker updates. We will bypass the common pitfalls of environment variables and systemd --user services, creating a reliable, idempotent automation workflow fit for production.
Table of Contents
Why Automate Rootless Docker Updates?
While “rootless” significantly reduces the attack surface, the Docker daemon itself is still a complex piece of software. Security vulnerabilities can and do exist. Automating updates ensures:
- Rapid Security Patching: C-V-E-s affecting the Docker daemon or its components can be patched across your fleet without manual intervention.
- Consistency and Compliance: Ensures all environments are running the same, approved version of Docker, simplifying compliance audits.
- Reduced Toil: Frees SREs and DevOps engineers from the repetitive, error-prone task of manual updates, especially in environments with many hosts.
The Core Challenge: Rootless vs. Traditional Automation
With traditional (root-full) Docker, Ansible’s job is simple. It connects as root (or uses become) and manages the docker service via system-wide systemd. With rootless, Ansible faces three key challenges:
1. User-Space Context
The rootless Docker daemon doesn’t run as PID 1‘s systemd. It runs as a systemd --user service under the specific, unprivileged user account. Ansible must be instructed to operate within this user’s context.
2. Environment Variables (DOCKER_HOST)
The Docker CLI (and Docker Compose) relies on environment variables like DOCKER_HOST and XDG_RUNTIME_DIR to find the user-space daemon socket. While our automation will primarily interact with the systemd service, tasks that validate the daemon’s health must be aware of this.
3. Service Lifecycle and Lingering
systemd --user services, by default, are tied to the user’s login session. If the user logs out, their systemd instance and the rootless Docker daemon are terminated. For a server process, this is unacceptable. The user must be configured for “lingering” to allow their services to run at boot without a login session.
Building the Ansible Playbook to Automate Rootless Docker Updates
Let’s build the playbook step-by-step. Our goal is a single, idempotent playbook that can be run repeatedly. This playbook assumes you have already installed rootless Docker for a specific user.
We will define our target user in an Ansible variable, docker_rootless_user.
Step 1: Variables and Scoping
We must target the host and define the user who owns the rootless Docker installation. We also need to explicitly tell Ansible to use privilege escalation (become: yes) not to become root, but to become the target user.
---
- name: Update Rootless Docker
hosts: docker_hosts
become: yes
vars:
docker_rootless_user: "docker-user"
tasks:
# ... tasks will go here ...
π‘ Advanced Concept:
become_uservs.remote_userYour
remote_user(inansible.cfgor-uflag) is the user Ansible SSHes into the machine as (e.g.,ansible,ec2-user). This user typically has passwordlesssudo. We usebecome: yesandbecome_user: {{ docker_rootless_user }}to switch from theansibleuser to thedocker-userto run our tasks. This is crucial.
Step 2: Ensure User Lingering is Enabled
This is the most common failure point. Without “lingering,” the systemd --user instance won’t start on boot. This task runs as root (default become) to execute loginctl.
- name: Enable lingering for {{ docker_rootless_user }}
command: "loginctl enable-linger {{ docker_rootless_user }}"
args:
creates: "/var/lib/systemd/linger/{{ docker_rootless_user }}"
become_user: root # This task must run as root
become: yes
We use the creates argument to make this task idempotent. It will only run if the linger file doesn’t already exist.
Step 3: Update the Docker Package
This task updates the docker-ce (or relevant) package. This task also needs to run with root privileges, as it’s installing system-wide binaries.
- name: Update Docker CE package
ansible.builtin.package:
name: docker-ce
state: latest
become_user: root # Package management requires root
become: yes
notify: Restart rootless docker service
Note the notify keyword. We are separating the package update from the service restart. This is a core Ansible best practice.
Step 4: Manage the Rootless systemd Service
This is the core of the automation. We define a handler that will be triggered by the update task. This handler *must* run as the docker_rootless_user and use the scope: user setting in the ansible.builtin.systemd module.
First, we need to gather the user’s XDG_RUNTIME_DIR, as systemd --user needs it.
- name: Get user XDG_RUNTIME_DIR
ansible.builtin.command: "printenv XDG_RUNTIME_DIR"
args:
chdir: "/home/{{ docker_rootless_user }}"
changed_when: false
become: yes
become_user: "{{ docker_rootless_user }}"
register: xdg_dir
- name: Set DOCKER_HOST fact
ansible.builtin.set_fact:
user_xdg_runtime_dir: "{{ xdg_dir.stdout }}"
user_docker_host: "unix://{{ xdg_dir.stdout }}/docker.sock"
handlers:
- name: Restart rootless docker service
ansible.builtin.systemd:
name: docker
state: restarted
scope: user
become: yes
become_user: "{{ docker_rootless_user }}"
environment:
XDG_RUNTIME_DIR: "{{ user_xdg_runtime_dir }}"
By using scope: user, we tell Ansible to talk to the user’s systemd bus, not the system-wide one. Passing the XDG_RUNTIME_DIR in the environment ensures the systemd command can find the user’s runtime environment.
The Complete, Production-Ready Ansible Playbook
Here is the complete playbook, combining all elements with handlers and correct user context switching.
---
- name: Automate Rootless Docker Updates
hosts: docker_hosts
become: yes
vars:
docker_rootless_user: "docker-user" # Change this to your user
tasks:
- name: Ensure lingering is enabled for {{ docker_rootless_user }}
ansible.builtin.command: "loginctl enable-linger {{ docker_rootless_user }}"
args:
creates: "/var/lib/systemd/linger/{{ docker_rootless_user }}"
become_user: root # Must run as root
changed_when: false # This command's output isn't useful for change status
- name: Update Docker packages (CE, CLI, Buildx)
ansible.builtin.package:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin
state: latest
become_user: root # Package management requires root
notify: Get user environment and restart rootless docker
handlers:
- name: Get user environment and restart rootless docker
block:
- name: Get user XDG_RUNTIME_DIR
ansible.builtin.command: "printenv XDG_RUNTIME_DIR"
args:
chdir: "/home/{{ docker_rootless_user }}"
changed_when: false
register: xdg_dir
- name: Fail if XDG_RUNTIME_DIR is not set
ansible.builtin.fail:
msg: "XDG_RUNTIME_DIR is not set for {{ docker_rootless_user }}. Is the user logged in or lingering enabled?"
when: xdg_dir.stdout | length == 0
- name: Set user_xdg_runtime_dir fact
ansible.builtin.set_fact:
user_xdg_runtime_dir: "{{ xdg_dir.stdout }}"
- name: Force daemon-reload for user systemd
ansible.builtin.systemd:
daemon_reload: yes
scope: user
environment:
XDG_RUNTIME_DIR: "{{ user_xdg_runtime_dir }}"
- name: Restart rootless docker service
ansible.builtin.systemd:
name: docker
state: restarted
scope: user
environment:
XDG_RUNTIME_DIR: "{{ user_xdg_runtime_dir }}"
# This entire block runs as the target user
become: yes
become_user: "{{ docker_rootless_user }}"
listen: "Get user environment and restart rootless docker"
π‘ Pro-Tip: Validating the Update
To verify the update, you can add a final task that runs
docker version*as the rootless user*. This confirms both the package update and the service health.post_tasks: - name: Verify rootless Docker version ansible.builtin.command: "docker version" become: yes become_user: "{{ docker_rootless_user }}" environment: DOCKER_HOST: "unix://{{ user_xdg_runtime_dir }}/docker.sock" register: docker_version changed_when: false - name: Display new Docker version ansible.builtin.debug: msg: "{{ docker_version.stdout }}"
Frequently Asked Questions (FAQ)
How do I run Ansible tasks as a non-root user for rootless Docker?
You use become: yes combined with become_user: your-user-name. This tells Ansible to use its privilege escalation method (like sudo) to switch to that user account, rather than to root.
What is `loginctl enable-linger` and why is it mandatory?
Linger instructs systemd-logind to keep a user’s session active even after they log out. This allows the systemd --user instance to start at boot and run services (like docker.service) persistently. Without it, the rootless Docker daemon would stop the moment your Ansible session (or any SSH session) closes.
How does this playbook handle the `DOCKER_HOST` variable?
This playbook correctly avoids relying on a pre-set DOCKER_HOST. Instead, it interacts with the systemd --user service directly. For the validation task, it explicitly sets the DOCKER_HOST environment variable using the XDG_RUNTIME_DIR fact it discovers, ensuring the docker CLI can find the correct socket.

Conclusion
Automating rootless Docker is not as simple as its root-full counterpart, but it’s far from impossible. By understanding that rootless Docker is a user-space application managed by systemd --user, we can adapt our automation tools.
This Ansible playbook provides a reliable, idempotent, and production-safe method to automate rootless Docker updates. It respects the user-space context, correctly handles the systemd user service, and ensures the critical “lingering” prerequisite is met. By adopting this approach, you can maintain the high-security posture of rootless Docker without sacrificing the operational efficiency of automated fleet management. Thank you for reading theΒ DevopsRolesΒ page!
