Skip to content
CloudOps
Newsletter
All guides
AI for Ansible By James Joyner IV · · 10 min read

Ansible Handlers Done Right: notify, listen, and flush_handlers

Use AI to fix Ansible handler logic with notify, listen, and flush_handlers so services restart only when they should, with every change human-reviewed.

  • #iac
  • #ansible
  • #ai
  • #handlers
  • #playbooks

The first time Ansible handlers truly bit me, it was 2 a.m. and I was staring at an nginx box serving config it had no business serving. I’d pushed a new vhost, the template task reported changed, and the playbook dutifully queued the restart nginx handler. Then a later task — a totally unrelated package install — failed. The play aborted. And because handlers only run at the end of a play by default, my restart never fired. nginx kept happily serving the old config in memory while the new file sat on disk, smug and useless. No error. No restart. Just a quiet lie.

The flip side is just as ugly. I’ve inherited playbooks that notify: restart postgres on a lineinfile task that “changes” every single run because somebody got the regex wrong. So Postgres bounced on every deploy, dropping connections for no reason. Handlers are one of Ansible’s best features and one of its most quietly misunderstood. Let me walk through how I actually use them now, and how I lean on AI to refactor the brittle restart logic I keep finding in the wild.

The mental model: handlers are deferred, deduplicated, and ordered

Three facts you must internalize before you touch a single handler:

  1. Handlers run at the end of the play, not where they’re notified.
  2. A handler runs once, no matter how many tasks notify it.
  3. Handlers run in the order they’re defined in the handler filenot the order they were notified.

That third one wrecks people. You notify reload app then restart db, but if your handler file lists restart db first, the DB restarts first. Ansible does not care about your notify sequence.

Here’s a clean tasks file that notifies properly:

# roles/web/tasks/main.yml
---
- name: "Deploy nginx site config"
  ansible.builtin.template:
    src: "site.conf.j2"
    dest: "/etc/nginx/conf.d/site.conf"
    owner: "root"
    group: "root"
    mode: "0644"
  notify: "reload nginx"

- name: "Deploy TLS certificate"
  ansible.builtin.copy:
    src: "files/site.pem"
    dest: "/etc/nginx/tls/site.pem"
    mode: "0600"
  notify: "reload nginx"

Two tasks, same handler. If both change, nginx still reloads exactly once. That dedup is the whole point.

Writing handlers that don’t surprise you

The matching handler file:

# roles/web/handlers/main.yml
---
- name: "reload nginx"
  ansible.builtin.service:
    name: "nginx"
    state: "reloaded"

- name: "restart nginx"
  ansible.builtin.service:
    name: "nginx"
    state: "restarted"

Prefer reloaded over restarted wherever the service supports it — a reload re-reads config without dropping live connections. Reach for restarted only when the service genuinely can’t hot-reload. I’ve watched AI-generated playbooks default to restarted everywhere because that’s the most common pattern in its training data. It is also the most disruptive. This is exactly the kind of thing a human catches and the model doesn’t.

Pro Tip: Name handlers after the effect, not the service — reload nginx, restart database. When you later fan one notify out to several handlers with listen, effect-based names keep the playbook readable instead of turning into a wall of restart-this, restart-that.

Fan one notify out to many handlers with listen

Here’s where handlers get genuinely powerful. Sometimes one change needs to trigger several actions. Instead of stuffing five handler names into a notify list, you notify a single topic and have multiple handlers listen for it:

# tasks
- name: "Update application bundle"
  ansible.builtin.unarchive:
    src: "app-{{ app_version }}.tar.gz"
    dest: "/opt/app"
    remote_src: false
  notify: "app deployed"
# handlers/main.yml
- name: "restart app worker"
  ansible.builtin.service:
    name: "app-worker"
    state: "restarted"
  listen: "app deployed"

- name: "restart app web"
  ansible.builtin.service:
    name: "app-web"
    state: "restarted"
  listen: "app deployed"

- name: "bust the CDN cache"
  ansible.builtin.command: "/usr/local/bin/cdn-purge.sh"
  listen: "app deployed"

One notify: "app deployed", three handlers fire. The task doesn’t need to know what “deployed” entails — that’s the role’s business. Add a fourth side effect later and you touch only the handler file. This is the decoupling that makes large roles maintainable, and it’s a refactor pattern I hand to AI constantly: “collapse these five notify lists into a single topic with listen handlers.” It’s mechanical, it’s tedious, and a fast junior engineer is great at exactly that — as long as I read every line of the diff afterward.

