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/andhost_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,debugandchanged_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:
apache_pkg: httpd apache_svc: httpd admin_group: wheel
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.yml | sensible defaults anyone can override (next lesson) |
| 2 | group_vars/all | true for everyone |
| 3 | group_vars/GROUP | true for a group |
| 4 | host_vars/HOST | one special machine |
| 5 | the play's vars: and vars_files: | settings for that playbook |
| 6 | register and set_fact | values worked out while running |
| 7 (strongest) | -e / --extra-vars on the command line | one-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'.
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?
✓ Check with ansible-inventory --host web2.
2. When does a handler run?
✓ Ten config changes still mean just one restart.
3. Why use groups [rocky] and [ubuntu] with their own group_vars?
✓ Adding another Red Hat-family server is now one line in the inventory.
4. A command: uptime task shows changed on every run. What's the fix?
✓ An honest changed count is what makes idempotence checks useful.