A playbook is a YAML file. Inside it lives a list of plays. Each play maps a set of hosts to a list of tasks that should be executed on them. A task is one invocation of a module. Handlers are special tasks that only run if some other task notifies them.
---
- name: Configure database servers # human-readable label for the PLAY
hosts: databases # which hosts from inventory to target
remote_user: deploy # SSH connection user
become: true # escalate to root via sudo
gather_facts: true # run setup module first (default)
vars: # play-scoped variables
mysql_root_password: "secret"
mysql_port: 3306
vars_files: # additional vars from external files
- vars/db_creds.yml
pre_tasks: # run BEFORE roles
- name: Update package cache
ansible.builtin.dnf:
update_cache: true
roles: # roles to apply
- common
- mysql
tasks: # explicit tasks (run AFTER roles)
- name: Open MySQL port in firewalld
ansible.posix.firewalld:
port: "{{{{ mysql_port }}}}/tcp"
permanent: true
immediate: true
state: enabled
post_tasks: # run AFTER tasks
- name: Verify MySQL is listening
ansible.builtin.wait_for:
port: "{{{{ mysql_port }}}}"
timeout: 30
handlers: # only run if notified
- name: restart mysql
ansible.builtin.systemd:
name: mysqld
state: restarted
| # | Section | When it runs |
|---|---|---|
| 1 | gather_facts (setup) | First, on every host (unless disabled) |
| 2 | pre_tasks | Before any role |
| 3 | roles | In the order listed |
| 4 | tasks | After all roles complete |
| 5 | post_tasks | After explicit tasks |
| 6 | handlers | At the very end, only if notified by a changed task |
post_tasks instead of regular tasks, handlers fire before validation runs — your service is restarted, then the post-task verifies it actually came back up. This is the right ordering.A single playbook file can hold many plays — each targeting different hosts:
---
- name: Database tier
hosts: databases
become: true
roles:
- mysql
- name: Web tier
hosts: webservers
become: true
roles:
- nginx
- app
- name: Wire web tier to db tier
hosts: webservers
become: true
tasks:
- name: Set MySQL host in app config
ansible.builtin.lineinfile:
path: /etc/myapp/config.ini
regexp: "^db_host"
line: "db_host = {{{{ groups['databases'][0] }}}}"
Plays run sequentially. Play 2 doesn't start until play 1 finishes on every host in its target group. This makes orchestration straightforward: the database tier finishes provisioning before the web tier starts.
Every play and every task supports name. It's optional but skipping it makes
output unreadable: tasks without names show as TASK [ansible.builtin.dnf] instead
of TASK [Install MySQL server package].
ansible-lint flags un-named tasks as a violation. Treat name as mandatory in shared code — your future self reading a 200-line playbook will thank you.Page 11 (next) goes deep on variables — including the precedence rules that determine which value wins when the same variable is set in five different places.