DevOpsil

Ansible Handlers: Triggering Service Restarts Only When Configuration Changes

Amara OkaforAmara Okafor15 min read

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_handlers when 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 listen topics for many-to-many relationships between tasks and handlers.
  • Always validate before restarting critical services like sshd, nginx, and database servers.
  • Use force_handlers: true in deployment playbooks to ensure handlers run even when tasks fail.
  • Name your handlers with a role | action convention 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.

Share:

Was this article helpful?

Amara Okafor
Amara Okafor

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

More in Ansible

View all →

Discussion