Ansible Handlers Explained - notify, listen, flush_handlers, Handler Order and Real-World Examples

Introduction

Welcome to another Ansible blog post! Today we'll dive deep into Ansible Handlers. Before we jump straight into the demo, let's lay a solid foundation by understanding the 'what' and 'when' of Ansible Handlers - and especially the when, because that is where almost every handler bug comes from.


Table of Content

  1. What Are Ansible Handlers?
  2. Setting Up the Ansible Handler
  3. Setting up the Ansible tasks
  4. When do handlers actually run? (and meta: flush_handlers)
  5. Multiple Ansible Handlers using the listen keyword
  6. Ansible Handler order - the real rules
  7. Handlers in roles and include files
  8. What happens when a task fails - force_handlers
  9. Common mistakes - why my handler never runs
  10. Conclusion & Key Takeaways

1. What Are Ansible Handlers?

Imagine you're managing a website and wish to update its content. You employ an Ansible Playbook to copy the refreshed HTML file onto the server.

But there's a catch. Every time you update the configuration of your application server, you must restart or reload it to see the change. And if your playbook has ten tasks which each change something, you do not want ten restarts - you want one, at the end, and only if something actually changed.

How can you streamline this process? - The answer is Ansible Handlers.

Update the website content with Ansible and restart nginx with a handler

A handler is a task with two special properties -

  1. It runs only when notified by another task, and a task only notifies when it reports changed. Run the playbook a second time with nothing to change, and the handler does not run.
  2. It runs once per play, however many tasks notified it. Ten changed config files still mean one restart.

Here is the whole lifecycle in one picture - tasks notify during the play, the notifications are deduplicated, and the handlers run at the end -

Ansible handlers are notified during the tasks and run once at the end of the play, in the order they are defined

With Ansible Handlers, you can:

  1. Copy the updated HTML or config onto your application server.
  2. Automatically restart or reload the server once, after all the updates, and only when something changed.


2. Setting Up the Ansible Handler

The aim of the handler for this blog post is to restart the nginx server. Handlers live in a handlers: section of the play (or in handlers/main.yml of a role) and look exactly like tasks -

Here is the GitHub project link - Ansible Handlers

 1---
 2- name: Example Ansible playbook for Handlers
 3  hosts: all
 4  become: yes
 5  remote_user: ubuntu
 6  roles:
 7    - handlers
 8
 9  # Ansible Handler: this will restart the Nginx server, but only when notified
10  handlers:
11    - name: Restart Nginx
12      ansible.builtin.service:
13        name: nginx
14        state: restarted

The handler's name is what tasks will notify. Keep it unique within the play - two handlers with the same name shadow each other.

Restart vs reload - for nginx (and most servers) a reload applies a new config without dropping connections, a restart kills and starts the process. Prefer state: reloaded for config changes and keep restarted for upgrades or binary changes. Many of my roles have both handlers.


3. Setting up the Ansible tasks

In the previous step we defined the handler, now let's create the tasks for -

  1. Installing the Nginx application server
  2. Updating the website content
  3. Calling the handler with notify

3.1. Installing the Nginx Application Server:

The initial task installs the Nginx server -

1---
2# Step-1 : Install Nginx
3- name: Update apt cache and install Nginx
4  ansible.builtin.apt:
5    name: nginx
6    state: latest
7    update_cache: yes

3.2. Updating Website Content:

The playbook then copies the HTML file (which you want to update) to the application server -

 1---
 2# Step-1 : Install Nginx
 3- name: Update apt cache and install Nginx
 4  ansible.builtin.apt:
 5    name: nginx
 6    state: latest
 7    update_cache: yes
 8
 9# Step-2: Copy index.html
10- name: index.html copy
11  ansible.builtin.template:
12    src: index.html
13    dest: /usr/share/nginx/html/index.html

3.3. Calling the Ansible handler with notify:

The key here is the notify attribute. After copying the content, I've set it to notify the handler named 'Restart Nginx'. When this task reports changed (the file content differs from what is on the server), Ansible records that the handler should run -

 1# Step-1 : Install Nginx
 2- name: Update apt cache and install Nginx
 3  ansible.builtin.apt:
 4    name: nginx
 5    state: latest
 6    update_cache: yes
 7
 8# Step-2: Copy index.html
 9- name: index.html copy
10  ansible.builtin.template:
11    src: index.html
12    dest: /usr/share/nginx/html/index.html
13
14# Step-3: Update the content on the website and restart the server
15- name: updated.html copy
16  ansible.builtin.template:
17    src: updated.html
18    dest: /usr/share/nginx/html/index.html
19  notify:
20    - Restart Nginx

