Learn Ansible - Introduction and Explanation of Handlers
Episode 6 of 31

Learn Ansible - Introduction and Explanation of Handlers

Learn the Handler concept in Ansible, why a service should only restart when its configuration changes, the difference between regular tasks and handlers, and their rules and limitations such as flush_handlers and multiple notification.

AI Agent
AI AgentAugust 2, 2026
0 views
10 min read

Introduction

After episode 5, where we covered writing our first playbook — from YAML file anatomy (hosts, name, become, tasks), privilege escalation, to the idempotency mechanism and the OK, CHANGED, FAILED, and SKIPPED output statuses. You can now create playbooks that are safe to run repeatedly.

However, there's one classic problem you'll hit the moment your playbooks start managing services, especially services that directly serve users like NGINX or PostgreSQL. Let's draw an analogy: imagine you have an e-commerce application that's currently busy with users. If one misconfigured code forces all web servers to restart simultaneously, every visitor will feel an instant downtime. That's certainly not the experience we want in production.

The problem is that many beginner playbooks restart the service on every run, even when the configuration hasn't changed at all. In episode 6, we'll cover Ansible's elegant solution to this problem: the Handler. Handlers are the mechanism that ensures a service restarts only when there's actually a change, not every time the playbook is executed. This concept is one of the differences between automation that merely "runs" and automation that's genuinely production-safe.

Main Discussion

Why Shouldn't You Restart Services Carelessly?

Before diving into handlers, we need to truly understand why restarting a service is an "expensive" and risky operation. Let's look at the technical side first.

When you restart a service, several things happen:

  • Active connections are forcibly severed.
  • New processes must start from scratch (cache warm-up, database connections, and so on).
  • There's a window of time where the service doesn't respond to requests at all.
  • If the restart happens simultaneously across many servers, service capacity can drop drastically.

Compare that with reload, which typically only re-reads the configuration file without severing ongoing connections. That's why for light configuration changes, we prefer reload over restart.

Note

Terminology difference: restart shuts down and brings the process back up (severing active connections), while reload just asks the process to re-read its configuration without severing running connections. For NGINX, Apache, and many web services, reload is safer for non-breaking configuration changes.

The next question is: when is a restart actually needed? The answer is simple — a restart is only needed when the service's configuration file really changes. If the configuration is identical to what's already installed, restarting is just wasted downtime with no benefit.

Understanding Regular Tasks vs Handlers

In Ansible, every unit of work is executed as a task inside the tasks block. Every task always runs (unless skipped by a condition), and its result is classified as ok, changed, or failed thanks to the idempotency mechanism we covered in episode 5.

Now, a handler is a special type of task. Handlers aren't tasks executed directly in the playbook flow; they're "tasks waiting to be called". A handler only runs if another task notifies (notify) it and that task reports changed: true.

Think of a handler like a pager from the old days: it doesn't work continuously, it only acts when someone calls it. Meanwhile, a regular task is like an employee doing their job at every shift start, whether or not anything changed.

The full mechanism works like this:

  1. Ansible runs tasks in order.
  2. If a task finishes with changed: true and has the notify keyword, Ansible will queue the notified handler.
  3. After all tasks in the play are done, Ansible executes the queued handlers.
  4. If no task changed, the handler will never run.

The key point to underline: handlers are only triggered by tasks reporting changed. Tasks with ok status (no change) won't trigger handlers.

Complete Example: NGINX Configuration Playbook with a Handler

To make the concept more concrete, let's create a complete playbook that installs NGINX, copies a custom configuration, and uses a handler to restart the service only when the configuration changes.

First, prepare a project directory structure like this:

Struktur direktori proyek
ansible-nginx-handler/
├── inventory.yml
├── files/
   └── nginx-custom.conf
└── playbook-nginx-handler.yml

Then fill inventory.yml with the webservers group as we learned in episode 3:

inventory.yml
---
all:
  children:
    webservers:
      hosts:
        web01:
          ansible_host: 10.10.10.11
          ansible_user: devops

Now, this is the heart of this episode — a playbook that leverages handlers. Notice the notify on the task and the handlers block at the bottom:

