Ansible Handlers: Triggering Service Restarts Only When Configuration Changes
Ansible Handlers: Triggering Service Restarts Only When Configuration Changes
If you've spent any time managing infrastructure with Ansible, you've probably run into this scenario: you're deploying a configuration change to nginx across 50 servers, and every single one of them restarts the service — even the ones where the configuration file didn't actually change. Your monitoring lights up, your on-call engineer wakes up angry, and you're left explaining why you restarted a perfectly healthy production service for no reason.
Handlers exist specifically to solve this problem. They're one of those Ansible features that seems simple on the surface but has enough depth to warrant a proper deep dive. This article is that deep dive.
What Are Handlers, Really?
At their core, handlers are tasks that only run when notified. They're defined separately from your main task list, and they execute at the end of a play — but only if something actually triggered them.
The key mental model here is event-driven execution. A regular task runs every time Ansible touches that host. A handler runs only when a preceding task reports a change. This maps beautifully to the real-world requirement: restart nginx only when the config actually changed.
Here's the simplest possible example:
---
- name: Configure nginx
hosts: webservers
tasks:
- name: Deploy nginx configuration
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: '0644'
notify: Restart nginx
handlers:
- name: Restart nginx
ansible.builtin.service:
name: nginx
state: restarted
The notify directive is the linchpin. When the template task reports changed: true (meaning Ansible actually wrote a new file), it queues the "Restart nginx" handler. If the file was already identical to what Ansible would have written, the task reports ok, and the handler never fires.
The Execution Model: When Handlers Actually Run
This is where a lot of people get tripped up. Handlers don't run immediately when notified — they run at the end of the play, after all tasks have completed. This has important implications.
---
- name: Configure application stack
hosts: appservers
tasks:
- name: Deploy app config
ansible.builtin.template:
src: app.conf.j2
dest: /etc/app/app.conf
notify: Restart app service
- name: Deploy systemd unit file
ansible.builtin.template:
src: app.service.j2
dest: /etc/systemd/system/app.service
notify:
- Reload systemd daemon
- Restart app service
- name: Verify config syntax
ansible.builtin.command: /usr/bin/app --config-check
changed_when: false
handlers:
- name: Reload systemd daemon
ansible.builtin.systemd:
daemon_reload: true
- name: Restart app service
ansible.builtin.service:
name: app
state: restarted
Notice that "Restart app service" gets notified by two different tasks. Here's the critical behavior: a handler only runs once, regardless of how many tasks notify it. Ansible deduplicates notifications. So even though both the config deployment and the systemd unit deployment notify the restart handler, the service restarts exactly once at the end of the play. This is correct and intentional behavior.
Handler Execution Order
Handlers execute in the order they're defined in the handlers section, not in the order they were notified. This matters enormously for the systemd example above — you need the daemon reload to happen before the service restart, and that ordering is determined by their position in the handlers list, not which task notified them first.
handlers:
# This runs first because it's defined first
- name: Reload systemd daemon
ansible.builtin.systemd:
daemon_reload: true
# This runs second
- name: Restart app service
ansible.builtin.service:
name: app
state: restarted
Get this ordering wrong and you'll restart a service that's still using the old systemd unit definition. Trust me, that's a fun one to debug at 2am.
Handlers Triggering Other Handlers: Listen Groups
Ansible provides a listen directive that lets multiple handlers subscribe to the same notification topic. This is powerful for building composable handler chains.
---
- name: Configure HAProxy
hosts: loadbalancers
tasks:
- name: Deploy HAProxy configuration
ansible.builtin.template:
src: haproxy.cfg.j2
dest: /etc/haproxy/haproxy.cfg
notify: haproxy config changed
- name: Deploy SSL certificates
ansible.builtin.copy:
src: "{{ item }}"
dest: /etc/haproxy/certs/
mode: '0600'
loop: "{{ ssl_certs }}"
notify: haproxy config changed
handlers:
- name: Validate HAProxy config
ansible.builtin.command: haproxy -c -f /etc/haproxy/haproxy.cfg
changed_when: false
listen: haproxy config changed
- name: Reload HAProxy
ansible.builtin.service:
name: haproxy
state: reloaded
listen: haproxy config changed
Both handlers listen to "haproxy config changed". When any task notifies that topic, both handlers run in definition order. This pattern is excellent for validation-before-restart workflows, which is something I insist on in any security-conscious pipeline.
Forcing Handlers to Run Mid-Play
Sometimes you need a handler to run before the play ends. The classic scenario is when you need a service to be running before subsequent tasks can execute against it.
---
- name: Deploy and configure PostgreSQL
hosts: dbservers
tasks:
- name: Install PostgreSQL
ansible.builtin.package:
name: postgresql
state: present
- name: Deploy postgresql.conf
ansible.builtin.template:
src: postgresql.conf.j2
dest: /etc/postgresql/14/main/postgresql.conf
notify: Restart PostgreSQL
- name: Flush handlers now
ansible.builtin.meta: flush_handlers
# These tasks need PostgreSQL to be running
- name: Create application database
community.postgresql.postgresql_db:
name: myapp
state: present
- name: Create application user
community.postgresql.postgresql_user:
name: myapp
password: "{{ vault_db_password }}"
state: present
The meta: flush_handlers task forces all pending handlers to run immediately at that point in the play. After the flush, the handler queue is empty — so if more tasks later notify the same handler, it would run again at the end of the play.
Handling Failures: The --force-handlers Flag and ANSIBLE_FORCE_HANDLERS
By default, if a task fails and the play aborts, pending handlers never run. This can leave your infrastructure in an inconsistent state — config files updated, but services not restarted to pick up the changes.
You can override this behavior:
# Command line flag
ansible-playbook deploy.yml --force-handlers
# Environment variable
ANSIBLE_FORCE_HANDLERS=true ansible-playbook deploy.yml
Or in your playbook:
---
- name: Deploy application
hosts: appservers
force_handlers: true
tasks:
# ... your tasks
I'd argue force_handlers: true should be your default for any deployment playbook that touches running services. The worst case without it is a service that's been partially reconfigured but hasn't been restarted — which is often worse than the task failing cleanly.
Real-World Pattern: The Rolling Restart
Here's where handlers really earn their keep in production environments. Let's build a complete nginx deployment pattern that handles configuration validation, graceful reloads vs restarts, and proper error handling.
---
- name: Deploy nginx configuration
hosts: webservers
serial: "25%" # Rolling update - 25% of hosts at a time
force_handlers: true
vars:
nginx_config_test_command: "nginx -t"
tasks:
- name: Deploy nginx main configuration
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: '0644'
validate: "nginx -t -c %s" # Validate before writing
notify: Reload nginx
- name: Deploy site configurations
ansible.builtin.template:
src: "{{ item.template }}"
dest: "/etc/nginx/sites-available/{{ item.name }}"
owner: root
group: root
mode: '0644'
loop: "{{ nginx_sites }}"
notify: Reload nginx
- name: Enable sites
ansible.builtin.file:
src: "/etc/nginx/sites-available/{{ item.name }}"
dest: "/etc/nginx/sites-enabled/{{ item.name }}"
state: link
loop: "{{ nginx_sites | selectattr('enabled', 'true') | list }}"
notify: Reload nginx
- name: Remove disabled sites
ansible.builtin.file:
path: "/etc/nginx/sites-enabled/{{ item.name }}"
state: absent
loop: "{{ nginx_sites | selectattr('enabled', 'false') | list }}"
notify: Reload nginx
handlers:
- name: Test nginx configuration
ansible.builtin.command: nginx -t
changed_when: false
listen: "Reload nginx"
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
listen: "Reload nginx"
The validate parameter on the template module is a first line of defense — it tests the config file before writing it. The handler then does a final nginx -t before the actual reload. Belt and suspenders.
Handlers in Roles: The Right Way to Structure Them
When you're working with roles, handlers live in roles/rolename/handlers/main.yml. This creates potential naming collision issues when multiple roles want to manage the same service.
Here's a production-grade role structure for a web application stack:
roles/
nginx/
handlers/
main.yml
tasks/
main.yml
templates/
nginx.conf.j2
certbot/
handlers/
main.yml
tasks/
main.yml
roles/nginx/handlers/main.yml:
---
- name: nginx | reload
ansible.builtin.service:
name: nginx
state: reloaded
listen: reload nginx
- name: nginx | restart
ansible.builtin.service:
name: nginx
state: restarted
listen: restart nginx
- name: nginx | test config
ansible.builtin.command: nginx -t
changed_when: false
listen:
- reload nginx
- restart nginx
roles/certbot/handlers/main.yml:
---
- name: certbot | reload nginx after cert renewal
ansible.builtin.service:
name: nginx
state: reloaded
listen: reload nginx
Wait — there's a problem here. Both roles define a handler that listens to "reload nginx". In Ansible, handlers with duplicate names across roles will only execute the first one defined. The listen mechanism helps avoid this, but you need to be deliberate.
The safer pattern for cross-role handler coordination is to use a dedicated common role for shared service handlers, or use fully qualified handler names with the role prefix:
# In roles/nginx/tasks/main.yml
- name: Deploy nginx.conf
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: nginx | reload
The rolename | action naming convention makes handler ownership explicit and prevents collisions.
Security-Conscious Handler Patterns
As someone who thinks about security first, I want to highlight some patterns that matter in secure environments.
Validate Before You Restart
Never restart a service based solely on the fact that a file changed. Always validate the configuration first:
handlers:
- name: Validate and reload sshd
block:
- name: Test sshd configuration
ansible.builtin.command: sshd -t
changed_when: false
- name: Reload sshd
ansible.builtin.service:
name: sshd
state: reloaded
listen: sshd config changed
For SSH daemon specifically, this is critical. A bad sshd config that causes a restart could lock you out of the entire fleet. The validation step is non-negotiable.
Audit Handler Executions
In regulated environments, you need to know what changed and when. Consider adding pre/post notification tasks:
handlers:
- name: Log service restart
ansible.builtin.lineinfile:
path: /var/log/ansible-handler.log
line: "{{ ansible_date_time.iso8601 }} - {{ inventory_hostname }} - nginx restart triggered"
create: true
listen: nginx restart event
- name: Restart nginx
ansible.builtin.service:
name: nginx
state: restarted
listen: nginx restart event
Avoid Handlers for Security-Critical Immediate Actions
Here's an opinionated take: for security incident response actions, don't use handlers. The delayed execution model is wrong for security contexts. If you're revoking a compromised certificate or blocking an IP, you want immediate execution, not "at the end of the play":
# BAD for security contexts - handler runs at end of play
- name: Revoke compromised cert
ansible.builtin.command: certbot revoke --cert-path /etc/ssl/compromised.pem
notify: Restart services # This is too slow for security incidents
# GOOD for security contexts - immediate execution
- name: Revoke compromised cert
ansible.builtin.command: certbot revoke --cert-path /etc/ssl/compromised.pem
- name: Immediately restart affected services
ansible.builtin.service:
name: "{{ item }}"
state: restarted
loop:
- nginx
- postfix
Common Pitfalls and How to Avoid Them
Pitfall 1: Handlers Don't Run on Skipped Tasks
If a task is skipped (due to when conditions), it doesn't notify its handler — even if the handler was previously notified by another task with the same name.
tasks:
- name: Deploy config (only in prod)
ansible.builtin.template:
src: app.conf.j2
dest: /etc/app/app.conf
when: env == 'production'
notify: Restart app
# If the above task is skipped, Restart app is NOT notified
This is usually the behavior you want, but it can surprise you when you're expecting a restart to happen based on a conditional deployment.
Pitfall 2: changed_when: false Prevents Handler Notification
This is a deliberate feature that catches people off guard. If you mark a task as changed_when: false, it will never notify its handlers, even if the underlying command made changes:
- name: Run database migrations
ansible.builtin.command: /app/manage.py migrate
changed_when: false # This means: NEVER notify handlers
notify: Restart app # This handler will NEVER fire
If you want conditional notification, use register and changed_when together:
- name: Run database migrations
ansible.builtin.command: /app/manage.py migrate
register: migration_result
changed_when: "'No migrations to apply' not in migration_result.stdout"
notify: Restart app # Now fires only when migrations actually ran
Pitfall 3: Handlers and --check Mode
In check mode, handlers are notified but they don't actually execute — which is correct and expected. However, this means check mode results for tasks that depend on handler execution can be misleading:
ansible-playbook deploy.yml --check
# Shows that nginx would be reloaded, but doesn't actually test if post-reload tasks would succeed
Always supplement check mode runs with integration tests for handler-dependent workflows.
Pitfall 4: The include_tasks Handler Problem
Handlers cannot use include_tasks. If you need complex handler logic, use a block or call a role:
# THIS DOESN'T WORK
handlers:
- name: Complex restart
ansible.builtin.include_tasks: restart_logic.yml # Error!
# THIS WORKS
handlers:
- name: Complex restart
block:
- name: Check if service exists
ansible.builtin.stat:
path: /etc/systemd/system/myapp.service
register: service_file
- name: Restart if service exists
ansible.builtin.service:
name: myapp
state: restarted
when: service_file.stat.exists
Pitfall 5: Duplicate Handler Names Across Plays
If you have multiple plays in one playbook, handlers are scoped to their play. A handler in play 1 cannot be notified by tasks in play 2:
- name: Play 1
hosts: webservers
handlers:
- name: Restart nginx # Scoped to Play 1
...
- name: Play 2
hosts: webservers
tasks:
- name: Deploy config
notify: Restart nginx # ERROR: This handler doesn't exist in Play 2's scope
Testing Handler Behavior
If you're not testing your handlers, you're flying blind. Here's how to validate handler behavior with Molecule:
# molecule/default/verify.yml
---
- name: Verify handler behavior
hosts: all
gather_facts: false
tasks:
- name: Check nginx is running after handler execution
ansible.builtin.service_facts:
- name: Assert nginx is active
ansible.builtin.assert:
that:
- ansible_facts.services['nginx.service'].state == 'running'
- ansible_facts.services['nginx.service'].status == 'enabled'
fail_msg: "nginx is not running - handler may not have executed correctly"
- name: Verify config was applied
ansible.builtin.command: nginx -T
changed_when: false
register: nginx_config
- name: Assert expected config directives are present
ansible.builtin.assert:
that:
- "'worker_processes auto' in nginx_config.stdout"
- "'keepalive_timeout 65' in nginx_config.stdout"
For testing that handlers only fire on changes, run your playbook twice and check idempotency:
# First run - should show changes and handler execution
ansible-playbook deploy.yml | grep -E "(changed|Running handlers)"
# Second run - should show no changes, no handlers
ansible-playbook deploy.yml | grep -E "(changed|Running handlers)"
# Should output nothing or show 0 changed
Complete Production Example: The Full Stack
Let me put everything together in a realistic production deployment scenario. This covers a web application with nginx, a Python application server, and Redis:
---
- name: Deploy web application stack
hosts: appservers
serial: 1
force_handlers: true
max_fail_percentage: 0
vars:
app_name: mywebapp
app_user: webapp
app_dir: /opt/mywebapp
pre_tasks:
- name: Remove from load balancer before deployment
community.general.haproxy:
state: disabled
host: "{{ inventory_hostname }}"
socket: /var/run/haproxy/admin.sock
delegate_to: "{{ groups['loadbalancers'][0] }}"
tasks:
# Application configuration
- name: Deploy application config
ansible.builtin.template:
src: app_config.py.j2
dest: "{{ app_dir }}/config/production.py"
owner: "{{ app_user }}"
group: "{{ app_user }}"
mode: '0600' # Secret config, restrictive permissions
notify: restart application
# Nginx configuration
- name: Deploy nginx site config
ansible.builtin.template:
src: nginx_site.conf.j2
dest: "/etc/nginx/sites-available/{{ app_name }}"
validate: "bash -c 'nginx -t -c /dev/stdin <<< \"events{} http{ include %s; }\"'"
notify: reload nginx
# Redis configuration
- name: Deploy Redis configuration
ansible.builtin.template:
src: redis.conf.j2
dest: /etc/redis/redis.conf
owner: redis
group: redis
mode: '0640'
notify: restart redis
# Systemd units
- name: Deploy application systemd unit
ansible.builtin.template:
src: mywebapp.service.j2
dest: /etc/systemd/system/mywebapp.service
notify:
- reload systemd
- restart application
- name: Flush handlers before verification
ansible.builtin.meta: flush_handlers
# Post-deployment verification
- name: Verify application health endpoint
ansible.builtin.uri:
url: "http://localhost:8080/health"
status_code: 200
timeout: 30
retries: 5
delay: 10
register: health_check
post_tasks:
- name: Return to load balancer after successful deployment
community.general.haproxy:
state: enabled
host: "{{ inventory_hostname }}"
socket: /var/run/haproxy/admin.sock
delegate_to: "{{ groups['loadbalancers'][0] }}"
when: health_check.status == 200
handlers:
- name: reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: restart application
ansible.builtin.service:
name: "{{ app_name }}"
state: restarted
listen: restart application
- name: verify application started
ansible.builtin.uri:
url: "http://localhost:8080/ready"
status_code: 200
retries: 10
delay: 5
listen: restart application
- name: validate nginx config
ansible.builtin.command: nginx -t
changed_when: false
listen: reload nginx
- name: reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
listen: reload nginx
- name: restart redis
ansible.builtin.service:
name: redis
state: restarted
This example demonstrates everything we've covered: listen topics for handler grouping, handler ordering for dependencies, flush_handlers for mid-play execution, validation before service actions, and force_handlers for resilience against task failures.
Summary and Key Takeaways
Handlers are one of Ansible's most elegant features when used correctly. Here's what to take away:
- Handlers run at the end of the play, not immediately when notified. Use
meta: flush_handlerswhen you need mid-play execution. - A handler runs at most once per play, regardless of how many tasks notify it. This is a feature, not a bug.
- Handler execution order is determined by definition order, not notification order. Be intentional about this.
- Use
listentopics for many-to-many relationships between tasks and handlers. - Always validate before restarting critical services like sshd, nginx, and database servers.
- Use
force_handlers: truein deployment playbooks to ensure handlers run even when tasks fail. - Name your handlers with a
role | actionconvention in large codebases to avoid collisions. - Test handler behavior explicitly — check both that they fire when they should, and that they don't fire when nothing changed.
The goal is idempotent infrastructure management: run your playbook once or a hundred times and end up with the same correctly-configured, minimally-disrupted system. Handlers are your primary tool for achieving that in the service management layer.
Get handlers right, and your infrastructure deployments stop being disruptive events and start being boring, routine operations. That's the goal.
Was this article helpful?
DevSecOps Lead
Security-first mindset in everything I ship. From zero-trust architectures to supply chain security, I make sure your pipeline doesn't become your weakest link.
Related Articles
Ansible Custom Filters: Transforming Data In Jinja2 Templates
If you've spent any serious time with Ansible, you've probably hit the wall where the built-in Jinja2 filters just don't cut it anymore. You're wrestling w...
Ansible Tags: Selective Task Execution For Faster Deployments
If you've ever run a full Ansible playbook just to push a single config change and watched it grind through 47 tasks before touching the one you care about...
Fix Ansible 'Permission Denied' on Become (Sudo)
Resolve Ansible become/sudo permission denied errors — fix sudoers config, passwordless sudo, and become method settings.
Fix Ansible 'Unreachable' Host Errors
Resolve Ansible unreachable host failures — diagnose SSH connectivity, key auth, Python interpreter, and timeout issues.
Ansible Dynamic Inventory: Automating Cloud Infrastructure
Use dynamic inventories to automatically discover and manage cloud infrastructure — AWS EC2, Azure VMs, and GCP instances with Ansible inventory plugins.
Ansible Fundamentals: Your First Playbook to Production
Learn Ansible from scratch — install Ansible, write your first playbook, understand modules, manage inventories, and automate server configuration.
More in Ansible
View all →Ansible Roles and Galaxy: Structuring Automation at Scale
Structure your Ansible automation with roles for reusability. Learn role directory structure, Galaxy usage, dependencies, and testing with Molecule.
Ansible Vault: Encrypting Secrets in Your Automation
Encrypt sensitive data with Ansible Vault — encrypt files, inline variables, multi-password setups, and integrate with external secret managers like HashiCorp Vault.