Playbooks and modules
A playbook is YAML that states the desired end result: plays, tasks, module arguments, variables and handlers.
A first playbook
A playbook is a list of plays. Each play names a host pattern and a list of tasks; tasks run in order, one host batch at a time.
# site.yml
- name: Configure web servers
hosts: web
become: true
vars:
app_port: 8080
tasks:
- name: Install nginx
apt:
name: nginx
state: present
- name: Deploy the site config
template:
src: templates/app.conf.j2
dest: /etc/nginx/conf.d/app.conf
notify: Reload nginx
- name: Ensure nginx is running
service:
name: nginx
state: started
enabled: true
handlers:
- name: Reload nginx
service:
name: nginx
state: reloadedansible-playbook -i inventory.ini site.yml
ansible-playbook -i inventory.ini site.yml --check --diff # rehearse
ansible-playbook -i inventory.ini site.yml --limit web1 # a single hostModules worth knowing
| Module | Purpose | Idempotent? |
|---|---|---|
apt / dnf / package | Install, remove or upgrade packages | Yes, through state |
copy / template | Push a file, optionally rendered from Jinja2 | Yes, compared by checksum |
file | Directories, symlinks, permissions, ownership | Yes |
lineinfile / blockinfile | Edit one line or one block of a config file | Yes, matched by pattern |
service / systemd | Start, stop and enable a unit | Yes |
user / group | Accounts and groups | Yes |
command / shell | Run a program; shell adds pipes and redirection | No — use only when nothing else fits |
get_url / uri | Download a file or call an HTTP endpoint | Partly; creates or checksum help |
- name: Create the release directory
file:
path: /srv/app/releases/{{ app_version }}
state: directory
owner: deploy
mode: "0755"
- name: Fetch the artifact
get_url:
url: "https://artifacts.example.com/app-{{ app_version }}.tgz"
dest: /tmp/app.tgz
checksum: "sha256:{{ artifact_sha256 }}"
- name: Unpack it only once
unarchive:
src: /tmp/app.tgz
dest: "/srv/app/releases/{{ app_version }}"
remote_src: true
creates: "/srv/app/releases/{{ app_version }}/bin/app"💡
creates: is the simplest idempotency guard for a task that cannot check itself: the task is skipped when that path already exists. removes: is the mirror image.Variables, facts and handlers
Variables arrive from several places with a fixed precedence: extra vars beat task vars, which beat play vars, which beat inventory vars, which beat role defaults. Facts are variables Ansible discovers on each host by running setup.
vars_files:
- vars/common.yml
tasks:
- name: Show what we discovered
debug:
msg: "{{ inventory_hostname }} runs {{ ansible_facts.distribution }} {{ ansible_facts.distribution_version }}"
- name: Loop over a list
user:
name: "{{ item.name }}"
groups: "{{ item.groups }}"
loop:
- { name: alice, groups: sudo }
- { name: bob, groups: docker }
when: ansible_facts.os_family == "Debian"
- name: Register a result and act on it
command: /usr/bin/check-migration
register: migration
changed_when: false
failed_when: migration.rc not in [0, 1]- Handler: a task that runs only when a task notifying it reported changed, exactly once, at the end of the play.
- tag:
--tags deployruns only the tasks you tagged, which keeps a long playbook usable. - block / rescue / always: groups tasks with error handling, like try, catch and finally.
- changed_when / failed_when: overrides Ansible's guess about what a command's result means.
- check_mode: modules that support it report whether they would change anything.
FAQ
When is a module not idempotent?
Practically, when it cannot tell whether the work is already done.
command and shell always run; wrap them with creates, a registered result and when, or use changed_when: false for a command that only reads.Why did my handler not run?
A handler fires only if a notifying task reported changed. If that task was skipped, already in the desired state, or ran in check mode, there was nothing to notify.
Related
Inventories and ad-hoc commands Roles, Vault and idempotency
Last refreshed 2026-09-18.