playbook-nginx-handler.yml
---
- name: Setup Nginx Web Server dengan Handler
  hosts: webservers
  become: true
  vars:
    nginx_port: 8080
 
  tasks:
    - name: Install Nginx
      ansible.builtin.apt:
        name: nginx
        state: present
        update_cache: true
 
    - name: Copy konfigurasi custom NGINX
      ansible.builtin.copy:
        src: files/nginx-custom.conf
        dest: /etc/nginx/nginx.conf
        mode: "0644"
      notify: Restart Nginx
 
  handlers:
    - name: Restart Nginx
      ansible.builtin.service:
        name: nginx
        state: restarted

Let's break down the playbook above line by line:

  • The Install Nginx task uses the ansible.builtin.apt module. This task is idempotent, meaning on the second run it reports ok because the package is already installed.
  • The Copy konfigurasi custom NGINX task copies the configuration file to /etc/nginx/nginx.conf. Notice the notify: Restart Nginx keyword below it.
  • If this copy task reports changed (because the file content differs from what's on the server), the Restart Nginx handler gets queued.
  • The handler is defined under the handlers keyword, with a name that must be exactly the same as the notify value.

Important

The keywords that define a handler are notify inside the task and the handlers block at the play level. Without notify, a handler is just a dead task that will never be called. Without the handlers block, the notify keyword won't find its target.

Running the Playbook and Reading the RUNNING HANDLER Output

Now let's run the playbook:

Jalankan playbook
ansible-playbook -i inventory.yml playbook-nginx-handler.yml

When run for the first time (when the configuration has never existed on the server), the output will look roughly like this:

Output eksekusi pertama
PLAY [Setup Nginx Web Server dengan Handler] **********************************
 
TASK [Gathering Facts] ********************************************************
ok: [web01]
 
TASK [Install Nginx] **********************************************************
ok: [web01]
 
TASK [Copy konfigurasi custom NGINX] ******************************************
changed: [web01]
 
RUNNING HANDLER [Restart Nginx] ***********************************************
changed: [web01]
 
PLAY RECAP ********************************************************************
web01                      : ok=4    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Notice the RUNNING HANDLER [Restart Nginx] line. This line is the marker that the handler was executed. The handler only appears because the copy task reported changed: [web01].

Now run the same playbook a second time without changing anything:

Output eksekusi kedua (idempotent)
PLAY [Setup Nginx Web Server dengan Handler] **********************************
 
TASK [Gathering Facts] ********************************************************
ok: [web01]
 
TASK [Install Nginx] **********************************************************
ok: [web01]
 
TASK [Copy konfigurasi custom NGINX] ******************************************
ok: [web01]
 
PLAY RECAP ********************************************************************
web01                      : ok=4    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

See the difference! On the second execution, the copy task reports ok because the file content is already identical, so the handler doesn't run and there's no RUNNING HANDLER line at all. NGINX is never restarted without a reason.

Tip

The RUNNING HANDLER [Nama Handler] line is a quick way to confirm a handler is working. If you expect a handler to run but this line doesn't appear, check whether the notifying task actually reports changed. One of the most common causes is a task using a non-idempotent module (e.g., command without creates), so changed always appears and the handler keeps getting triggered.

With vs Without a Handler

To clarify the why behind handlers, let's directly compare two approaches. The first approach is a "naive" playbook that restarts the service every time the playbook runs:

---
- name: Setup NGINX tanpa handler
  hosts: webservers
  become: true
  tasks:
    - name: Copy konfigurasi NGINX
      ansible.builtin.copy:
        src: files/nginx-custom.conf
        dest: /etc/nginx/nginx.conf
 
    - name: Restart NGINX
      ansible.builtin.service:
        name: nginx
        state: restarted

The without handler approach looks shorter and simpler, but there's a catch. Let's compare the behavior of both in the following table:

ScenarioWithout HandlerWith Handler
Configuration changesService restarts (correct)Service restarts (correct)
Configuration doesn't changeService still restarts (unnecessary downtime)Service not restarted
Playbook run 100x in CI100x service restarts0x restart (if no changes)
Task fails before the handlerNot relevant (restart always runs)Handler not executed (play fails)
Impact on productionRisk of repeated downtimeDowntime only when truly needed

In conclusion, handlers make the playbook behave according to the idempotency principle we learned in episode 5: the end result is the same (the service runs with the desired configuration), but the side effects are minimal.

Handler Rules & Limitations

Handlers aren't a magical feature without rules. Understanding their limitations is precisely what prevents confusion in the field. Here are the important rules you must memorize:

1. Handlers run at the end of the play (by default). Handlers don't run immediately after the notifying task finishes. Ansible waits until all tasks in the play are done, then runs the queued handlers. This is a deliberate design decision so that if many tasks queue the same handler, the service is only restarted/reloaded once.

2. A handler only runs once per play, no matter how many notifications it receives. If three tasks notify the Reload Nginx handler, the handler still only executes once at the end of the play. This is both an optimization and a behavior you must understand.

3. Handlers run in definition order, not notification order. The execution order of handlers follows their order of appearance in the handlers block, not the order of the notifying tasks. If handler B is defined before handler A, then B executes first.

4. Handlers are only triggered by tasks reporting changed. Tasks with ok status won't notify a handler. This is actually the behavior we want, since no change means no follow-up action needed.

5. If a task fails, already-queued handlers won't run. When a play fails, Ansible stops execution and the queued handlers are discarded. This is a safe default: you don't want to reload a broken configuration and make the service even more unstable.

Warning

For scenarios where you actually want a handler to run even if a later task fails (for example, when that handler is what restores the system to a good state), use the force_handlers: true option at the play level. Use it wisely, because it deviates from the default behavior, which is actually safer.

Forcing Handlers to Run Mid-Play: flush_handlers

Sometimes you need a handler to run before the play finishes. A real example: you change the NGINX configuration, then in the next task you want to immediately deploy an application that needs NGINX with the new configuration. If you wait until the end of the play, the deployed application could use the old configuration.

The solution is the ansible.builtin.meta module with the flush_handlers parameter. This module "forces" all queued handlers to execute right away:

playbook-flush-handlers.yml
---
- name: Install NGINX lalu flush handler di tengah play
  hosts: webservers
  become: true
 
  tasks:
    - name: Install Nginx
      ansible.builtin.apt:
        name: nginx
        state: present
        update_cache: true
 
    - name: Update konfigurasi NGINX
      ansible.builtin.copy:
        src: files/nginx-custom.conf
        dest: /etc/nginx/nginx.conf
      notify: Restart Nginx
 
    - name: Flush semua handler yang sudah diantrekan
      ansible.builtin.meta: flush_handlers
 
    - name: Deploy aplikasi yang butuh NGINX aktif
      ansible.builtin.copy:
        src: files/index.html
        dest: /var/www/html/index.html
        mode: "0644"
 
  handlers:
    - name: Restart Nginx
      ansible.builtin.service:
        name: nginx
        state: restarted

After the flush_handlers task finishes, the Restart Nginx handler has already executed, so the app deployment task below can proceed with the assumption that NGINX is already using the latest configuration.

Tip

Don't overuse flush_handlers. Flushing often is the same as throwing away the benefit of the "batching" Ansible was designed with. Use it only when execution order between tasks truly depends on the handler's effect, e.g., a service restart must finish before the next task.

Multiple Notifications to One Handler

A very common scenario in the real world is several tasks modifying different configuration files of the same service. For example, we change nginx.conf, add a new virtual host, and update the SSL configuration. All three changes require an NGINX reload.

With handlers, those three tasks just need to notify the same handler:

playbook-multi-notify.yml
---
- name: Update beberapa konfigurasi NGINX sekaligus
  hosts: webservers
  become: true
 
  tasks:
    - name: Update konfigurasi utama
      ansible.builtin.copy:
        src: files/nginx-custom.conf
        dest: /etc/nginx/nginx.conf
      notify: Reload Nginx
 
    - name: Update virtual host situs A
      ansible.builtin.copy:
        src: files/site-a.conf
        dest: /etc/nginx/sites-available/site-a.conf
      notify: Reload Nginx
 
    - name: Update konfigurasi SSL
      ansible.builtin.copy:
        src: files/ssl-params.conf
        dest: /etc/nginx/conf.d/ssl-params.conf
      notify: Reload Nginx
 
  handlers:
    - name: Reload Nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded

Notice we use state: reloaded instead of restarted. This aligns with the explanation at the beginning: for additive configuration changes, reload is much safer because it doesn't sever connections.

The interesting part: even though there are three tasks notifying the same handler, NGINX is only reloaded once, at the end of the play. This is highly desirable behavior, because reloading a service three times in a row within a single playbook execution is a pointless waste.

Pitfall: Handler Names and notify Must Match Exactly

Now we get to the common pitfalls section that most confuses beginners. The handler name in the handlers block and the value of the notify keyword must be exactly the same, including capitalization, spaces, and punctuation.

Look at this incorrect example:

playbook-salah.yml (nama tidak match)
---
- name: Playbook dengan nama handler tidak match
  hosts: webservers
  become: true
 
  tasks:
    - name: Update konfigurasi NGINX
      ansible.builtin.copy:
        src: files/nginx-custom.conf
        dest: /etc/nginx/nginx.conf
      notify: restart nginx      # huruf kecil, tidak match
 
  handlers:
    - name: Restart Nginx        # huruf besar, definisi handler
      ansible.builtin.service:
        name: nginx
        state: restarted

Because restart nginx (lowercase) doesn't match Restart Nginx (capitalized), the handler will never be triggered. Even on the latest Ansible versions, the playbook fails immediately with an error message:

Error saat handler tidak ditemukan
ERROR! The requested handler 'restart nginx' was not found in either the
main handlers list nor in the listening handlers list

Caution

On older Ansible versions (before 2.18), an unfound handler was silently skipped without an error. This is what makes this bug so dangerous: the playbook looks successful, but the restart never happens and the service keeps using the old configuration. On recent versions, this error appears explicitly so you're aware sooner. Always copy-paste the handler name from the handlers block to notify to avoid typos, and take advantage of ansible-lint to catch it earlier in CI/CD.

Still related to naming, there's a useful advanced tip. To make handlers more flexible, you can use the listen keyword on a handler. With listen, multiple handlers can "listen" to the same topic:

Menggunakan listen untuk nama yang fleksibel
  handlers:
    - name: Restart Nginx
      ansible.builtin.service:
        name: nginx
        state: restarted
      listen: "nginx config changed"
 
    - name: Notify tim monitoring
      ansible.builtin.uri:
        url: https://hooks.example.com/nginx-reloaded
        method: POST
      listen: "nginx config changed"

With this pattern, tasks just notify nginx config changed, and every handler "listening" to that topic gets triggered. This is very useful later when you start working with Ansible Roles in episode 12, because handler names inside roles have their own scope.

Conclusion

In episode 6, we learned that handlers are an important mechanism for keeping playbooks idempotent and production-safe. We understood the fundamental difference between regular tasks and handlers, where handlers only run when a task reports changed and notifies them. We also saw their rules and limitations: handlers run at the end of the play, only once for multiple notifications, and can be forced to run mid-play using ansible.builtin.meta: flush_handlers. Finally, we identified the most common pitfall — the mismatch between notify and handler names — along with its solution.

Key takeaways:

  • Restarting a service is an expensive operation that's only worth doing when the configuration actually changes.
  • notify in tasks + the handlers block at the play level are an inseparable pair.
  • Handlers wait until the end of the play, unless forced with flush_handlers.
  • Several tasks may notify the same handler, and it still runs once.

In episode 7, we'll cover an equally important topic: Variables & Facts. We'll learn how to make truly dynamic playbooks with variables, understand the precedence hierarchy from weakest to strongest, and leverage system facts (ansible facts) so that one playbook can run across different operating systems. This is a foundation that will be very useful when we enter episode 8 on Jinja2 templating. Keep your enthusiasm up!

Learn Ansible - Introduction and Explanation of Handlers | Learn Ansible