Ansible Handlers: Restart Services Only When Needed
Use handlers to reload nginx only when its configuration changes, test the config before a reload, and avoid the traps of failed runs and handler order.
Some changes only take effect after a service reloads: nginx reads its configuration at start and on reload. You could reload nginx at the end of every run, but that touches every server even when nothing changed. Handlers solve this: a task that changes something notifies a handler, and the handler runs once, at the end, only if a notification arrived. In this part you add nginx configuration to web.yml, reload nginx with a handler, make the handler test the configuration first, and see the two situations in which handlers do not run when you expect them to.
Prerequisites
Add Configuration and a Handler
Right now nginx tells every client its exact version:
curl -sI http://192.168.56.11/ | grep -i '^server'
Server: nginx/1.24.0 (Ubuntu)
Add two configuration files to web.yml: one hides the version, one adds two security headers. Both notify the same handler topic, nginx config changed. Then add a handlers section with two handlers that listen to that topic. This is the whole playbook now:
---
- name: Set up the web servers
hosts: web
become: true
pre_tasks:
- name: Stop on systems this playbook does not support
ansible.builtin.assert:
that: ansible_facts['os_family'] == "Debian"
fail_msg: This playbook supports Debian and Ubuntu only.
tags: always
tasks:
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
cache_valid_time: 3600
tags: packages
- name: Hide the nginx version in responses
ansible.builtin.copy:
content: "server_tokens off;\n"
dest: /etc/nginx/conf.d/hardening.conf
owner: root
group: root
mode: '0644'
notify: nginx config changed
- name: Add security headers
ansible.builtin.copy:
content: |
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
dest: /etc/nginx/conf.d/headers.conf
owner: root
group: root
mode: '0644'
notify: nginx config changed
- name: Publish the home page
ansible.builtin.copy:
content: |
<h1>Hello from {{ inventory_hostname }}</h1>
<p>Run by {{ site_owner }}. Managed by Ansible.</p>
dest: /var/www/html/index.html
owner: root
group: root
mode: '0644'
tags: page
- name: Start nginx and enable it at boot
ansible.builtin.service:
name: nginx
state: started
enabled: true
handlers:
- name: Check the nginx configuration
ansible.builtin.command: nginx -t
changed_when: false
listen: nginx config changed
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
listen: nginx config changed
post_tasks:
- name: Fetch the home page from the control node
ansible.builtin.uri:
url: 'http://{{ ansible_host }}/'
return_content: true
register: home_page
delegate_to: localhost
become: false
check_mode: false # a GET changes nothing, so it also runs with --check
- name: Check that the page names the host
ansible.builtin.assert:
that: ('<h1>Hello from ' ~ inventory_hostname ~ '</h1>') in home_page.content
fail_msg: '{{ ansible_host }} did not serve its own page'
Run it:
ansible-playbook web.yml
TASK [Hide the nginx version in responses] *************************************
changed: [web2]
changed: [web1]
TASK [Add security headers] ****************************************************
changed: [web2]
changed: [web1]
TASK [Publish the home page] ***************************************************
ok: [web2]
ok: [web1]
TASK [Start nginx and enable it at boot] ***************************************
ok: [web1]
ok: [web2]
RUNNING HANDLER [Check the nginx configuration] ********************************
ok: [web2]
ok: [web1]
RUNNING HANDLER [Reload nginx] *************************************************
changed: [web1]
changed: [web2]
TASK [Fetch the home page from the control node] *******************************
ok: [web1 -> localhost]
ok: [web2 -> localhost]
TASK [Check that the page names the host] **************************************
ok: [web1] => {
"changed": false,
"msg": "All assertions passed"
}
ok: [web2] => {
"changed": false,
"msg": "All assertions passed"
}
PLAY RECAP *********************************************************************
web1 : ok=11 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web2 : ok=11 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Read the order of events:
- Both configuration tasks changed something on both hosts, so each sent the notification
nginx config changedtwice per host. - The handlers did not run right away. They ran once per host, after all tasks of the
taskssection, and beforepost_tasks, so the test at the end saw the reloaded nginx. - They ran in the order they are written in the
handlerssection, not in the order of the notifications: firstnginx -t, then the reload.
The result:
curl -sI http://192.168.56.11/ | grep -i -E '^(server|x-)'
Server: nginx
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Run the playbook again and nothing is notified, so no handler runs:
ansible-playbook web.yml
PLAY RECAP *********************************************************************
web1 : ok=9 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web2 : ok=9 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
How Notifications Work
notifytakes a handler'snameor alistentopic, or a list of them. A topic lets several handlers react to one event, and lets tasks notify "what happened" (nginx config changed) instead of "what to do".- A handler runs only if the notifying task reported
changed.okandskippeddo not notify. - A handler runs at most once per host per flush, however many tasks notified it.
- Handlers are flushed at the end of each section of a play (
pre_tasks, thenrolesandtasks, thenpost_tasks), and when a task callsansible.builtin.meta: flush_handlers. - Handler names must be unique in a play. If two roles both define
Reload nginx, only the last one loaded can run. Give handlers in roles names that include the role, or notify alistentopic.
When a later task needs the new configuration, flush the handlers before it instead of waiting for the end of the section:
- name: Switch nginx to the new upstream
ansible.builtin.copy:
src: upstream.conf
dest: /etc/nginx/conf.d/upstream.conf
mode: '0644'
notify: nginx config changed
- name: Run the pending handlers now
ansible.builtin.meta: flush_handlers
- name: Warm the cache through the new upstream
ansible.builtin.uri:
url: http://localhost/warmup
Test the Configuration Before Reloading
The Check the nginx configuration handler runs nginx -t before the reload. When the check fails, the reload never happens and nginx keeps serving with the configuration it already has. Try it: break the first file with a typo, server_tokens of;.
ansible-playbook web.yml --limit web1
RUNNING HANDLER [Check the nginx configuration] ********************************
fatal: [web1]: FAILED! => {"changed": false, "changed_when_result": false, "cmd": ["nginx", "-t"], "delta": "0:00:00.024931", "end": "2026-10-24 03:41:29.547712", "msg": "The command exited with a non-zero return code.", "rc": 1, "start": "2026-10-24 03:41:29.522781", "stderr": "2026/10/24 03:41:29 [emerg] 83095#83095: invalid value \"of\" in /etc/nginx/conf.d/hardening.conf:1\nnginx: configuration file /etc/nginx/nginx.conf test failed", "stderr_lines": ["2026/10/24 03:41:29 [emerg] 83095#83095: invalid value \"of\" in /etc/nginx/conf.d/hardening.conf:1", "nginx: configuration file /etc/nginx/nginx.conf test failed"], "stdout": "", "stdout_lines": []}
PLAY RECAP *********************************************************************
web1 : ok=7 changed=1 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0
The handler failed, so the reload did not run, web1 stopped, and the site is still up with the old configuration:
curl -s -o /dev/null -w '%{http_code}\n' http://192.168.56.11/
200
The broken file is still on disk, though. If nginx restarted now, for example after a reboot, it would not start. Fix the typo and run again: the file changes back, the handlers are notified, and this time they pass.
ansible-playbook web.yml --limit web1
RUNNING HANDLER [Check the nginx configuration] ********************************
ok: [web1]
RUNNING HANDLER [Reload nginx] *************************************************
changed: [web1]
PLAY RECAP *********************************************************************
web1 : ok=11 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
When a module writes the complete configuration file, its validate option is stronger: it tests the new file before it replaces the old one, so a broken file never reaches its place. Part 7 uses it for a sudoers file. For files that nginx includes, like these, the handler check is the practical option.
When Handlers Do Not Run
The classic trap: a task changes a configuration file and notifies a handler, then a later task fails. By default, a host that fails runs no handlers, so the reload never happens. On the next run the configuration task reports ok, because the file is already right, so nothing notifies the handler again. The new configuration sits on disk, unused, until something else restarts the service.
---
- name: A failure after a notification
hosts: web1
become: true
tasks:
- name: Change the configuration
ansible.builtin.copy:
content: "gzip_comp_level 5;\n"
dest: /etc/nginx/conf.d/gzip.conf
mode: '0644'
notify: Reload nginx
- name: A later task fails
ansible.builtin.command: /bin/false
changed_when: false
handlers:
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
ansible-playbook gotcha.yml
The output, with the long fatal line cut after 72 characters:
TASK [Gathering Facts] *********************************************************
ok: [web1]
TASK [Change the configuration] ************************************************
changed: [web1]
TASK [A later task fails] ******************************************************
fatal: [web1]: FAILED! => {"changed": false, "changed_when_result": fals
PLAY RECAP *********************************************************************
web1 : ok=2 changed=1 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0
No RUNNING HANDLER line: the reload was lost, and running the playbook again does not bring it back, because the file is already in place. Reload nginx by hand to apply it (ansible web1 -b -a "systemctl reload nginx"). To avoid the problem, run with --force-handlers, or set force_handlers: true on the play, and notified handlers run even on a host where a task failed. To see it, delete the file so that the task changes it again, then run with the option:
ansible web1 -b -m ansible.builtin.file -a "path=/etc/nginx/conf.d/gzip.conf state=absent"
web1 | CHANGED => {
"changed": true,
"path": "/etc/nginx/conf.d/gzip.conf",
"state": "absent"
}
ansible-playbook gotcha.yml --force-handlers
The output, with the long fatal line cut after 72 characters:
TASK [Gathering Facts] *********************************************************
ok: [web1]
TASK [Change the configuration] ************************************************
changed: [web1]
TASK [A later task fails] ******************************************************
fatal: [web1]: FAILED! => {"changed": false, "changed_when_result": fals
RUNNING HANDLER [Reload nginx] *************************************************
changed: [web1]
PLAY RECAP *********************************************************************
web1 : ok=3 changed=2 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0
This time the handler ran although a task failed. gotcha.yml was only a demonstration; delete it, and remove its file from web1:
ansible web1 -b -m ansible.builtin.file -a "path=/etc/nginx/conf.d/gzip.conf state=absent"
ansible web1 -b -a "systemctl reload nginx"
rm gotcha.yml
The second case is check mode: with --check, a task that would change reports changed, and its handlers are shown as if they ran, but nothing is reloaded.
Reload or Restart?
Prefer a reload when the service supports one. nginx reloads its configuration without dropping open connections; a restart stops the process and briefly refuses new ones. Some changes need a restart anyway, such as a new nginx module or, for many databases, a memory setting. Give each its own handler and notify the one that fits:
handlers:
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
- name: Restart nginx
ansible.builtin.service:
name: nginx
state: restarted
For a fleet behind a load balancer, combine handlers with serial, which runs the whole play on a few hosts at a time, so that only part of the fleet reloads at once. Part 9 shows it.
Common Mistakes
- Restarting in a regular task. A task with
state: restartedrestarts every time. Use a handler. - Notifying a name that does not exist. The playbook fails with
The requested handler 'Reload nginix' was not found. Topics withlistenmake typos less likely, because one name covers several handlers. - Expecting notification order to matter. Handlers run in the order of the
handlerssection. - A failed run that leaves changed files behind. Use
--force-handlers, or check the service after a failed run. - A command in a handler without
changed_when.nginx -tchanges nothing; say so.
Summary
- Tasks notify handlers by name or by
listentopic; a handler runs once per host, at the end of the section, and only if a notifying task changed something. - Handlers run in the order they are defined;
meta: flush_handlersruns them early. - Test the configuration in a handler before the reload, so a broken file never reaches the running service.
- A failed task cancels pending handlers on that host;
--force-handlerschanges that.
In Part 7 you replace the inline file contents with Jinja2 templates, give the site its own nginx server block, and use the other file modules.
Keep Learning
Tags
Found an issue?