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.

Reading the Docker Symptoms on Linux Decision diagram mapping common Docker errors on Linux workstations to installation causes. Reading the Docker Symptoms on Linux Which Docker error appears? permission denied socket user not in docker group compose not a command v1 package, no v2 plugin paths missing in container snap confinement
Each symptom points to a different installation choice the baseline must standardise.

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

  1. 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.

  1. 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.

  1. 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.

One Playbook, Two Modes Flow from the reviewed playbook to self-service local runs and central fleet runs with drift reports. One Playbook, Two Modes workstation.yml reviewed in git self-service -i localhost, fleet inventory, schedule check mode drift report
The same definition serves a developer's laptop and a managed fleet.

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

  1. Lint and test the playbook in CI with ansible-lint and a run against a fresh Ubuntu container or VM image, so changes are proven before they reach machines.

  2. Schedule check-mode runs weekly for managed machines and alert on unexpected changed counts, which usually mean someone installed a conflicting package.

  3. Leave project tools to projects. The playbook installs the baseline and the toolchain manager; project versions belong in each repository's mise.toml or dev container, as discussed in the workstation provisioning topic.

Docker Setups Found Before Standardising Bar chart of how Docker was installed across twenty Linux workstations before the playbook. Docker Setups Found Before Standardising distro docker.io 8 machines upstream docker-ce 6 machines snap 4 machines rootless manual 2 machines
Survey of one team; four installation methods produced four sets of problems.

Platform caveats

Fedora and RHEL: replace apt tasks with dnf, and Docker's repository URL with the Fedora or RHEL one; with SELinux enforcing, bind mounts need :z/:Z labels in Compose files.

Rootless Docker: the docker group 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 arm64 packages 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.