Philipp Lehmann @PhilippTheServer

Deploying Docker Compose from Ansible Without Shelling Out

A shell task wrapping docker compose up can never report a real change or run in check mode.

The problem

The fastest way to get a Compose stack deployed from Ansible is also the one that gives up the most:

# Works, but tells you nothing.
- name: Deploy the stack
  ansible.builtin.command: docker compose -f /opt/myapp/docker-compose.yml up -d
  args:
    chdir: /opt/myapp
  changed_when: false

changed_when: false is there because without it, this task would report changed: true on every run forever — docker compose up always exits 0, whether it pulled a new image, recreated three containers, or did precisely nothing. So the task is silenced instead of made accurate. The cost of that silence is total: this task can never tell you a container was recreated, can never fail a --check run because check mode does not exist for a raw command, and produces a diff of nothing when you run ansible-playbook --diff. You have correctly deployed the stack and destroyed Ansible’s ability to reason about it.

There is a second, quieter problem. docker compose up -d is not itself atomic against concurrent state: if the compose file changed and an image needs pulling, the shell task blocks for as long as that pull takes, with no distinction in the run’s output between “45 seconds because it recreated everything” and “45 seconds because the registry was slow and nothing changed.” Debugging a slow deploy from Ansible’s log alone is not possible.

Working through it

Compose state belongs to a module, not a shell invocation

The community.docker collection ships community.docker.docker_compose_v2, which shells out to the same docker compose CLI underneath but parses its output to build a real Ansible result: which services were created, started, or recreated, and whether anything happened at all.

- name: Deploy the stack
  community.docker.docker_compose_v2:
    project_src: /opt/myapp
    state: present
  register: compose_result

compose_result.changed is now a real signal — the module compares the previous and current state of each service and only reports changed: true when something in the plan actually differed. compose_result.actions lists what happened, which you can use to gate a dependent task, the same way render_config.changed gates a restart in a role that does not use containers at all.

Check mode is not free, but it is available

Because the module models the compose plan rather than only executing it, it supports --check: it can compute what would change without applying it, by asking docker compose to plan and diffing that plan against reality. This is the one thing a shell task structurally cannot do — a raw command module either runs the command or does not run it at all, there is no notion of “run it in dry-run mode” unless the underlying command itself supports a flag for that and you wire it manually.

ansible-playbook -i inventory site.yml --check --diff

Run against a host with no drift, this reports changed=0. Run after editing the compose file, it reports which services would be recreated, before anything happens.

Pin the collection, not just the images

community.docker moves independently of ansible-core, and the compose module’s behaviour around pull policies and orphan removal has changed across versions. A requirements.yml with a pinned version is what makes the role reproducible on a different machine, or a year later:

# requirements.yml
collections:
  - name: community.docker
    version: "3.13.2"
ansible-galaxy collection install -r requirements.yml

Decide what “present” means for images you build yourself

docker_compose_v2 defaults to pulling images that have a build: key only when they are missing. If your compose file builds a local image and you change the Dockerfile without bumping a tag, the module will not know to rebuild it — this is a real limitation, not an oversight to route around silently. State it plainly to whoever maintains the role: either version your image tags on every change, or set build: always and accept that the task reports changed on every run where a rebuild happens, whether or not the image content actually differs. There is no way to have both a floating local build and precise change detection; pick one.

The solution

A complete, runnable stack: a Compose file for a small web service behind Caddy, and the playbook and role that deploy it idempotently.

# docker-compose.yml
services:
  web:
    image: traefik/whoami:v1.10.3
    restart: unless-stopped
    expose:
      - "80"

  proxy:
    image: caddy:2.8.4-alpine
    restart: unless-stopped
    ports:
      - "8080:80"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
    depends_on:
      - web
# Caddyfile
:80 {
    reverse_proxy web:80
}
# requirements.yml
collections:
  - name: community.docker
    version: "3.13.2"
# roles/compose-stack/tasks/main.yml
- name: Create the stack directory
  ansible.builtin.file:
    path: "{{ compose_project_dir }}"
    state: directory
    owner: root
    group: root
    mode: "0755"

- name: Copy the compose file
  ansible.builtin.copy:
    src: docker-compose.yml
    dest: "{{ compose_project_dir }}/docker-compose.yml"
    owner: root
    group: root
    mode: "0644"

- name: Copy the Caddyfile
  ansible.builtin.copy:
    src: Caddyfile
    dest: "{{ compose_project_dir }}/Caddyfile"
    owner: root
    group: root
    mode: "0644"

- name: Deploy the stack
  community.docker.docker_compose_v2:
    project_src: "{{ compose_project_dir }}"
    state: present
  register: compose_result

- name: Show what changed
  ansible.builtin.debug:
    var: compose_result.actions
  when: compose_result.changed
# site.yml
- name: Deploy the whoami stack
  hosts: docker_hosts
  become: true
  vars:
    compose_project_dir: /opt/whoami
  roles:
    - compose-stack

Verifying it

ansible-galaxy collection install -r requirements.yml
ansible-playbook -i inventory site.yml
ansible-playbook -i inventory site.yml   # must report changed=0 on the deploy task

curl -s localhost:8080 | head -n1        # Hostname: <container id>

# Prove check mode reflects reality:
sed -i 's/v1.10.3/v1.10.2/' docker-compose.yml
ansible-playbook -i inventory site.yml --check --diff   # reports the image would change
ansible-playbook -i inventory site.yml                  # actually recreates the web service

Correct output on the second real run, with no compose file edit in between, is:

PLAY RECAP *********************************************************
host : ok=4   changed=0   unreachable=0   failed=0    skipped=0

Conclusion

changed_when: false on a task that legitimately changes things is not a fix, it is turning off the instrument. If the task can genuinely be a no-op, use a module that can tell the difference; if it cannot, at minimum wire changed_when to a real check rather than silencing it.

Check mode is a property of the module, not a flag you can bolt onto a shell command. Any time a shell task feels indispensable, look for the module first — the collection ecosystem covers most things people reach for the CLI to do, compose included.

A local build with a floating tag and precise change detection are mutually exclusive. Decide which one the role needs and say so, rather than discovering it during an incident when a code change silently did not get deployed because the image tag never moved.