Forcing handlers to run mid-play with meta: flush_handlers

Remember my 2 a.m. nginx problem? The fix is meta: flush_handlers. It forces all pending handlers to run right now, instead of waiting for the end of the play:

- name: "Deploy critical security config"
  ansible.builtin.template:
    src: "hardening.conf.j2"
    dest: "/etc/app/hardening.conf"
    mode: "0644"
  notify: "restart app"

- name: "Flush handlers before the risky migration"
  ansible.builtin.meta: "flush_handlers"

- name: "Run database migration that might fail"
  ansible.builtin.command: "/opt/app/bin/migrate --apply"

Now the security config takes effect before the migration that might blow up the play. If you have an ordering dependency — “the service must be running new config before the next step” — flush_handlers is how you express it. Don’t sprinkle it everywhere, though; it defeats the dedup-at-end optimization and makes plays harder to reason about. Use it where correctness actually demands mid-play ordering.

When the play fails anyway: force_handlers

flush_handlers helps when you know the risky boundary. But what about an unexpected failure on some random host? By default, if a task fails, that host’s pending handlers are abandoned. force_handlers: true changes that — notified handlers run even when a later task errors out:

- name: "Configure and deploy the edge fleet"
  hosts: "edge"
  force_handlers: true
  roles:
    - "web"
    - "app"

This is the seatbelt for my original incident. With force_handlers: true, the failed package install would still have let nginx reload with the new config. You can also set it globally in ansible.cfg, but I prefer it at the play level so the intent is visible right where someone reads the playbook.

Pro Tip: force_handlers runs handlers on hosts that already failed — make sure those handlers are idempotent and safe on a half-configured box. A handler that assumes a file exists can turn one failure into two.

Check mode lies about handlers (and that’s fine)

Here is the gotcha that trips up everyone running dry runs. In check mode (--check), tasks report what would change but don’t actually change anything. So handlers, by default, do not run in check mode — there’s no real change to react to, and the handler’s own action is skipped too. Your --check --diff output will show the trigger task as changed, but you won’t see the restart happen.

ansible-playbook site.yml --check --diff --limit "staging"

That’s correct behavior, but you have to read it correctly: a green-on-paper check run does not prove your handler chain works end to end. I treat check mode as a config-diff preview, not a handler test. To actually validate handler wiring, I run against a real staging host and watch for the handler lines in the output. If you genuinely need a handler to evaluate during check runs, you can set check_mode: false on that specific handler — but think hard before you do, because now you’re letting a “dry run” perform a real action.

How I actually use AI on this — and where I stop

Handler refactors are perfect AI work: pattern-heavy, repetitive, low-creativity. I’ll paste a gnarly role into Claude and ask it to collapse duplicated notifies into listen topics, swap needless restarted for reloaded, and flag any task notifying a handler that doesn’t exist (a silent no-op Ansible won’t warn you about loudly). It’s a fast junior engineer. It is not a senior reviewer, and it is definitely not me.

So the rules are non-negotiable. Every AI change gets a human diff review — I read it like I’d read a junior’s PR. Every change runs through --check --diff against staging first, with the explicit understanding that check mode won’t exercise the handlers themselves, so I follow up with a real staging apply. And the model never, ever touches the vault. AI gets the playbook structure; it does not get --ask-vault-pass, it does not get decrypted secrets pasted into a prompt, and it does not get the keys. If you run your prompts through a reviewed library — the kind of thing I keep in our prompt packs and refine in the prompt workspace — you get the speed without handing over the crown jewels.

For anyone standardizing this across a team, the broader IaC category here has more on keeping AI-assisted infrastructure changes honest, and our automated code review dashboard is genuinely good at catching the “this restarts on every run” class of bug before it reaches production.

Conclusion

Handlers reward a little precision and punish hand-waving. Notify the effect, define handlers in the order you want them to run, use listen to fan out, reach for flush_handlers when ordering matters mid-play, and add force_handlers: true so a late failure doesn’t leave a service running stale config. Let AI do the tedious refactor — it’s fast and tireless — but read every diff, dry-run against staging knowing check mode won’t fire your handlers, and keep the vault keys to yourself. Done right, your services restart exactly when they should and never when they shouldn’t. Which, at 2 a.m., is the only thing you actually want.

Free download · 368-page PDF

Download the Free 500-Prompt DevOps AI Toolkit

500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.

  • 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
  • Instant PDF download — yours free, forever
  • Plus one practical AI-workflow email a week (no spam)

Single opt-in · unsubscribe anytime · no spam.