Sign in
← Ansible
15 min · medium · Lesson 6 of 12

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:

TASKS HANDLER QUEUE end of play template: src: nginx.conf.j2 changed notify file: state=link (enable config) changed notify x2 - collapses service: started (already running) ok - no notify handlers: - name: restart nginx service: restarted nginx CHANGED runs once at the end of the play changed - notifies the handler ok - handler not called handler runs once
Two notifies collapse into one handler call, and the handler fires once at the end of the play.

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:

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:

FlagWhat it does
-i inventory.inispecify the inventory file
--checkdry run: show what will change, without applying it
--diffshow a line-by-line diff of files and templates
--limit web-1apply only to this host or group
-Kask for the sudo password (become_password)
--tags deployrun only the tasks tagged deploy
--skip-tags slowskip the tasks tagged slow
-v / -vv / -vvvincrease 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.

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.

1. Run the site.yml playbook with the inventory.ini inventory.
2. A dry run: see what would change, without applying it.
3. Apply the playbook only to the web-1 host.

Done reading?

Mark the lesson as read to move your track progress forward.

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.

Get access for $49

Tell the agent: use MCP izi, give me lesson ansible-basics-06-first-playbook