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.
-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
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¶
--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¶
- Write an inventory with a single
[test]group containinglocalhost ansible_connection=local(runs locally, no SSH needed — safe for practicing). - Write a playbook that ensures a file
/tmp/ansible-test.txtexists with specific content, using thecopymodule withcontent:. - Run it twice in a row and confirm the second run reports
changed=0— proving idempotency. - 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-playbookoutput's finalPLAY RECAPline should showchanged=0,failed=0. - Step 4: the recap should show
changed=1, andcat /tmp/ansible-test.txtshould show your original intended content again, not "tampered".
Common mistakes¶
- Writing playbooks as a sequence of
shell/commandtasks instead of using purpose-built modules (package,template,systemd,copy, ...). Raw shell commands are not idempotent by default — ashell: useradd deploytask fails on the second run because the user already exists, whereas theansible.builtin.usermodule handles "ensure this user exists" correctly regardless of current state. - Never running
--check --diffbefore 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=absentorcommand: rm -rf <path>against a variable-derived path, run against a large inventory without--checkfirst — 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>(theallgroup) runs against every host in the inventory — a typo'd or overly broad inventory file turns a test command into a fleet-wide action.
Related articles¶
Review questions¶
- Why is a
shell: useradd deploytask not idempotent, while theansible.builtin.usermodule achieves the same goal safely? - What does
--check --diffactually do, and why should it become a habit before any unfamiliar playbook run? - Why do handlers only run once per play, even if notified by multiple tasks?
- What's the practical risk of running a playbook against the
allinventory group without first scoping it to a smaller test group?