Provisioning Linux Workstations With Ansible
Developers on Linux desktops each installed Docker differently — the distribution's docker.io, Docker's own repository, a snap — and the result is a mix of permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock, docker: 'compose' is not a docker command, and one machine where the snap's confinement hides the project directory from containers. The fix is not another wiki page but a single, idempotent definition of the Linux workstation baseline. This page writes one as an Ansible playbook that developers can run on their own machine and a platform team can run across a fleet, as part of workstation provisioning and team dotfiles.
Ansible fits because it is agentless, readable, idempotent by design and can target localhost as easily as a hundred machines. Its check mode also makes drift visible without changing anything.
Diagnostic
Record how the current machine differs from what the team expects:
#!/usr/bin/env bash
set -euo pipefail
. /etc/os-release && echo "distro: $ID $VERSION_ID"
snap list docker 2>/dev/null && echo "docker installed as a snap" || true
dpkg -l | awk '/^ii/ && $2 ~ /^(docker|containerd|docker-ce|docker.io|docker-compose)/ {print "deb:", $2, $3}'
id -nG "$USER" | tr ' ' '\n' | grep -qx docker && echo "user in docker group" || echo "user NOT in docker group"
docker compose version 2>&1 | head -1
Expected bad output:
distro: ubuntu 24.04
docker 27.2.0 2915 latest/stable canonical** -
deb: docker-compose 1.29.2-6
user NOT in docker group
docker: 'compose' is not a docker command.
Docker comes from a snap, the old Python docker-compose v1 package is installed instead of the v2 plugin, and the user cannot reach the daemon without sudo.
Root cause
Linux gives developers several legitimate ways to install the same tool, each with different packaging, versions and confinement. Without a shared definition, every machine reflects whichever tutorial its owner found. Docker is the worst case: the distribution's docker.io, Docker's upstream docker-ce repository and the snap all install an engine, but only some include the Compose v2 plugin, the snap restricts which host paths containers can mount, and none adds the user to the docker group automatically. A playbook turns those choices into one reviewed decision applied everywhere, and Ansible's idempotency means applying it again to a correct machine changes nothing, so it can run on a schedule to correct drift.
Resolution
- Write the playbook with the baseline as variables and tasks that converge each machine to it:
- name: Developer workstation baseline
hosts: all
become: true
vars:
dev_user: "{{ ansible_env.SUDO_USER | default(ansible_user_id) }}"
baseline_packages: [git, curl, jq, make, build-essential, pkg-config, libssl-dev, direnv, ca-certificates]
tasks:
- name: Remove conflicting Docker packages
ansible.builtin.apt:
name: [docker-compose, podman-docker]
state: absent
- name: Remove the Docker snap
community.general.snap:
name: docker
state: absent
- name: Install baseline packages
ansible.builtin.apt:
name: "{{ baseline_packages }}"
state: present
update_cache: true
cache_valid_time: 3600
- name: Add Docker's apt key
ansible.builtin.get_url:
url: https://download.docker.com/linux/ubuntu/gpg
dest: /etc/apt/keyrings/docker.asc
mode: "0644"
- name: Add Docker's apt repository
ansible.builtin.apt_repository:
repo: "deb [signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable"
filename: docker
- name: Install Docker Engine with Compose and Buildx plugins
ansible.builtin.apt:
name: [docker-ce, docker-ce-cli, containerd.io, docker-buildx-plugin, docker-compose-plugin]
state: present
update_cache: true
- name: Let the developer use Docker without sudo
ansible.builtin.user:
name: "{{ dev_user }}"
groups: docker
append: true
- name: Raise inotify limits for file watchers
ansible.posix.sysctl:
name: "{{ item.name }}"
value: "{{ item.value }}"
sysctl_file: /etc/sysctl.d/60-dev-watchers.conf
loop:
- { name: fs.inotify.max_user_watches, value: "524288" }
- { name: fs.inotify.max_user_instances, value: "512" }
Save as workstation.yml. The inotify limits prevent the ENOSPC watcher errors covered in fixing ENOSPC file watcher limit errors.
- Run it self-service on the developer's own machine:
#!/usr/bin/env bash
set -euo pipefail
sudo apt-get install -y -qq ansible-core
ansible-galaxy collection install community.general ansible.posix
ansible-playbook -i localhost, -c local workstation.yml --ask-become-pass
newgrp docker <<'EOF'
docker compose version
EOF
Group membership takes effect in new login sessions; newgrp applies it to the current one for verification.
- Run it centrally for managed machines, with an inventory and a schedule:
#!/usr/bin/env bash
set -euo pipefail
ansible-playbook -i inventory/workstations.ini workstation.yml --check --diff | tee drift-report.txt
grep -E '^(changed|failed):' drift-report.txt | sort | uniq -c
--check --diff reports exactly what would change on each machine without changing it — a drift report the platform team can review before applying.
Expected output
PLAY RECAP *********************************************************************
localhost : ok=10 changed=6 unreachable=0 failed=0 skipped=0
$ id -nG | tr ' ' '\n' | grep -x docker
docker
$ docker compose version
Docker Compose version v2.29.2
$ ansible-playbook -i localhost, -c local workstation.yml --ask-become-pass | tail -2
localhost : ok=10 changed=0 unreachable=0 failed=0 skipped=0
The first run changes six things; the second changes nothing, which is the idempotency that makes scheduled runs safe. Docker works without sudo, and Compose v2 is available as a plugin.
Prevention
Lint and test the playbook in CI with
ansible-lintand a run against a fresh Ubuntu container or VM image, so changes are proven before they reach machines.Schedule check-mode runs weekly for managed machines and alert on unexpected
changedcounts, which usually mean someone installed a conflicting package.Leave project tools to projects. The playbook installs the baseline and the toolchain manager; project versions belong in each repository's
mise.tomlor dev container, as discussed in the workstation provisioning topic.
Platform caveats
Fedora and RHEL: replace
apttasks withdnf, and Docker's repository URL with the Fedora or RHEL one; with SELinux enforcing, bind mounts need:z/:Zlabels in Compose files.
Rootless Docker: the
dockergroup grants root-equivalent access to the host. Security-sensitive organisations may prefer rootless Docker or Podman; adapt the playbook's group task accordingly and document the trade-off.
WSL2: the same playbook works inside a WSL2 Ubuntu distribution with systemd enabled; skip the snap removal if snapd is not installed.
Apple Silicon (ARM64): not applicable to macOS; for ARM Linux workstations, Docker's apt repository publishes
arm64packages and the playbook works unchanged.
Rollback
Ansible does not keep automatic undo information. Reverse individual changes with the inverse tasks, or restore from the machine's backup. Removing the Docker packages and group membership looks like this:
#!/usr/bin/env bash
set -euo pipefail
sudo apt-get remove -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo gpasswd -d "$USER" docker
sudo rm -f /etc/apt/sources.list.d/docker.list /etc/sysctl.d/60-dev-watchers.conf
Frequently Asked Questions
Why use Ansible instead of a shell script?
Ansible tasks are idempotent and declarative, and check mode reports drift without changing anything. A shell script can be idempotent too, but it takes more care, and it cannot produce a reviewable diff across a fleet.
Why remove the Docker snap?
The snap's confinement restricts which host paths containers can mount and changes where configuration lives, which breaks bind mounts outside the home directory and confuses tooling. Docker's upstream packages avoid those restrictions.
Is adding developers to the docker group safe?
It is convenient but grants root-equivalent access, because anyone who can start containers can mount the host filesystem. On developer workstations this is usually acceptable; where it is not, use rootless Docker or Podman.
Can developers extend the playbook for their own tools?
Keep the team playbook to the shared baseline. Personal tools belong in personal dotfiles or a separate personal playbook that runs after the team one.