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.

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.
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:
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.
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:
changed: true and has the notify keyword, Ansible will queue the notified handler.The key point to underline: handlers are only triggered by tasks reporting changed. Tasks with ok status (no change) won't trigger handlers.
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:
ansible-nginx-handler/
├── inventory.yml
├── files/
│ └── nginx-custom.conf
└── playbook-nginx-handler.ymlThen fill inventory.yml with the webservers group as we learned in episode 3:
---
all:
children:
webservers:
hosts:
web01:
ansible_host: 10.10.10.11
ansible_user: devopsNow, 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:
---
- 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: restartedLet's break down the playbook above line by line:
ansible.builtin.apt module. This task is idempotent, meaning on the second run it reports ok because the package is already installed./etc/nginx/nginx.conf. Notice the notify: Restart Nginx keyword below it.changed (because the file content differs from what's on the server), the Restart Nginx handler gets queued.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 HANDLER OutputNow let's run the playbook:
ansible-playbook -i inventory.yml playbook-nginx-handler.ymlWhen run for the first time (when the configuration has never existed on the server), the output will look roughly like this:
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:
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=0See 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.
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: restartedThe without handler approach looks shorter and simpler, but there's a catch. Let's compare the behavior of both in the following table:
| Scenario | Without Handler | With Handler |
|---|---|---|
| Configuration changes | Service restarts (correct) | Service restarts (correct) |
| Configuration doesn't change | Service still restarts (unnecessary downtime) | Service not restarted |
| Playbook run 100x in CI | 100x service restarts | 0x restart (if no changes) |
| Task fails before the handler | Not relevant (restart always runs) | Handler not executed (play fails) |
| Impact on production | Risk of repeated downtime | Downtime 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.
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.
flush_handlersSometimes 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:
---
- 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: restartedAfter 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.
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:
---
- 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: reloadedNotice 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.
notify Must Match ExactlyNow 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:
---
- 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: restartedBecause 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! The requested handler 'restart nginx' was not found in either the
main handlers list nor in the listening handlers listCaution
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:
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.
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:
notify in tasks + the handlers block at the play level are an inseparable pair.flush_handlers.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!