Skip to content

Introduction to Ansible: Inventories, Playbooks, and Idempotency

Ansible's core value proposition over a shell script is idempotency: running the same playbook twice against an already-configured host should report "nothing changed" the second time, rather than erroring out or duplicating work. This article covers the pieces that make that true.

How it works

Inventory: what hosts you're managing

# inventory.ini
[webservers]
web1.example.com
web2.example.com ansible_host=10.0.0.12

[databases]
db1.example.com ansible_user=deploy

[production:children]
webservers
databases

Groups ([webservers]) can be nested via :children. ansible_host/ansible_user are connection variables — they tell Ansible how to actually reach that host, separate from its display name in the inventory.

ansible webservers -i inventory.ini -m ping

-m ping is Ansible's own module (not ICMP) — it confirms Python is reachable and working over SSH on the target, the most basic possible connectivity check before running anything real.

Playbooks: declarative, not procedural

# site.yml
- name: Configure web servers
  hosts: webservers
  become: true
  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/conf.d/site.conf
        mode: "0644"
      notify: Reload nginx

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

  handlers:
    - name: Reload nginx
      ansible.builtin.systemd:
        name: nginx
        state: reloaded
ansible-playbook -i inventory.ini site.yml

This is declarative, not a shell script: state: present means "ensure this package is installed" — if it already is, nothing happens (reported as ok, not changed); if it isn't, Ansible installs it. The same playbook run against a host that's already fully configured should show all tasks as ok with zero changed, which is exactly how you confirm a playbook is genuinely idempotent.

Handlers only run on change, and only once

The notify: Reload nginx handler only fires if the template task actually changed the file — and even if ten different tasks in the same play notify the same handler, it still only runs once, at the end of the play. This avoids, e.g., reloading nginx ten times during one run just because ten separate config files were templated out in the same playbook.

Variables and templates

{# templates/site.conf.j2 #}
server {
    listen {{ http_port | default(80) }};
    server_name {{ inventory_hostname }};
}

{{ inventory_hostname }} is a built-in variable — the host's name as it appears in the inventory. | default(80) provides a fallback if http_port isn't defined anywhere for that host, avoiding a hard failure on a missing variable.

Checking what would change, without changing anything

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

--check ("dry run") reports what would change without actually applying it; --diff shows the actual content differences for file-based tasks (template, copy, lineinfile). This is the single most useful habit to build before running an unfamiliar or modified playbook against production — always --check --diff first.

Ubuntu/Debian vs. RHEL-family differences

Ansible itself is distribution-agnostic (it's Python running from your control machine, connecting over SSH — the target doesn't need Ansible installed at all, only Python). The practical difference is in the modules you use: ansible.builtin.apt vs. ansible.builtin.dnf/yum, or the distribution-agnostic ansible.builtin.package module (as used above) which picks the right backend automatically based on the target's detected OS family. The example deploys to /etc/nginx/conf.d/, which the nginx packages of both families include by default; the Debian-style sites-available/ would additionally need a symlink in sites-enabled/.

Practical exercise

  1. Write an inventory with a single [test] group containing localhost ansible_connection=local (runs locally, no SSH needed — safe for practicing).
  2. Write a playbook that ensures a file /tmp/ansible-test.txt exists with specific content, using the copy module with content:.
  3. Run it twice in a row and confirm the second run reports changed=0 — proving idempotency.
  4. Change the file's content manually (echo "tampered" > /tmp/ansible-test.txt), re-run the playbook, and confirm it detects and corrects the drift (changed=1).

How to verify you did it correctly

  • Second run of step 3: ansible-playbook output's final PLAY RECAP line should show changed=0, failed=0.
  • Step 4: the recap should show changed=1, and cat /tmp/ansible-test.txt should show your original intended content again, not "tampered".

Common mistakes

  • Writing playbooks as a sequence of shell/command tasks instead of using purpose-built modules (package, template, systemd, copy, ...). Raw shell commands are not idempotent by default — a shell: useradd deploy task fails on the second run because the user already exists, whereas the ansible.builtin.user module handles "ensure this user exists" correctly regardless of current state.
  • Never running --check --diff before applying an unfamiliar playbook to production hosts.
  • Hardcoding values that should be variables (hostnames, ports, paths), making the same playbook unusable for a second, slightly different environment without editing the playbook itself.

⚠ Dangerous commands

  • Any playbook with ansible.builtin.file: state=absent or command: rm -rf <path> against a variable-derived path, run against a large inventory without --check first — a single wrong variable resolves the same destructive action across every targeted host simultaneously, which is both the power and the danger of configuration management at scale.
  • ansible all -i inventory.ini -m <module> (the all group) runs against every host in the inventory — a typo'd or overly broad inventory file turns a test command into a fleet-wide action.

Review questions

  1. Why is a shell: useradd deploy task not idempotent, while the ansible.builtin.user module achieves the same goal safely?
  2. What does --check --diff actually do, and why should it become a habit before any unfamiliar playbook run?
  3. Why do handlers only run once per play, even if notified by multiple tasks?
  4. What's the practical risk of running a playbook against the all inventory group without first scoping it to a smaller test group?