Skip to main content
RunBook Academy

Docker & ContainersXXXVI Β· AutomationAnsible

Ansible for Docker hosts β€” and the tasks that are not idempotent

Advanced⏱ ~26 minansible-corecommunity.docker

What you'll learn

  • Use `community.docker` modules instead of shelling out to the CLI
  • Explain how the module decides whether an existing container needs recreating
  • Recognise a task that reports `changed` on every run, and one that never does
  • Roll a change across a fleet without taking every host down at once

Prerequisites

Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-11

Not yet marked complete on this device.

The reason to move Docker automation from shell to Ansible is not that YAML is nicer than bash. It is that Ansible gives you three things a script does not: an inventory so the same change reaches every host, a changed flag so you can see what a run actually did, and --check so you can find out before you do it.

All three are only as honest as the modules you use. A playbook full of command: docker ... tasks has an inventory and nothing else.

Use the modules, not the CLI

community.docker is a separate collection; ansible-core does not ship it.

Read-only / Safecollection
$ ansible-galaxy collection list community.docker
# /usr/lib/python3/dist-packages/ansible_collections
Collection       Version
---------------- -------
community.docker 5.0.4

Illustrative output

The version matters more than usual here, because several defaults have changed across major versions. Pin it in requirements.yml and record the version you tested against.

- name: Run the application container
  community.docker.docker_container:
    name: web
    image: registry.example.com/myorg/myapp:1.4.2
    state: started
    restart_policy: unless-stopped
    stop_timeout: 60
    published_ports:
      - "127.0.0.1:8080:8080"
    env:
      APP_ENV: production
    volumes:
      - myapp-data:/var/lib/myapp
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 30s

Run it twice: the second run reports ok, not changed. That is the contract, and it is what makes the play safe to run every hour from a scheduler.

The three ways idempotence breaks

1. Shelling out

# Reports "changed" on every single run, forever.
- name: Start the stack
  ansible.builtin.command: docker compose up -d
  args:
    chdir: /srv/myapp

command and shell have no idea what they ran. Ansible marks the task changed because a command executed, not because anything differed. The immediate cost is that your change report is noise. The real cost is notify: β€” a handler wired to this task fires on every run, so a restart handler restarts the service every hour whether or not anything changed.

Fix it by using the right module:

- name: Bring up the stack
  community.docker.docker_compose_v2:
    project_src: /srv/myapp
    state: present
    pull: always
    wait: true

or, when no module exists, by telling Ansible how to judge the result:

- name: Generate the TLS bundle once
  ansible.builtin.command: /usr/local/sbin/make-bundle.sh
  args:
    creates: /etc/myapp/tls/bundle.pem

creates: makes the task a genuine no-op when the file exists. changed_when: does the same job when the decision depends on the command’s output.

2. The default that never pulls

There is a matching trap in check mode. The Docker API cannot say whether a pull would change anything, so with pull: always the module reports a change in --check only when the image is absent. The option pull_check_mode_behavior lets you choose the other behaviour, but neither setting can give you a truthful dry run for a moving tag. One more reason to pin.

3. Comparisons that ignore what you care about

comparisons controls how each property is judged. strict means any difference triggers a recreate. ignore means never recreate over this property. allow_more_present β€” valid for lists, sets and dicts β€” means only recreate if something you specified is missing from the container, so extra values the container already has are tolerated.

- name: Application container, with explicit comparison rules
  community.docker.docker_container:
    name: web
    image: registry.example.com/myorg/myapp:1.4.2
    env:
      APP_ENV: production
    comparisons:
      '*': strict          # default everything not listed below to strict
      labels: allow_more_present

The '*' wildcard is worth setting deliberately. Without it you are relying on per-option defaults you have not read, and the failure mode is a container that never gets recreated when a setting changes β€” the same silent drift as the pull default, arriving from a different direction.

Handlers run late

tasks:
  - name: Deploy the daemon configuration
    ansible.builtin.copy:
      src: daemon.json
      dest: /etc/docker/daemon.json
      mode: '0644'
    notify: restart docker

  - name: Start the application container
    community.docker.docker_container:
      name: web
      image: registry.example.com/myorg/myapp:1.4.2
      state: started

handlers:
  - name: restart docker
    ansible.builtin.systemd_service:
      name: docker
      state: restarted

This play has a bug. Handlers run at the end of the play, so the container starts against the old daemon configuration, and the daemon is then restarted underneath it. If live-restore is not enabled, that restart takes the container down again.

Force the handler to run at the point it is needed:

  - name: Apply pending daemon restarts before touching containers
    ansible.builtin.meta: flush_handlers

The second handler trap: if a later task in the play fails, handlers notified earlier do not run at all by default. The configuration file is on disk, the daemon has not been restarted, and the host is now in a state neither the old nor the new play describes. --force-handlers changes that, and is worth setting for plays whose handlers are corrective rather than optional.

Rolling across a fleet

Read-only / Safedry run
ansible-playbook -i inventory/production deploy.yml \
--limit appserver01.example.com \
--check --diff

Knowledge check

Knowledge check Β· 4 questions

  1. Q1. A playbook uses `image: myapp:latest` with `community.docker.docker_container` and default options. A new image has been pushed to that tag. What happens on the next run?

  2. Q2. Which tasks report `changed` on every run regardless of the host state? Select all that apply.

  3. Q3. By default, a handler notified early in a play does not run at all if a later task in that play fails.

  4. Q4. Which combination makes a `docker_container` task usable as a rolling deploy across a group of hosts?

Passing score: 75%. Answers are checked in this browser.