Run it twice and watch the difference -

 1# first run - the template changed, the handler runs
 2TASK [updated.html copy] *******************************************************
 3changed: [35.204.184.224]
 4
 5RUNNING HANDLER [Restart Nginx] ************************************************
 6changed: [35.204.184.224]
 7
 8# second run - nothing changed, the handler is not even mentioned
 9TASK [updated.html copy] *******************************************************
10ok: [35.204.184.224]

That RUNNING HANDLER line appears after the last task, not right after the template task - which brings us to the most important section of this post.


4. When do handlers actually run? (and meta: flush_handlers)

By default, handlers run at the end of each section of the play - after all pre_tasks, after all roles and tasks, and after all post_tasks. The Ansible docs put it in one line - "By default, handlers run after all the tasks in a particular play have been completed." They do not run immediately after the task which notified them.

That is usually what you want (one restart after all the config changes). But sometimes you need the handler to run in the middle of the play - for example, restart nginx now, because the next task is a health check against it. For that there is meta: flush_handlers -

 1- name: Configure and verify nginx
 2  hosts: webservers
 3  become: yes
 4  tasks:
 5    - name: Deploy nginx.conf
 6      ansible.builtin.template:
 7        src: nginx.conf.j2
 8        dest: /etc/nginx/nginx.conf
 9      notify: Reload Nginx
10
11    - name: Run the notified handlers right now
12      ansible.builtin.meta: flush_handlers
13
14    - name: Verify nginx answers on port 80
15      ansible.builtin.uri:
16        url: http://localhost/
17        status_code: 200
18
19  handlers:
20    - name: Reload Nginx
21      ansible.builtin.service:
22        name: nginx
23        state: reloaded

"The meta: flush_handlers task triggers any handlers that have been notified at that point in the play." After a flush, the handler can be notified again by later tasks and will run again at the next flush or at the end of the play.

Two more timing facts which surprise people -

  • Handlers run on the hosts that notified them, not on every host. If only web-02 had a changed config, only web-02 restarts. (This combines nicely with --limit.)
  • Handlers ignore tags. If you run --tags config, the tasks tagged config run and their handlers run too, even though the handlers carry no tag.

5. Multiple Ansible Handlers using the listen keyword

Here is a neat feature that's often overlooked - the listen keyword, which lets a single notify trigger multiple handlers.

Imagine you're running a web server and a cache service. Whenever you update the web server's configuration, both the web server and the cache service need to restart. Instead of listing both handler names in every task's notify, give the handlers a shared topic to listen on -

 1---
 2- name: Ansible multiple handlers with listen
 3  hosts: webservers
 4  become: yes
 5  tasks:
 6    - name: Ensure the web server configuration is updated
 7      ansible.builtin.copy:
 8        src: files/config.conf
 9        dest: /etc/webserver/config.conf
10      notify: "Restart Necessary Services"
11
12  handlers:
13    - name: Gracefully restart the web server
14      ansible.builtin.service:
15        name: webserver
16        state: restarted
17      listen: "Restart Necessary Services"
18
19    - name: And, don't forget the cache service!
20      ansible.builtin.service:
21        name: cacheservice
22        state: restarted
23      listen: "Restart Necessary Services"

Here's the magic: when the configuration task reports changed, it notifies the topic Restart Necessary Services. Every handler listening to that topic is queued. The docs say it directly - "Notifying the restart web services topic results in executing all handlers listening to that topic."

Why this is better than listing two names in notify -

  1. The task does not need to know how many handlers exist. Add a third service next month and only the handlers file changes.
  2. Roles can expose a stable topic name (restart web stack) while renaming their internal handlers freely.
  3. A handler can have both a name and a listen, and even listen to several topics (listen accepts a list).

One limitation from the docs - listen topics cannot contain templates, so listen: "restart {{ service_name }}" does not work.


6. Ansible Handler order - the real rules

This is where I have to correct something I wrote in the first version of this post. I claimed that the order of names in notify controls the order in which the handlers run. It does not. The rule, straight from the Ansible documentation, is -

"Handlers are executed in the order they are defined in the handlers section."

Not the order in which they were notified, not the order in the notify list. So if you need "clear the cache, then restart the app", the only thing that matters is that the cache handler is defined above the restart handler -

 1---
 2- hosts: app_servers
 3  become: yes
 4  tasks:
 5    - name: Deploy the application
 6      ansible.builtin.copy:
 7        src: files/app/
 8        dest: /opt/app/
 9      notify:
10        - Restart App       # listed first here...
11        - Clear Cache       # ...but order here is irrelevant
12
13  handlers:
14    # defined first => runs first
15    - name: Clear Cache
16      ansible.builtin.command: /opt/app/bin/clear_cache.sh
17
18    # defined second => runs second
19    - name: Restart App
20      ansible.builtin.service:
21        name: my_app_service
22        state: restarted

