Your first playbook
The structure of a YAML playbook: hosts, become, tasks, handlers, notify, vars, when, loop, Jinja2 templates, and block/rescue/always. Running ansible-playbook.
Ad-hoc commands from previous lessons are fine for a one-off check. A server
setup that you need to repeat on a dozen machines, and again six months from
now, goes into a playbook: a file that lives in git, that you can read, run
with --check, and apply again with no surprises. In this lesson we'll look
at what a playbook consists of, how to restart a service only when the config
changes, and how to control execution flow with when, loop, and block.
Playbook: what it is
A playbook is a YAML file that describes what to do and on which hosts. Inside it is a list of plays. Each play says: "take these hosts, become a superuser if needed, and run the tasks in order."
Most often a single playbook holds a single play. As the stack grows there are more plays, but let's start with one.
Full working example
---
- name: configure web server
hosts: webservers
become: true
vars:
nginx_port: 80
app_root: /var/www/html
packages:
- nginx
- curl
- vim
tasks:
- name: install packages
apt:
name: "{{ packages }}"
state: present
update_cache: true
- name: create site directory
file:
path: "{{ app_root }}"
state: directory
owner: www-data
group: www-data
mode: "0755"
- name: deploy nginx config
template:
src: templates/nginx.conf.j2
dest: /etc/nginx/sites-available/app.conf
owner: root
group: root
mode: "0644"
notify: restart nginx
- name: enable config
file:
src: /etc/nginx/sites-available/app.conf
dest: /etc/nginx/sites-enabled/app.conf
state: link
notify: restart nginx
- name: start and enable nginx
service:
name: nginx
state: started
enabled: true
handlers:
- name: restart nginx
service:
name: nginx
state: restarted
Let's break down each part. The modules here are written with short names
(apt, template). That also works, but the Ansible docs and this course's
labs use full names like ansible.builtin.template (FQCN): that way it's
immediately clear which collection the module comes from, with no confusion
over a same-named module in a different collection.
Anatomy of a play
hosts: — which hosts the play applies to. The value comes from the
inventory. You can give a group (webservers), a specific host (web-1),
several separated by commas (web-1,web-2), or all.
gather_facts: — whether to collect facts about the host before the
tasks. It's true by default, so the line is usually left out.
become: true — run the tasks with superuser privileges (sudo).
Without it, modules that change system files or install packages fail with a
permission error.
vars: — the play's variables. You can use them in tasks as
{{ name }}. In the example, packages is a list, which you can pass
straight into apt: name: "{{ packages }}".
tasks: — the list of tasks. They run top to bottom, one at a time.
Each task is a name: (a human-readable description) plus a module with
arguments.
handlers: — special tasks that run on notification.
Handlers and notify
A handler is a task that runs only if something notified it (notify), and
only if the notifying task returned changed.
The classic example: restart nginx only if the config actually changed.
tasks:
- name: deploy config
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: restart nginx # notify by the handler's name
handlers:
- name: restart nginx
service:
name: nginx
state: restarted
If template returned ok (the file didn't change), the handler won't run.
If several tasks notify the same handler, it still runs once, after all
the tasks in the tasks section. That's protection against extra restarts.
Three more rules worth knowing in practice:
notifylooks up a handler by name character for character. A typo or a different case (Restart nginxinstead ofrestart nginx) fails the run with an error that the handler wasn't found.- Handlers run in the order they're written in the
handlerssection, not in notification order. - If a task fails after notifying, the handler doesn't run by default. The
--force-handlersflag runs already-notified handlers even on failure. Need to restart a service before the end oftasks? There's a task for that:ansible.builtin.meta: flush_handlers.
when: conditions
when: lets you run a task only under a certain condition.
- name: configure firewall (Debian only)
ufw:
rule: allow
port: "{{ nginx_port }}"
when: ansible_os_family == "Debian"
Ansible gathers facts about hosts (ansible_os_family, ansible_distribution,
ansible_architecture, and dozens more) automatically before running tasks.
You can see this in the output as the Gathering Facts task. The ufw module
in the example isn't part of ansible-core: it lives in the community.general
collection (community.general.ufw), installed separately or alongside the
ansible package.
A condition can also depend on the result of a previous task. In that case
you first need to store that result somewhere. That's what register is for:
- name: check if reboot is required
ansible.builtin.stat:
path: /var/run/reboot-required
register: reboot_required
- name: reboot after kernel update
ansible.builtin.reboot:
when: reboot_required.stat.exists
The first task checks the file and puts the result into the reboot_required
variable, the second reads the stat.exists field from it. Without the first
task, the second fails with an undefined variable error: reboot_required
doesn't appear out of nowhere on its own. A common mistake when copying ready
snippets: taking the task with when: and leaving the task with register:
behind in the original article.
loop: loops
loop: repeats a task for each item in a list. The current item is available
as {{ item }}.
- name: create users
user:
name: "{{ item }}"
state: present
shell: /bin/bash
loop:
- deploy
- monitoring
- backup
For installing packages it's better to pass the list straight into apt,
which is faster (one apt call instead of several):
- name: install packages
apt:
name:
- nginx
- curl
- vim
state: present
But loop: is irreplaceable when you need to create several files, users, or
resources with different parameters.
Jinja2 templates: what's inside .j2
Above, the template task deployed nginx.conf.j2, but we didn't look
inside. A template is a regular config with Jinja2 substitutions and control
constructs inserted into it. The host gets the already-rendered file, with no
Jinja left in it.
# {{ ansible_managed }}
server {
listen {{ nginx_port }};
server_name {{ inventory_hostname }};
root {{ app_root }};
{% if enable_gzip | default(false) %}
gzip on;
{% endif %}
{% for path in deny_paths | default([]) %}
location {{ path }} {
deny all;
}
{% endfor %}
}
What's at work here:
{{ nginx_port }}— a variable substitution (from vars, inventory, facts).{% if %}— a conditional block: the line lands in the file only if the condition is true.{% for %}— a loop: a chunk of config is generated for each item in the list.| default(...)— a filter: if the variable isn't set, the default value is used, with no error.{{ ansible_managed }}— a service marker saying "this file is generated by Ansible, don't edit it by hand."
A typical mistake is a value starting with {{ in YAML with no quotes: the
parser will take it for the start of a dictionary and fail. Write
name: "{{ var }}".
block / rescue / always: error handling
block groups tasks, rescue catches an error inside the block, always
runs no matter what. It's the equivalent of try / except / finally, handy
for a deploy with rollback.
- name: deploy with rollback
block:
- name: roll out new version
copy:
src: "app-{{ version }}.jar"
dest: /opt/app/app.jar
notify: restart app
- name: wait until service is healthy
uri:
url: http://localhost:8080/health
status_code: 200
register: health
retries: 5
delay: 3
until: health.status == 200
rescue:
- name: roll back to previous version
command: /opt/app/rollback.sh
always:
- name: remove lock file
file:
path: /tmp/deploy.lock
state: absent
If any task in block fails, Ansible moves to rescue (where the result is
available through ansible_failed_result). always runs no matter what:
both on success and on failure. That's why cleanup goes there.
Running the playbook
The basic command:
ansible-playbook -i inventory.ini site.yml
Useful flags:
| Flag | What it does |
|---|---|
-i inventory.ini | specify the inventory file |
--check | dry run: show what will change, without applying it |
--diff | show a line-by-line diff of files and templates |
--limit web-1 | apply only to this host or group |
-K | ask for the sudo password (become_password) |
--tags deploy | run only the tasks tagged deploy |
--skip-tags slow | skip the tasks tagged slow |
-v / -vv / -vvv | increase output verbosity |
An example with several flags: see what would change on web-1 only.
ansible-playbook -i inventory.ini site.yml --limit web-1 --check --diff
Idempotency in the context of a playbook
A second run of the full playbook should show changed=0. Nginx is already
installed, the config matches, the service is running: every task returns
ok. The handler never fires.
That's the quality check for a playbook: run it twice, look at the PLAY RECAP.
If changed > 0 on the second run, something is breaking idempotency.
Common mistakes
The handler didn't fire after editing the config. A handler runs only
when the notifying task returned changed. If the file was already changed
by a previous run where notify wasn't there yet, the next run returns ok,
and the service keeps the old config. After a mistake like this, the service
gets reloaded once separately, and notify is added to the playbook.
Restarting a service with a regular task. A command: nginx -s reload or
service: state: restarted task in tasks fires on every run, even when
nothing changed. That's where extra restarts and changed on the second run
come from. The restart belongs in a handler.
A typo in the handler's name. notify: Restart nginx with a handler
named restart nginx gives the error The requested handler 'Restart nginx' was not found. Names are compared character by character, case included.
A variable from nowhere in when:. The error Error while evaluating conditional: '...' is undefined means the variable didn't exist at the
moment the condition was checked. Look for where it was supposed to come
from: register in a previous task, vars, the inventory, or facts.
{{ at the start of an unquoted value. YAML reads this as a dictionary,
and Ansible stops with YAML parsing failed: This may be an issue with missing quotes around a template block. Write msg: "{{ var }}".
Expecting --check to show everything. command and shell tasks are
skipped in --check mode (skipping), so dry run won't show whatever depends
on them.
What you'll need for the tasks
In the labs there's one lab environment: a web-1 container with nginx and
ansible-core. Ansible there manages the same machine it runs on
(ansible_connection=local in the inventory), so SSH isn't needed. The
playbooks live in the lab's work/ folder, you edit them with your own
editor, and run them inside the container with bash setup/run.sh.
ansible-playbook -i inventory.ini <file>.ymland thePLAY RECAPline (ok,changed,failed);--checkand--diff;- the play sections
vars,tasks,handlers, and thenotifykey on a task; when:and where the variables for a condition come from;ansible.builtin.template,ansible.builtin.copy,ansible.builtin.user.
There's no systemd in the container, so the service module doesn't work
there. In the lab, nginx is reloaded with the nginx -s reload command
through ansible.builtin.command.
In the next lesson we'll look at roles and the structure of their folders.
Self-check
Type the command or key line. Case and spaces don't matter.
Interview-style questions
Interview questions on this topic: 5
Included with practice access, $49 one-time. Get access
Answer out loud or in writing, then reveal the answer and honestly mark whether you knew it.
Done reading?
Mark the lesson as read to move your track progress forward.
Lesson marked as done.
Go through it with an AI agent
The agent explains anything unclear in plain words, gives you 2 labs for this lesson and drills you on interview questions. $49 one-time, lifetime access.
Tell the agent: use MCP izi, give me lesson ansible-basics-06-first-playbook
Progress saved in this browser
Clear your history or open this on your phone, and the marks will be gone. Leave your email and they'll move to your account. No password needed.
Done. moved to your account. You can see your progress on any device. My progress