Your First Playbook
Scope: a complete, real playbook — every part named,
run end to end. This guide's copy lives in
examples/site.yml and runs
unchanged.
The playbook
---
- name: Configure the demo app
hosts: webservers
vars:
app_dir: /tmp/demoapp-{{ inventory_hostname }}
tasks:
- name: Create the app directory
ansible.builtin.file:
path: "{{ app_dir }}"
state: directory
mode: "0755"
- name: Copy the notice file
ansible.builtin.copy:
src: files/motd.txt
dest: "{{ app_dir }}/motd.txt"
mode: "0644"
- name: Template the app config
ansible.builtin.template:
src: templates/app.conf.j2
dest: "{{ app_dir }}/app.conf"
mode: "0644"
notify: Stamp config change
- name: Show the rendered config path
ansible.builtin.debug:
msg: "Config for {{ inventory_hostname }} written to {{ app_dir }}/app.conf"
handlers:
- name: Stamp config change
ansible.builtin.copy:
content: "config changed at {{ ansible_facts['date_time']['iso8601'] }}\n"
dest: "{{ app_dir }}/.config-stamp"
mode: "0644"
Anatomy, top to bottom
- A play — one
hosts:block: which machines, and the tasks to apply to them. A playbook can hold several plays (web servers first, then databases). vars:— play-level variables.app_diris built frominventory_hostname, a built-in variable holding each machine's inventory name — so web1 and web2 get different directories from one line. Variables get a full chapter next.- A task — a name (write it like a sentence a human would say; it appears in the output) plus exactly one module call:
file— directories and files exist (or don't), with modes.copy— a file from the control machine lands on the target, as-is.template— like copy, but the file is a Jinja2 template filled in per machine (next chapters).debug— prints a message. Trivial, and the first tool you reach for when learning.{{ ... }}— Jinja2 substitution: anywhere it appears, the variable's value is spliced in before the task runs.notify:/handlers:— "if the config actually changed, also do this, once, at the end." Handlers get their own chapter with templates.
Module names are written ansible.builtin.file — the
fully-qualified form. Bare file also works; the long
form says unambiguously whose module you mean when
collections enter the picture.
Run it
ansible-playbook site.yml
PLAY [Configure the demo app] **************************************************
TASK [Gathering Facts] *********************************************************
ok: [web1]
ok: [web2]
TASK [Create the app directory] ************************************************
changed: [web1]
changed: [web2]
...
PLAY RECAP *********************************************************************
web1 : ok=6 changed=4 unreachable=0 failed=0
web2 : ok=6 changed=4 unreachable=0 failed=0
Reading the output: every task reports per host —
ok (already right), changed (Ansible made it right),
or failed. The PLAY RECAP is the scoreboard: six
tasks OK'd (including the automatic fact-gathering),
four changed something, nothing unreachable, nothing
failed. The file each machine ended up with:
# Managed by Ansible -- edits will be overwritten
[app]
port = 8080
host = web1
environment = production
port came from the inventory's group vars,
host from each machine's own name, environment from a
default in the template. One playbook, two machines,
correctly different results — and if you run it again,
four becomes zero. That's the next chapter.