Ansible in depth · Lesson 1 · 35 min

Variables, loops & handlers

In the DevOps path you wrote one playbook with the settings inside it. That's fine for two servers. With twenty, the settings need their own home: some are true for every server, some only for one family, and some only for one machine. This lesson shows where Ansible looks for variables, which one wins when they disagree, and two tools every real playbook uses: loops and handlers.

You will learn

  • group_vars/ and host_vars/, and how variable precedence works
  • Using groups to hold the family differences (httpd vs apache2, wheel vs sudo)
  • loop: to do one task for many items
  • Handlers: restart a service only when its config really changed
  • register, debug and changed_when
  • Jinja2 in templates: {% if %} and {% for %}

Where variables live

ansible/
├── ansible.cfg
├── inventory.ini            [web] web1 web2   [rocky] web1   [ubuntu] web2
├── group_vars/
│   ├── all.yml              every host
│   ├── web.yml              every host in [web]
│   ├── rocky.yml            only the Red Hat family
│   └── ubuntu.yml           only the Debian family
├── host_vars/
│   └── web2.yml             just this one machine
└── site.yml

Ansible loads these automatically, by name. A file called group_vars/web.yml applies to the web group, and host_vars/web2.yml to the host web2. It works the same next to the inventory or next to the playbook. A folder works too (group_vars/web/ with several files inside), which you'll use for secrets in the Vault lesson.

Groups for the family differences

Instead of sprinkling when: ansible_facts['os_family'] == … everywhere, put each family's machines in a group and give each group its own vars file:

group_vars/rocky.yml
apache_pkg: httpd
apache_svc: httpd
admin_group: wheel
group_vars/ubuntu.yml
apache_pkg: apache2
apache_svc: apache2
admin_group: sudo

Now the tasks just say name: "{{ apache_pkg }}" and work everywhere. Adding AlmaLinux later means adding a host to [rocky], with no task changes.

Who wins? Variable precedence

When the same variable is set in several places, the more specific one wins. Here's a simplified version of Ansible's list (the full one has 22 levels!), from weakest to strongest:

Set in…Use it for
1 (weakest)role defaults/main.ymlsensible defaults anyone can override (next lesson)
2group_vars/alltrue for everyone
3group_vars/GROUPtrue for a group
4host_vars/HOSTone special machine
5the play's vars: and vars_files:settings for that playbook
6register and set_factvalues worked out while running
7 (strongest)-e / --extra-vars on the command lineone-off overrides: -e site_version=3

To see exactly what a host ends up with, ask: ansible-inventory --host web2, or ansible web -m debug -a 'var=site_title'.

One variable, one place

Precedence is powerful and confusing. Most teams follow a simple rule: define each variable in one place, at the most general level that's true, and only override it on purpose.

Loops

- name: Create an account for every admin
  ansible.builtin.user:
    name: "{{ item }}"            # item = the current element
    groups: "{{ admin_group }}"   # wheel on Rocky, sudo on Ubuntu
    append: true
  loop: "{{ admins }}"            # a list from group_vars/web.yml

Add a name to the admins list, rerun, and only the new account shows changed. The others say ok.

Handlers: restart only when needed

Restarting Apache on every run would drop connections for no reason. A handler is a task that only runs when another task notifies it, and only if that task actually changed something. Handlers run once at the end of the play, however many tasks notified them.

  tasks:
    - name: Put our home page in place
      ansible.builtin.template:
        src: templates/index.html.j2
        dest: /var/www/html/index.html
        mode: "0644"
      notify: Restart Apache        # only if the file changed

  handlers:
    - name: Restart Apache          # matched by name
      ansible.builtin.service:
        name: "{{ apache_svc }}"
        state: restarted

register, debug and changed_when

- name: How long has each server been up?
  ansible.builtin.command:
    cmd: uptime
  register: up                      # save the result in a variable
  changed_when: false               # reading uptime changes nothing!

- name: Show it
  ansible.builtin.debug:
    msg: "{{ inventory_hostname }}: {{ up.stdout }}"

A registered result has fields like stdout, stderr, rc and changed. changed_when: false keeps a read-only command from reporting changed every run, which keeps the playbook idempotent.

Smarter templates

<h1>{{ site_title }}</h1>
{% if admins | length > 0 %}
<p>Admins: {{ admins | join(', ') }}</p>
{% endif %}

Jinja2 also has {% for user in admins %}…{% endfor %}, and filters like default('x'), upper, length and join. Keep logic in templates small: if it gets complicated, work the value out in a variable first.

Practice: two titles, one list, zero wasted restarts 🧮

The project in ~/ansible already has an inventory with [rocky] and [ubuntu] groups and their vars files. Add the shared settings, give web2 its own title, then run the improved playbook and watch the handler.

Quick check

1. site_title is set in group_vars/web.yml AND in host_vars/web2.yml. What does web2 get?

2. When does a handler run?

3. Why use groups [rocky] and [ubuntu] with their own group_vars?

4. A command: uptime task shows changed on every run. What's the fix?

Finished the missions and the quiz? Mark it done to track your progress.