An Ansible playbook records the state a machine should have. That distinction matters after a partial deploy: the next run observes what is really present and repairs only the missing pieces, rather than blindly replaying every command from the beginning. A playbook is an executable contract between the inventory, the modules, and the operator who reviews the change.

Say the end, not each step

An imperative shell script says “run these commands in this order.” A playbook instead says “this package is installed, this file has these bytes, and this service is enabled and running.” A module compares the current host with that target before it acts. Matching tasks report ok; only a mismatch reports changed. The declaration can therefore be applied to a fresh host, a partly configured host, or one edited by hand.

The target is not vague intent. Module arguments are the contract: state: present, an exact template, an owner and mode, or a service’s enabled/running pair. Keep shared values in variables and roles, but make the final value visible to a reviewer. A hidden default or a command that mutates state outside the module makes the contract harder to reason about.

Modules make reruns converge

Most built-in modules are designed to be idempotent. ansible.builtin.copy compares content and metadata before replacing a file. ansible.builtin.package asks the package manager for the requested state. ansible.builtin.service changes enablement or running state only when necessary. A second invocation should be a quiet confirmation, not another restart, duplicate line, or appended record.

Some actions are not naturally idempotent. A raw command that creates a user or calls an API repeats its side effect unless guarded. Prefer a purpose-built module; otherwise use when with a registered read result, or precise creates/removes conditions. changed_when controls reporting and handler notification, not execution. Setting it to false cannot prevent a repeated mutation.

Inventory is the map

The playbook describes outcomes, while inventory selects hosts and variables select context. ansible_user, an SSH agent, or Vault can supply credentials without putting secrets in the repository. Group variables let one declaration cover a fleet while preserving host-specific values such as a port or region. Run with an explicit inventory in CI so “localhost” does not accidentally mean the runner itself.

Check mode (--check) asks modules to predict changes without applying them, and --diff shows content differences for modules that support it. Prediction is not proof: modules calling external systems may not implement check mode, and secrets should not be printed in a diff. Treat a check-mode report as review material, then run normal mode with the same inventory and keep the result with the deployment record.

When it breaks

A playbook can drift when a task shells out, a module has incomplete check-mode support, or an unpinned package resolves a different version tomorrow. Serial execution can leave half a group changed when a later host fails; use serial, handlers, and a rollback plan where the service cannot tolerate that window. A task depending on a fact gathered earlier must state that dependency instead of relying on incidental order.

Scope confusion is another failure: an inventory containing localhost, with a local connection targets the runner, while all selects every matching inventory host. Syntax checking cannot detect the wrong target. Review inventory and --list-hosts; inspect effective variables, including command-line overrides. Protect secrets with no_log and diff: false. Database migrations and cloud APIs still need their own transaction and retry semantics.

Check it quickly

Save the YAML as site.yml and Python as verify_ansible.py in one directory, then run these commands. The local connection modifies only a temporary file.

python3 -m venv .venv-ansible
.venv-ansible/bin/python -m pip install ansible-core==2.21.4
.venv-ansible/bin/python verify_ansible.py
- name: Converge a disposable local file
  hosts: localhost
  connection: local
  gather_facts: false
  tasks:
    - name: Declare the banner content and mode
      ansible.builtin.copy:
        dest: "{{ fixture }}"
        content: "managed by ansible\n"
        mode: "0644"
import json
import os
from pathlib import Path
import re
import stat
import subprocess
import sys
import tempfile

playbook = Path(__file__).with_name("site.yml").resolve()
ansible = Path(sys.executable).parent / "ansible-playbook"
print(subprocess.check_output([str(ansible), "--version"], text=True).splitlines()[0])
with tempfile.TemporaryDirectory(prefix="ansible-state-") as directory:
    work = Path(directory)
    banner = work / "banner"
    config = work / "ansible.cfg"
    config.write_text("[defaults]\nstdout_callback=default\n", encoding="utf-8")
    env = dict(os.environ, ANSIBLE_CONFIG=str(config), ANSIBLE_NOCOLOR="1",
               ANSIBLE_LOCAL_TEMP=str(work / "local-tmp"))
    variables = {"fixture": str(banner), "ansible_python_interpreter": sys.executable,
                 "ansible_remote_tmp": str(work / "module-tmp")}
    command = [str(ansible), "-i", "localhost,", str(playbook),
               "--extra-vars", json.dumps(variables)]

    def run(*options):
        result = subprocess.run(command + list(options), cwd=work, env=env,
                                check=True, text=True, capture_output=True, timeout=60)
        print(result.stdout)
        return result.stdout

    def changed(expected, *options):
        output = run(*options)
        recap = re.search(r"localhost\s+:\s+ok=(\d+)\s+changed=(\d+)\s+"
                          r"unreachable=(\d+)\s+failed=(\d+)", output)
        assert recap and tuple(map(int, recap.groups())) == (1, expected, 0, 0)
        return output

    run("--syntax-check")
    assert "managed by ansible" in changed(1, "--check", "--diff")
    assert not banner.exists(), "check mode created the absent file"
    changed(1)
    assert banner.read_bytes() == b"managed by ansible\n"
    assert stat.S_IMODE(banner.stat().st_mode) == 0o644
    changed(0)
    banner.write_bytes(b"manual drift\n")
    before = (banner.read_bytes(), banner.stat().st_mtime_ns, banner.stat().st_mode)
    diff = changed(1, "--check", "--diff")
    assert "manual drift" in diff and "managed by ansible" in diff
    assert before == (banner.read_bytes(), banner.stat().st_mtime_ns, banner.stat().st_mode)
    changed(1)
    assert banner.read_bytes() == b"managed by ansible\n"
    changed(0)
print("PASS: syntax, absent-file preview, convergence, drift preview, and repair")

With Ansible Core 2.21.4, syntax checking succeeds. Check mode reports changed=1 without creating the absent file; normal execution creates it and a rerun reports changed=0. After manual drift, the preview shows both versions without changing bytes, mode, or modification time. Normal execution repairs the file and converges again. These assertions test this copy task; other modules and upgrades need their own checks.

Further reading

Sources