Ansible Deep Dive Part 3: Playbooks, Tasks, and the Idempotency Contract

Ad-hoc commands from Part 1 aren’t repeatable, aren’t version-controlled, and can’t express “do these six things in order, and only restart the service if something actually changed.” That’s what a playbook is for. This post is the core of the whole series — everything from Part 4 onward is refinement on top of what’s here.

Anatomy of a playbook

# webserver.yml
---
- name: Configure web servers
  hosts: webservers
  become: true
  vars:
    http_port: 80
  tasks:
    - name: Install nginx
      ansible.builtin.package:
        name: nginx
        state: present

    - name: Deploy site configuration
      ansible.builtin.template:
        src: templates/site.conf.j2
        dest: /etc/nginx/sites-available/default
      notify: Reload nginx

    - name: Ensure nginx is running and enabled
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true

  handlers:
    - name: Reload nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded

A playbook is a YAML file containing one or more plays. A play maps a set of tasks onto a set of hosts. A task calls exactly one modulepackage, template, service above — with arguments describing the desired state.

Run it with:

ansible-playbook -i inventory.yml webserver.yml

The idempotency contract

This is the single most important concept in the entire series, and it’s the thing that separates Ansible (and its peers) from a shell script that happens to be YAML-shaped.

A module is idempotent if running it twice produces the same end state as running it once, and — critically — if running it a second time when nothing needs to change reports “ok” rather than “changed.” ansible.builtin.package: name=nginx state=present checks whether nginx is already installed before doing anything; if it is, the task reports green with zero changes made. ansible.builtin.service: state=started checks whether the service is already running.

This is why you almost never reach for ansible.builtin.command or ansible.builtin.shell when a purpose-built module exists. command: apt install -y nginx will happily run apt install every single time, reporting “changed” on every run whether or not anything actually changed, and giving Ansible no way to know it could skip the work. Worse, most shell commands aren’t naturally idempotent at all — command: useradd bob fails outright the second time it runs, because the user already exists. The user module, by contrast, checks current state first and only acts on the difference.

The practical rule I follow: reach for command/shell only when no purpose-built module exists for what you’re doing, and when you do, pair it with creates: or changed_when: so Ansible can still reason about whether anything actually happened:

- name: Run database migration
  ansible.builtin.command: /opt/app/migrate.sh
  args:
    creates: /opt/app/.migrated  # skip if this file already exists

Handlers: react to change, don’t force it

Notice the notify: Reload nginx on the template task, and the corresponding handlers: block. A handler only fires if a task that notifies it actually reports changed. Deploy the same, unchanged template a second time, and the reload is skipped entirely — nginx doesn’t get bounced for no reason. This is idempotency propagating outward from a single task to the consequences of that task, and it’s the pattern that stops “just run the playbook again to be safe” from causing an unnecessary service restart on a production box.

A subtlety worth knowing early: handlers run once, at the end of the play, no matter how many tasks notify them. Three tasks all notifying Reload nginx still produces exactly one reload, not three.

Check mode and diff mode: dry-run before you trust it

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

--check runs the playbook without making any changes, reporting what would happen. --diff additionally shows the actual content diff for anything using template or copy. Not every module supports check mode faithfully — a module that shells out to a script has no way to simulate the script’s effect — but for the built-in modules covering packages, services, files, and templates, this is close to a free safety net, and I run it before any playbook touches something I care about for the first time.

Tags: running part of a playbook

- name: Install nginx
  ansible.builtin.package:
    name: nginx
    state: present
  tags: [install]

- name: Deploy site configuration
  ansible.builtin.template:
    src: templates/site.conf.j2
    dest: /etc/nginx/sites-available/default
  tags: [config]
ansible-playbook webserver.yml --tags config       # only the config task
ansible-playbook webserver.yml --skip-tags install # everything except install

Useful during iterative development — you don’t want to reinstall packages every time you’re tweaking a config template — but I’d caution against over-relying on tags as a substitute for splitting a playbook into smaller, focused plays. If you find yourself tagging every single task so you can cherry-pick which ones run, that’s usually a sign the playbook is trying to do too many unrelated things at once.

Loops and conditionals, briefly

- name: Install a list of packages
  ansible.builtin.package:
    name: "{{ item }}"
    state: present
  loop:
    - nginx
    - curl
    - git

- name: Only on Debian-family hosts
  ansible.builtin.package:
    name: nginx
    state: present
  when: ansible_facts['os_family'] == 'Debian'

loop replaces the older with_items syntax (still seen in legacy playbooks — treat it as deprecated, not idiomatic). when is a Jinja2 expression evaluated against facts and variables; ansible_facts is the dictionary of everything Ansible gathered about the host before the play started running — which is exactly where Part 4 picks up: variables, facts, and the Jinja2 templating language underneath both when and the template module.