Ansible Course
Tasks, Handlers and Variables
In this lesson
Tasks, handlers, and variables are the three core building blocks of every Ansible playbook beyond the play header itself. A task is a single call to a module with a set of arguments; a handler is a special task that runs only when explicitly triggered by a change; and a variable is a named value that makes tasks configurable without editing their definitions. Together they enable automation that is not only repeatable but also flexible — the same playbook can configure a development server, a staging environment, and a production fleet by changing nothing but the variables supplied to it.
Task Attributes in Depth
Every task is a YAML mapping. Beyond the
required name and module call, tasks accept a rich set of optional
attributes that control how and when they execute. These attributes are the difference
between a fragile task and a production-grade one.
Key task-level attributes
name
Human-readable
description. Shown in playbook output. Required — always include it.
when
A Jinja2 expression
that must evaluate to true for the task to run. If false, the task
is skipped. Used for conditional execution based on facts, variables, or
previous task results.
register
Captures the
module's return value into a variable. The variable holds the full JSON result —
including changed, stdout, rc, and any
data the module returns.
notify
Names a handler to
trigger if this task reports changed. Multiple tasks can notify the
same handler — it runs only once at the end of the play, regardless of how many
tasks notified it.
loop
Runs the task once
for each item in a list. The current item is available as
{{ item }}. Replaces the older with_items syntax.
Covered in depth in Lesson 16.
become
Override the
play-level privilege escalation setting for this specific task. Useful when
most tasks run as a normal user but one specific task needs root.
ignore_errors
When true,
a failure in this task does not stop the play. Use sparingly — only when a failure
is genuinely expected and handled by subsequent tasks.
tags
One or more labels
that let you run or skip this task selectively with
--tags or --skip-tags at the command line. Covered
in Lesson 22.
- name: Install required packages
ansible.builtin.package:
name: "{{ item }}"
state: present
loop:
- nginx
- git
- curl
become: true # override — this task specifically needs root
when: ansible_os_family == "Debian" # only run on Debian-based systems
tags:
- packages
- install
notify: Restart application # trigger handler if any package changes
Variables and Scope
Ansible resolves variables through a
precedence chain
of over 20 levels. In practice, you only need to understand five: the higher a variable
is defined in the chain, the higher its priority. A variable defined at the command line
always wins; a variable in group_vars is always overridden by a host-level
variable for the same key.
The practical rule: if a variable is not
behaving as you expect, check which scope defined it and whether a higher-priority
source is overriding it. Run
ansible-playbook site.yml -e "var=value" to temporarily override any
variable without touching a file — useful for debugging.
Defining Variables
Variables can be defined in multiple places. Each serves a different purpose — understanding when to use each location makes your playbooks clean, flexible, and easy to override per environment.
Method 1 — Inline vars: block in the play
---
- name: Deploy application
hosts: webservers
vars:
app_port: 8080 # scoped to this play only
app_user: appuser
deploy_dir: /var/www/app
tasks:
- name: Create app directory
ansible.builtin.file:
path: "{{ deploy_dir }}"
state: directory
owner: "{{ app_user }}"
mode: "0755"
Method 2 — External vars_files:
# In the playbook:
- name: Deploy application
hosts: webservers
vars_files:
- vars/app.yml # load variables from an external file
- vars/secrets.yml # keep secrets in a separate (Vault-encrypted) file
# In vars/app.yml:
---
app_port: 8080
app_user: appuser
deploy_dir: /var/www/app
supported_os: ["Ubuntu", "Debian"]
Method 3 — ansible.builtin.set_fact during a play
- name: Compute derived variables at runtime
ansible.builtin.set_fact:
app_url: "http://{{ ansible_default_ipv4.address }}:{{ app_port }}"
backup_path: "{{ deploy_dir }}/backups/{{ ansible_date_time.date }}"
# These variables are now available to all subsequent tasks in the play
Method 4 — group_vars/ directory (recommended for most projects)
# File: group_vars/webservers.yml
# Loaded automatically for every host in the webservers group
---
nginx_port: 80
nginx_worker_processes: auto
nginx_client_max_body_size: 10m
app_root: /var/www/html
# File: group_vars/all.yml
# Loaded automatically for EVERY host in the inventory
---
ntp_server: pool.ntp.org
timezone: UTC
log_level: warning
The Settings Menu Analogy
Variable precedence works exactly like a settings menu with multiple levels. The app has factory defaults (group_vars). The user can override those in their profile (host_vars). An administrator can override those with a system policy (play vars). And a developer testing something can override everything with an environment variable (extra vars). The highest-level setting always wins — and you can always trace back which level set the final value.
Handlers and Notify
A
handler
is a task that only runs when it is explicitly notified by another task that reported
changed. Handlers are defined in a separate handlers: block
at the play level and execute after all regular tasks have completed — regardless of
how many tasks notified them, the handler runs exactly once.
The canonical use case is service restarts: you want Nginx to restart only when its configuration actually changed, not on every playbook run. Without handlers you would either restart on every run (wasteful and potentially disruptive) or never restart automatically (requiring manual intervention). Handlers solve this cleanly.
---
- name: Configure Nginx
hosts: webservers
become: true
tasks:
- name: Install Nginx
ansible.builtin.package:
name: nginx
state: present
# No notify here — installing doesn't need a restart
- name: Deploy Nginx main configuration
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
mode: "0644"
notify: Restart Nginx # notifies handler IF this task changed
- name: Deploy virtual host configuration
ansible.builtin.template:
src: vhost.conf.j2
dest: /etc/nginx/conf.d/app.conf
mode: "0644"
notify: Restart Nginx # same handler — still runs only once
- name: Ensure Nginx is started
ansible.builtin.service:
name: nginx
state: started
enabled: true
# Handlers run AFTER all tasks complete, regardless of how many notified them
handlers:
- name: Restart Nginx
ansible.builtin.service:
name: nginx
state: restarted
# Even if both config tasks changed, Nginx restarts exactly once
What just happened?
Both the main config and the virtual host config
tasks notify Restart Nginx. If both change in a single run, the handler
is queued twice — but Ansible deduplicates the queue and runs it only once at the
end of the play. This prevents double-restarts during deployments where multiple
config files change simultaneously.
Capturing Results with register
The
register
attribute captures a task's full return value into a variable. This is how you pass
data between tasks — check a command's output, read a file's content, or use a
module's return value in a later when: condition.
- name: Check if app config exists
ansible.builtin.stat:
path: /etc/app/config.yml
register: config_file # capture the result into 'config_file'
# Use the registered variable in the next task's when condition
- name: Back up existing config before replacing it
ansible.builtin.copy:
src: /etc/app/config.yml
dest: /etc/app/config.yml.bak
remote_src: true # copy on the remote host, not from control node
when: config_file.stat.exists # only run if the file was found
- name: Deploy new config
ansible.builtin.template:
src: config.yml.j2
dest: /etc/app/config.yml
notify: Restart application
Inspecting registered variables with ansible.builtin.debug
- name: Get current app version
ansible.builtin.command:
cmd: /usr/local/bin/app --version
register: app_version_result
changed_when: false # command doesn't change anything — suppress changed
- name: Print the captured output
ansible.builtin.debug:
msg: "Current version: {{ app_version_result.stdout }}"
# You can also dump the entire registered object to see its structure:
- name: Inspect full result structure during development
ansible.builtin.debug:
var: app_version_result # prints the full JSON result object
TASK [Print the captured output] **********************************************
ok: [web01.example.com] => {
"msg": "Current version: 2.4.1"
}
TASK [Inspect full result structure during development] ***********************
ok: [web01.example.com] => {
"app_version_result": {
"changed": false,
"cmd": "/usr/local/bin/app --version",
"rc": 0,
"stdout": "2.4.1",
"stdout_lines": ["2.4.1"],
"stderr": "",
"failed": false
}
}What just happened?
The debug module printed both a
formatted message using app_version_result.stdout and the full raw
result object. During development, dumping the full object with var:
is the fastest way to discover what keys a registered variable contains — then
you can reference specific fields in subsequent tasks and conditions.
Controlling changed and failed Status
Two task attributes give you precise control
over when Ansible considers a task to have made a change or to have failed.
changed_when
and
failed_when
are essential when working with command or shell tasks, which
do not have built-in idempotency logic.
changed_whenchanged reportfalse to suppress
spurious changed on read-only commandsfailed_when# changed_when: suppress false positives from read-only commands
- name: Check if migrations are needed
ansible.builtin.command:
cmd: python manage.py showmigrations --list
register: migration_status
changed_when: false # this command never changes anything
# changed_when: mark changed only when the output says so
- name: Run database migrations
ansible.builtin.command:
cmd: python manage.py migrate
register: migration_result
changed_when: "'No migrations to apply' not in migration_result.stdout"
# failed_when: treat a specific non-zero exit as non-fatal
- name: Grep for error in log file
ansible.builtin.command:
cmd: grep -c ERROR /var/log/app.log
register: error_count
failed_when: error_count.rc > 1 # exit code 1 = no match (not a failure)
changed_when: false
Handlers Only Run When a Task Reports changed — Not on ok
A frequently misunderstood rule:
notify only triggers a handler when the notifying task reports
changed. If the task reports ok — because the system
was already in the desired state — the handler is never queued. This means if
your config file did not change but you need a service restart anyway, you must
trigger it manually. Never assume a handler ran just because a task includes
a notify directive.
Key Takeaways
-e at the command
line to temporarily override any variable without touching a file.
register captures a task's full return value
— use it to pass data between tasks, drive conditional logic, or inspect
what a module returned. Use debug: var: to inspect the full
object during development.
changed_when: false on read-only commands
— without it, every command and shell task
reports changed on every run, making your play recap
meaningless as an idempotency signal.
group_vars/ and vars_files: over
inline vars: — keeping variables out of the play
header makes the playbook logic readable and variables easy to override
per environment.
Teacher's Note
Add register and
debug to a task in the Lesson 13 playbook and inspect the full result
object — understanding what a module actually returns is the fastest way to learn
what you can do with registered variables in when: conditions.
Practice Questions
1. Three tasks all notify the same
handler in a single playbook run and all three report changed.
How many times does the handler run?
2. A command task runs
a read-only check and always reports changed even though it never
modifies anything. What attribute and value should you add to fix this?
3. Which task attribute captures a module's full return value — including stdout, rc, and changed — into a variable for use by subsequent tasks?
Quiz
1. A task that deploys a config file
notifies a handler, but on this run the file was already correct and the task
reported ok. What happens to the handler?
2. The same variable
app_port is defined in group_vars/all.yml, in the play's
vars: block, and passed with -e "app_port=9090" at the
command line. Which value does Ansible use?
3. What does
ansible.builtin.set_fact do?
Up Next · Lesson 15
Facts and Gathering Facts
Discover how Ansible automatically collects hundreds of facts about every managed node — and how to use them to write playbooks that adapt intelligently to any server they run on.