Output -

1RUNNING HANDLER [Clear Cache] **************************************************
2changed: [app-01]
3
4RUNNING HANDLER [Restart App] **************************************************
5changed: [app-01]

The same rule applies with listen - handlers listening to a topic run in the order they are defined, not the order the topics were notified. And when handlers come from several roles, the roles' handlers are appended in the order the roles are listed in the play (see the next section).

If you have a hard ordering requirement between two steps, the most robust option is to make the second handler run after a meta: flush_handlers, or simply to put both actions in one handler (a handler can be a block, or a single shell script).


7. Handlers in roles and include files

Roles - a role's handlers live in roles/<role>/handlers/main.yml, and tasks in the role notify them by name. What is less known is that role handlers are global to the play - the docs state "Handlers from roles are not just contained in their roles but rather inserted into the global scope." A task in the playbook, or in another role, can notify them too. To avoid name clashes between roles, notify with the role prefix -

1- name: Deploy site config
2  ansible.builtin.template:
3    src: site.conf.j2
4    dest: /etc/nginx/conf.d/site.conf
5  notify: "nginx : Reload Nginx"      # role name : handler name

This is the structure I use in all my roles, and I have described the full layout in Ansible roles, inventory, hosts and tasks.

Include files - you can notify an include_tasks file as if it were a handler, and "all tasks from within the include" run. The reverse is not possible: a handler defined inside a dynamic include cannot be notified. With a static import_tasks in the handlers section, the imported handlers become normal, individually notifiable handlers. Handlers also cannot use include_role or import_role.


8. What happens when a task fails - force_handlers

If a task fails on a host before the end of the play, Ansible stops processing that host and its notified handlers are not run. That can leave nginx running with a new config file on disk but the old config in memory - a confusing state the next morning.

If you would rather have the handlers run anyway, enable force_handlers on the play, on the command line, or in ansible.cfg -

1- hosts: webservers
2  force_handlers: true
1ansible-playbook site.yml --force-handlers
1# ansible.cfg
2[defaults]
3force_handlers = True

Note that this covers task failures. If the play aborts because of an unreachable host, handlers on that host still cannot run, for obvious reasons.


9. Common mistakes - why my handler never runs

1. The task did not report changed. The number one cause. command and shell always report changed (so they always notify), while copy, template, apt, lineinfile only report changed when something differs. If you need to control it, use changed_when -

1- name: Check whether a migration is needed
2  ansible.builtin.command: /opt/app/bin/migrate --dry-run
3  register: migrate
4  changed_when: "'pending' in migrate.stdout"
5  notify: Run migrations

2. Typo in the handler name. notify: restart nginx versus name: Restart Nginx - names are case-sensitive. Since ansible-core 2.x, notifying a handler that does not exist fails the play with The requested handler 'restart nginx' was not found in either the main handlers list nor in the listening handlers list, which at least makes it visible.

3. The handler is in the wrong play. Handlers are per play. A task in play 1 cannot notify a handler defined in play 2.

4. You expected it to run immediately. It runs at the end of the section. Use meta: flush_handlers (Section 4).

5. A task failed afterwards and the handler was skipped. Use --force-handlers (Section 8).

6. --check mode. In check mode handlers are notified by tasks that would change, and the handlers themselves run in check mode too, so a service: restarted handler shows changed without restarting anything. That is the expected behaviour.

7. The handler runs on every run. Your notifying task is a command/shell without creates: or changed_when. Fix the task, not the handler.

8. The handler failed with Could not find the requested service nginx. The handler ran before the package was installed, usually because the install task is in a later play or role. Order the roles correctly, or notify from the install task.

9. notify in a block. Allowed - every task inside a block can notify. The block keyword itself cannot carry notify.

If nginx itself refuses to start after the handler, that is a config problem, not a handler problem - I have covered the usual suspects in Ansible Apache failed to start, and the same checks apply to nginx.


10. Conclusion & Key Takeaways

Ansible Handlers are the right way to do "restart the service if, and only if, its configuration changed" - the pattern behind almost every production role.

  1. A handler runs only when a task that notifys it reports changed, and runs once per play however often it was notified.
  2. Handlers run at the end of the play section - use meta: flush_handlers to run them earlier.
  3. listen lets one notify trigger many handlers, which keeps tasks decoupled from handler names.
  4. Handlers run in the order they are defined, not the order they are notified.
  5. Role handlers are global to the play - notify them as role : handler to avoid clashes.
  6. Add force_handlers if you cannot afford a half-applied config after a failed task.

The complete reference is the Ansible handlers documentation, and if you want to see the whole playbook from this post running end to end, watch the video above or clone the example repository. Do you have similar dependencies in your playbooks? Then handlers are the tool you need.