Docker & ContainersXXXVI Β· AutomationAnsible
Ansible for Docker hosts β and the tasks that are not idempotent
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
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.
$ ansible-galaxy collection list community.docker# /usr/lib/python3/dist-packages/ansible_collections
Collection Version
---------------- -------
community.docker 5.0.4Illustrative 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
ansible-playbook -i inventory/production deploy.yml \
--limit appserver01.example.com \
--check --diffKnowledge check
Knowledge check Β· 4 questions
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?
Q2. Which tasks report `changed` on every run regardless of the host state? Select all that apply.
Q3. By default, a handler notified early in a play does not run at all if a later task in that play fails.
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.