OPNsenseXLIII · Ansible-Driven Firewall ConfigurationPlaybook design
Ansible playbook shape — pre-flight, change, post-flight, and the anti-lockout gates
What you'll learn
- Design a playbook with pre-flight, change, and post-flight phases that catches both anti-lockout risk and partial-fleet failure
- Use handlers and flush_handlers to control when service reloads actually happen
- Structure roles and tasks for reuse across fleets and playbooks
- Run a playbook safely across a serial batch of firewalls, with a canary first
Prerequisites
Verified against OPNsense 25.x · FreeBSD 14.x · PF (FreeBSD packet filter) FreeBSD 14.x · Unbound 1.20+ · Kea DHCP OPNsense 25.x plugin · WireGuard in-kernel + OPNsense plugin · strongSwan (IPsec plugin) OPNsense 25.x plugin · OpenVPN 2.6.x · Suricata 7.x · 2026-08-14
A firewall playbook is not a one-shot script. It is a four-phase workflow: pre-flight checks that confirm the firewall is reachable and the change is safe to apply; the change tasks themselves; post-flight verification that the firewall’s desired state matches the playbook; and a final guarantee that nothing has been left in a partial state. Each phase catches a different class of failure, and skipping any one of them gives the operator a false sense of safety.
This lesson covers the four-phase shape, handlers for controlling service reloads, role-and-task-file structure for reuse, and a worked playbook that adds a rule to a single firewall and then a serial fleet.
The four phases
A production firewall playbook has four phases, in order:
- Reach. Confirm the firewall is reachable over the API and authentication works. An unreachable firewall or a wrong API key fails the play before any change is attempted.
- Pre-flight. Confirm the change is safe to apply. Check the existing rules for an anti-lockout rule that would be displaced; check the rule order to confirm the new rule will land in the expected position; check for conflicting rules. A pre-flight failure aborts before any change.
- Change. Apply the desired state via the Ansible modules. Each task is idempotent (search-then-add-or-set internally); the play reports
changedfor tasks that actually modified something. - Verify. Read the state back and confirm the desired state is present. A verification failure signals a partial change; the rollback path is to revert to the configuration revision recorded before the change began.
The phases map to plays in the playbook:
- name: Reach — API connectivity
hosts: firewalls
connection: local
gather_facts: false
module_defaults:
group/ansibleguy.opnsense.all:
firewall: "{{ ansible_host }}"
api_key: "{{ opnsense_api_key }}"
api_secret: "{{ opnsense_api_secret }}"
ssl_verify: true
tasks:
- name: Confirm the API credentials authenticate
ansible.builtin.uri:
url: "https://{{ ansible_host }}/api/core/firmware/status"
method: GET
url_username: "{{ opnsense_api_key }}"
url_password: "{{ opnsense_api_secret }}"
force_basic_auth: true
status_code: 200
- name: Pre-flight — read the current rules
hosts: firewalls
connection: local
gather_facts: false
tasks:
- name: List the automation rules
ansibleguy.opnsense.list:
target: rule
register: current_rules
- name: Fail if the change ticket already has rules on this firewall
ansible.builtin.assert:
that:
- current_rules.data | selectattr('description', 'search', 'CHG-2026-1314') | list | length == 0
fail_msg: "CHG-2026-1314 rules already exist here; re-running would need a review first"
- name: Change — apply the desired state
hosts: firewalls
connection: local
gather_facts: false
tasks:
- name: Ensure outbound allow for CI runners
ansibleguy.opnsense.rule:
description: "CHG-2026-1314 outbound"
match_fields: ['description']
action: pass
interface: ['lan']
direction: in
ip_protocol: inet
protocol: any
source_net: "{{ ci_runners_alias }}"
destination_net: any
log: true
reload: false
notify: Reload the filter
- name: Verify — confirm the rule is present
hosts: firewalls
connection: local
gather_facts: false
tasks:
- name: Re-read the automation rules
ansibleguy.opnsense.list:
target: rule
register: applied_rules
- name: Assert exactly one rule carries the ticket
ansible.builtin.assert:
that:
- applied_rules.data | selectattr('description', 'search', 'CHG-2026-1314') | list | length == 1
handlers:
- name: Reload the filter
ansibleguy.opnsense.reload:
target: rule
Three details in that skeleton do real work.
connection: local is mandatory. The modules run on the controller and reach the firewall over its API; without it Ansible tries to SSH into the firewall and execute Python there, which is not how these modules work.
module_defaults under group/ansibleguy.opnsense.all sets the connection parameters once for every module in the collection, rather than repeating firewall, api_key and api_secret on every task. The credentials come from vault-encrypted variables — never from the playbook.
reload: false on the change task, paired with a handler that reloads once, is the batching decision. Left at its default the module applies after every write, so a play with eight rule tasks reloads the ruleset eight times.
Each play targets the same group with a different intent. The reach play can also be run alone (ansible-playbook reach.yml) as a sanity check before any change is queued.
$ ansible-playbook -i inventories/prod.yml playbooks/firewall-rules.yml --limit fw-dc1-edge-01 --check --diff --ask-vault-passPLAY [Reach — API connectivity] ***********************************************
TASK [Confirm the API credentials authenticate] *******************************
ok: [fw-dc1-edge-01]
PLAY [Pre-flight — read the current rules] ************************************
TASK [List the automation rules] **********************************************
ok: [fw-dc1-edge-01]
TASK [Fail if the change ticket already has rules on this firewall] ***********
ok: [fw-dc1-edge-01]
PLAY [Change — apply the desired state] ***************************************
TASK [Ensure outbound allow for CI runners] ***********************************
--- before
+++ after
@@ -1,1 +1,8 @@
-{}
+{
+ "action": "pass",
+ "description": "CHG-2026-1314 outbound",
+ "destination_net": "any",
+ "interface": ["lan"],
+ "source_net": "ci_runners"
+}
changed: [fw-dc1-edge-01]
PLAY [Verify — confirm the rule is present] ***********************************
TASK [Re-read the automation rules] *******************************************
ok: [fw-dc1-edge-01]
TASK [Assert exactly one rule carries the ticket] *****************************
failed: [fw-dc1-edge-01]
PLAY RECAP ********************************************************************
fw-dc1-edge-01 : ok=4 changed=1 unreachable=0 failed=1
Illustrative output
Handlers and flush_handlers
A handler is a task that runs only when notified by a change. In a firewall playbook the handler is the reload, and the reason to use one is that every write module in the collection applies by default — reload: true — so a play with eight rule tasks rebuilds and reloads the ruleset eight times unless you say otherwise.
tasks:
- name: Ensure outbound allow for CI runners
ansibleguy.opnsense.rule:
description: "CHG-2026-1314 outbound"
match_fields: ['description']
source_net: "{{ ci_runners_alias }}"
reload: false
notify: Reload the filter
handlers:
- name: Reload the filter
ansibleguy.opnsense.reload:
target: rule
Handlers run at the end of the play, which is usually what you want: all the writes happen, then one reload. ansible.builtin.meta: flush_handlers forces pending handlers to run at a chosen point instead — for example after the rule tasks but before a verification play that needs the rules to be live.
The rollback shape is block/rescue, and the important part is what the rescue does. Aliases and rules reload separately, so a change-set that touches both notifies both handlers:
tasks:
- block:
- name: Ensure the CI runners alias
ansibleguy.opnsense.alias:
name: ci_runners
type: host
content: "{{ ci_runner_addresses }}"
reload: false
notify: Reload the aliases
- name: Ensure outbound allow for CI runners
ansibleguy.opnsense.rule:
description: "CHG-2026-1314 outbound"
match_fields: ['description']
source_net: ci_runners
reload: false
notify: Reload the filter
- name: Apply both reloads now
ansible.builtin.meta: flush_handlers
rescue:
- name: Revert to the configuration revision recorded before the change
ansibleguy.opnsense.raw:
module: core
controller: backup
command: revertBackup
parameters: ["{{ pre_change_revision }}"]
action: post
- name: Re-fail so the caller sees the failure
ansible.builtin.fail:
msg: "Change-set failed and was reverted to {{ pre_change_revision }}"
Two things make this a rollback rather than a stop. The revision was recorded before the block began — GET /api/core/backup/backups/this returns the history newest first, and the first entry is the state to return to. And the rescue re-fails after reverting, so the run reports failure rather than quietly succeeding on a reverted host.
The raw module is doing the revert because the collection has no module for the backup controller. That is exactly what raw is for: it takes the module, controller and command as parameters and issues the API call, with none of the idempotency the other modules provide — which is acceptable here because reverting to a named revision is a one-shot action, not a desired state.
Roles and task files
A playbook that defines five roles, each implementing a firewall area (rules, aliases, NAT, interfaces, monitoring), is the production shape. A role is a directory of tasks, defaults, handlers, and templates; the playbook imports it:
- name: Change — apply all configuration areas
hosts: firewalls
connection: local
gather_facts: false
roles:
- role: fw_outbound_rules
vars:
rule_ticket: CHG-2026-1314
- role: fw_security_aliases
vars:
alias_name: blocked_ips
- role: fw_monitoring
The role directory:
roles/
fw_outbound_rules/
tasks/main.yml
defaults/main.yml
handlers/main.yml
fw_security_aliases/
tasks/main.yml
A role’s tasks/main.yml holds the ansibleguy.opnsense.* tasks, with defaults from defaults/main.yml that the playbook overrides per role with vars:. Role names are plain identifiers — dots in a role name are not namespacing and will confuse Ansible’s collection resolution. The discipline is one role per configuration area, owned by one team.
Serial batches across a fleet
A multi-firewall playbook uses serial to limit concurrency:
- name: apply rule group to all firewalls
hosts: firewalls
serial: 1
...
The serial: 1 runs the play against one firewall at a time, in inventory order. A failure on the first host halts the play; the operator verifies manually before continuing. The argument at the top of the play (ansible-playbook --limit fw-canary-01) runs only the canary first; a subsequent run uses --limit edge --serial 2 to run two firewalls at a time in batches.
The pattern with max_fail_percentage:
- hosts: firewalls
serial: 5
max_fail_percentage: 20
Up to 20% of a batch of 5 may fail (one firewall) before the play halts. The discipline: pick a percentage that catches the “most things are broken, stop before more are broken” case but tolerates a single bad host.
Worked walkthrough: a complete playbook
The canonical playbook for “add a rule across the estate, safely”:
reach.yml— confirm every firewall is reachable over the API. Run as the first step.change-firewalls.yml— the change playbook with a single role. Block: stage the writes, flush handlers. Rescue: revert to the recorded configuration revision.verify-firewalls.yml— re-run the change playbook in--checkmode. Expected: zero changes (the desired state is already in place).
Three files. Three commands:
ansible-playbook -i inv/prod.yml reach.yml --ask-vault-pass
ansible-playbook -i inv/prod.yml change-firewalls.yml --serial 1 --ask-vault-pass
ansible-playbook -i inv/prod.yml change-firewalls.yml --check --ask-vault-pass
The final check-mode run should report zero changes everywhere. If any host reports changed, the previous change did not apply, and the operator investigates before declaring success.
Summary
- The four phases — reach, pre-flight, change, verify — catch different classes of failure and form a production-discipline playbook.
- Every write module applies by default. Set
reload: falseand reload once from a handler;flush_handlersanchors that reload at a chosen point. The block/rescue structure is the rollback path. - Plays set
connection: localand put the connection parameters inmodule_defaultsundergroup/ansibleguy.opnsense.all. Roles give reuse: one role per configuration area, owned by one team. serialandmax_fail_percentagebound the blast radius. A failed host is investigated manually before re-running.- The playbook shape that ships in production: reach → change (with rescue) → verify (re-run for zero changes). Three files, three commands, one disciplined shape.
Knowledge check · 4 questions
Q1. You want to add five firewall rules atomically — either all five apply or none do. Which playbook structure is correct?
Q2. By default, handlers run at the end of the play and batch — five tasks that each notify: reload filter will trigger only one reload at the end.
Q3. Which of the following are characteristics of a production-discipline firewall playbook? Select all that apply.
Q4. You run the change playbook with `serial: 2` against eight firewalls. The first firewall of the first batch fails midway through the change phase. What should you do?
Passing score: 75%. Answers are checked in this browser.