Skip to main content
Ansible · lesson 3 of 6
Ansible intermediate Lesson 3 of 6

Roles and Handlers: notify Only Fires on changed, Never on ok

ansible-galaxy init scaffolds a real role directory. Wire a handler to a config-writing task with notify, and watch it disappear from the run entirely the moment the task reports ok instead of changed.

By SkillByExample · Updated

ansible roles handlers notify

A role is just a standard directory layout — and the handler pattern inside it has one rule that’s easy to assume wrong: notify only fires on changed, never on ok.

Scaffolding a real role

ansible-galaxy init roles/webserver
- Role roles/webserver was created successfully
find roles/webserver -type f | sort
roles/webserver/README.md
roles/webserver/defaults/main.yml
roles/webserver/handlers/main.yml
roles/webserver/meta/main.yml
roles/webserver/tasks/main.yml
roles/webserver/tests/inventory
roles/webserver/tests/test.yml
roles/webserver/vars/main.yml

ansible-galaxy init doesn’t ask what the role should do — it just lays out the directories every role is expected to have, mostly empty, ready to fill in.

Wiring a task to a handler

# roles/webserver/defaults/main.yml
---
webserver_port: 8080
# roles/webserver/templates/config.j2
port={{ webserver_port }}
# roles/webserver/tasks/main.yml
---
- name: Create config directory
  file:
    path: /tmp/webserver-config
    state: directory

- name: Write config file
  template:
    src: config.j2
    dest: /tmp/webserver-config/app.conf
  notify: Reload config
# roles/webserver/handlers/main.yml
---
- name: Reload config
  debug:
    msg: "Config reloaded on port {{ webserver_port }}"
# role_playbook.yml
---
- name: Use the webserver role
  hosts: local
  roles:
    - webserver
ansible-playbook -i inventory.ini role_playbook.yml
TASK [webserver : Create config directory] *************************************
changed: [localhost]

TASK [webserver : Write config file] *******************************************
changed: [localhost]

RUNNING HANDLER [webserver : Reload config] ************************************
ok: [localhost] => {
    "msg": "Config reloaded on port 8080"
}

PLAY RECAP *********************************************************************
localhost                  : ok=4    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

The config file was written for the first time (changed), which queued Reload config, and it ran once, after both regular tasks completed — handlers always run at the end of the play, not the instant they’re notified.

The behavior worth knowing: no change, no handler

ansible-playbook -i inventory.ini role_playbook.yml
TASK [webserver : Create config directory] *************************************
ok: [localhost]

TASK [webserver : Write config file] *******************************************
ok: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=3    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

No RUNNING HANDLER line at all this time — it doesn’t even appear in the output. ok=3, not 4: the handler genuinely didn’t run. Both tasks reported ok (the config directory already existed, the rendered template already matched the file on disk), so nothing notified the handler — and notify never fires on ok, only on changed. This is the exact behavior a config-reload pattern depends on: reload only when the config actually changed, never on every single run regardless of state.

Practice

1. Change webserver_port and confirm the handler fires again with the new value.
ansible-playbook -i inventory.ini role_playbook.yml -e webserver_port=9090
TASK [webserver : Write config file] *******************************************
changed: [localhost]

RUNNING HANDLER [webserver : Reload config] ************************************
ok: [localhost] => {
    "msg": "Config reloaded on port 9090"
}

Changing the port changes what the template renders, so the file’s content no longer matches what’s on disk — changed, which notifies the handler again, which prints the actual new value it was given via -e.

2. Add a second task that also notifies Reload config, and confirm the handler still only runs once.
# roles/webserver/tasks/main.yml, second task added
- name: Touch a marker file
  file:
    path: /tmp/webserver-config/.reload-marker
    state: touch
  notify: Reload config
rm -rf /tmp/webserver-config
ansible-playbook -i inventory.ini role_playbook.yml
TASK [webserver : Create config directory] *************************************
changed: [localhost]

TASK [webserver : Write config file] *******************************************
changed: [localhost]

TASK [webserver : Touch a marker file] *****************************************
changed: [localhost]

RUNNING HANDLER [webserver : Reload config] ************************************
ok: [localhost] => {
    "msg": "Config reloaded on port 8080"
}

PLAY RECAP *********************************************************************
localhost                  : ok=5    changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

rm -rf wiped the whole directory, so all three tasks report changed this time — but still only one RUNNING HANDLER line, not two, even though both Write config file and Touch a marker file notify the same handler. Ansible deduplicates handlers notified multiple times within a single play and runs each one exactly once, after every regular task has finished.

3. List every variable a role's defaults/main.yml currently sets, without running the role.
cat roles/webserver/defaults/main.yml
---
webserver_port: 8080

Reading defaults/main.yml directly is often faster than digging through ansible-doc output for a role you wrote yourself — it’s meant to be the readable, easily-overridden baseline for exactly this kind of quick check.

Next: advanced playbook patterns — loops, conditionals, and error handling with blocks.

Frequently Asked Questions

What does ansible-galaxy init actually create?
A standard role skeleton — tasks/, handlers/, defaults/, vars/, templates/, meta/, and a tests/ directory — following the exact directory convention Ansible expects a role to have. Nothing in it is required to exist for a role to work (an empty tasks/main.yml still counts), but this layout is what every tool, collection, and Ansible Galaxy expects when sharing or reusing a role.
What's the difference between defaults/main.yml and vars/main.yml?
Both hold role-scoped variables, but defaults/ sits at the very bottom of Ansible's precedence order — meant to be easily overridden by anything (group_vars, host_vars, play vars, role parameters) — while vars/ sits much higher, closer to the top, meant for values the role itself depends on that a caller shouldn't casually override. Use defaults/ for "here's a sensible starting point" and vars/ for "this is how the role actually works internally."
Why didn't the handler run on the second playbook execution?
notify only queues a handler to run if the task that names it actually reports changed — the second run's Write config file task reported ok (nothing to write, the template output already matched what's on disk), so nothing queued the Reload config handler at all. This is deliberate: a handler represents "do this because something actually changed," and running it on every play regardless of state would defeat that purpose.
If a handler is notified twice in one play, does it run twice?
No — Ansible deduplicates notified handlers within a single play and runs each one at most once, after all the regular tasks in that play have finished (not immediately when notified). Two different tasks can both notify the same handler and it still only fires once, at the end.
Can a task force a handler to run even when nothing changed?
Not directly through notify, but changed_when on the task can override what counts as "changed" for that specific task — setting changed_when: true unconditionally would make it always report changed and therefore always fire anything it notifies, though that's rarely the right fix; usually the better move is to accept that the handler correctly shouldn't run when there's genuinely nothing to reload.