OPNsenseXLIII · Ansible-Driven Firewall ConfigurationInventory design
Ansible inventory for firewalls — static hosts, group_vars, host_vars, and dynamic discovery
What you'll learn
- Design an inventory that groups firewalls by environment and role so that plays can target the right slice
- Use group_vars and host_vars to store per-firewall and per-group overrides in a single source of truth
- Build a dynamic inventory plugin that pulls the firewall estate from the OPNsense API
- Recognise the trap of inventory sprawl and the discipline of a single source of truth
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
An Ansible inventory is the source of truth about which firewalls exist, what groups they belong to, and what variables apply to each. A fleet of OPNsense firewalls that is run through Ansible runs through this inventory — every play’s --limit, every role’s variable, every dynamic group references the data in inventory.yml and the group_vars/ and host_vars/ directories. The discipline that keeps a fleet governable is a well-shaped inventory: one that names hosts unambiguously, groups them by environment and role, supports per-firewall overrides without sprawl, and pulls from a single source of truth.
This lesson covers host naming, group structure, host_vars and group_vars for overrides, dynamic inventory via the API, and the trap of inventory sprawl.
The inventory file
The static inventory in YAML (inventory.yml) or INI (hosts.ini) declares hosts and groups. The default in modern Ansible is YAML because it scales better with deep nesting.
all:
children:
firewalls:
children:
dmz:
hosts:
fw-dc1-dmz-01:
ansible_host: 10.1.0.1
fw-dc1-dmz-02:
ansible_host: 10.1.0.2
edge:
hosts:
fw-dc1-edge-01:
ansible_host: 198.51.100.1
fw-dc2-edge-01:
ansible_host: 198.51.100.2
Three things to notice:
- Naming convention.
fw-<datacentre>-<role>-<index>. The naming is meaningful (datacentre + role + index), human-readable, and unambiguous at a glance. ansible_hostsets the IP or hostname Ansible uses to reach the firewall. If the name in the inventory matches the actual DNS name, this is unnecessary; if it does not (and it usually does not, because namefw-dc1-dmz-01may resolve to multiple IPs or be a CNAME of an internal service),ansible_hostoverrides it.- Group hierarchy.
firewallsis the global group;dmzandedgeare sub-groups. A play targetingfirewallsruns against every firewall; a play targetingdmzruns only against the DMZ firewalls.
$ ansible-inventory -i inventory.yml --list --yaml | head -40all:
children:
dmz:
hosts:
fw-dc1-dmz-01:
ansible_host: 10.1.0.1
fw-dc1-dmz-02:
ansible_host: 10.1.0.2
edge:
hosts:
fw-dc1-edge-01:
ansible_host: 198.51.100.1
hosts:
fw-dc1-dmz-01: {}
fw-dc1-dmz-02: {}
fw-dc1-edge-01: {}
[... truncated ...]
Illustrative output
Group structure that matches the firewall estate
The right group structure mirrors how the operator thinks about the estate:
- By environment (production, staging, dev, lab). The discipline: an automation that targets
productionnever accidentally touches a non-production firewall. - By role (edge, dmz, internal, vpn-terminator). Plays that manage one role target its group.
- By location (datacentre, region). Plays that run a site-specific maintenance target a location group.
The groups can be nested: firewalls contains production, which contains edge, which contains all the edge firewalls in production. A play can target any level — --limit edge matches every firewall in every environment that has edge. This is the flexibility, and the trap: an --limit that’s too broad runs against everything.
host_vars/ and group_vars/
Ansible reads additional variable files from directories named host_vars/ and group_vars/ relative to the inventory file. host_vars/<host>.yml defines variables for one host; group_vars/<group>.yml defines variables for every host in the group.
A reasonable production layout:
inventory.yml
group_vars/
firewalls.yml # applies to every firewall
edge.yml # applies only to edge firewalls
dmz.yml # applies only to dmz firewalls
host_vars/
fw-dc1-edge-01.yml # applies only to one firewall
Variable precedence: host_vars overrides group_vars overrides defaults. The strongest variable wins. A use case for host_vars: a single firewall that needs a one-off API key, a different collector endpoint, or a temporary override.
A typical group_vars/firewalls.yml:
ansible_connection: local
opnsense_api_key: "{{ vault_opnsense_api_key }}"
opnsense_api_secret: "{{ vault_opnsense_api_secret }}"
opnsense_api_port: 443
opnsense_ssl_verify: true
ansible_connection: local is the one that surprises people. The collection’s modules run on the controller and reach the firewall over its HTTPS API, so Ansible must not try to open a connection to the host at all — the host entry exists to give the play a name, an address, and a place to hang variables. There is no httpapi connection plugin for OPNsense and no ansible_network_os value for it; the address reaches the modules as an ordinary parameter.
The key and the secret are two halves of one credential, and both belong in the vault. The group_vars file holds only the references; the values live in a vault-encrypted sibling. A play then wires them into every module at once:
module_defaults:
group/ansibleguy.opnsense.all:
firewall: "{{ ansible_host }}"
api_key: "{{ opnsense_api_key }}"
api_secret: "{{ opnsense_api_secret }}"
api_port: "{{ opnsense_api_port }}"
ssl_verify: "{{ opnsense_ssl_verify }}"
ssl_verify defaults to true in the collection, and leaving it there is the right choice. Turning it off to get past a self-signed certificate discards the only assurance the controller has that it is configuring the firewall it thinks it is — on a credential that can rewrite the ruleset. Issue the firewall a certificate the controller trusts, or point ssl_ca_file at the internal CA that signed it.
$ ansible-vault encrypt host_vars/fw-dc1-edge-01.yml; cat host_vars/fw-dc1-edge-01.yml$ANSIBLE_VAULT;1.1;AES256
66386463633062336233376564313531393739343630356233663233666637396536366632316531
... (ciphertext only) ...
[exit success]Illustrative output
Dynamic inventory
For an estate that grows and shrinks — new firewalls provisioned, old ones decommissioned — the inventory file becomes stale quickly. A dynamic inventory plugin polls a source at run time and produces the inventory on the fly. For an OPNsense estate, the source is the API of a “controller” firewall (or another inventory source).
A custom dynamic inventory reads a list of firewalls from a config file or an external system and returns them in Ansible’s JSON format:
def inventory():
return {
'all': {
'children': {
'firewalls': {
'hosts': [
{'fw-dc1-edge-01': {'ansible_host': '198.51.100.1'}},
{'fw-dc1-dmz-01': {'ansible_host': '10.1.0.1'}},
]
}
}
}
}
The inventory is consumed by Ansible through inventory.py <plugin> or --list/ --host callbacks. The advantage: the firewall list is decoupled from the playbook repository. The disadvantage: a dynamic inventory is harder to audit at a glance than a static inventory.yml.
A hybrid is common: a static inventory for the known production estate, dynamic inventory for ephemeral firewalls. Two files: inventories/prod.yml (static) and inventories/ephemeral.yml (dynamic); the playbook chooses which to load.
The trap of inventory sprawl
A six-month-old inventory is a story worth reading:
- Hosts that nobody can account for.
- Variables that conflict (two
host_varsfiles for the same host). - Duplicated definitions (the same alias in two
group_varsfiles with different values). - Comments from operators who tried to maintain it and gave up.
The discipline: a small, structured inventory with clear naming and clear variables. Specifically:
- One inventory source per environment (prod, staging, lab). Three environments → three files.
- Group_vars and host_vars are short. A
group_varsfile longer than 200 lines is sprawl — the right response is to extract common patterns into a role and keep only firewall-specific config in inventory. - Variables go in
vars/, not inlined in playbooks. A play that starts withvars:is moving variables out of inventory. Variables belong in the inventory (or in a role’sdefaults/main.yml). - Comment and schema. Each
group_varsfile has a header explaining what the variables mean and what units/values they accept. Future operators will thank you.
Putting it together: a run
A typical run flow:
# 1. Confirm the inventory
ansible-inventory -i inventories/prod.yml --list
# 2. Run with --check on one canary
ansible-playbook -i inventories/prod.yml playbooks/firewall-rules.yml \
--limit fw-dc1-edge-01 --check --diff --ask-vault-pass
# 3. Review the diff, then commit if all looks well
# 4. Run on the canary for real
ansible-playbook -i inventories/prod.yml playbooks/firewall-rules.yml \
--limit fw-dc1-edge-01 --ask-vault-pass
# 5. Verify with --check again (should show zero changed)
ansible-playbook -i inventories/prod.yml playbooks/firewall-rules.yml \
--limit fw-dc1-edge-01 --check
# 6. Run on the remaining fleet in serial
ansible-playbook -i inventories/prod.yml playbooks/firewall-rules.yml \
--serial 2 --ask-vault-pass
Six steps, but they are the disciplined shape: validate the inventory, dry-run on a canary, run on the canary, verify idempotency, then serial-execute against the fleet. A playbook that skips steps runs against the fleet hoping for the best.
Summary
- The inventory is the source of truth: which firewalls exist, which groups they belong to, and what variables apply.
- A naming convention (
fw-<datacentre>-<role>-<index>) plus nested groups by environment and role makes the inventory readable and targetable. group_varsandhost_varsare the right place for per-group and per-host variables, but short. Vault-encrypted secrets live in dedicated files.- A dynamic inventory plugin is appropriate for ephemeral firewalls; static inventory is the production default.
- A clean inventory is structured, short, vault-encrypted where it has secrets, and run through
ansible-inventory --listbefore every fleet run.
Knowledge check · 4 questions
Q1. You need to apply a different alias set to edge firewalls than to DMZ firewalls. The cleanest inventory design is:
Q2. A host_vars file for a firewall can hold the plaintext API key as long as the host_vars directory is gitignored.
Q3. Which of the following contribute to inventory sprawl and should be avoided? Select all that apply.
Q4. You are about to run a playbook against the production estate. Which step is the most appropriate first action?
Passing score: 75%. Answers are checked in this browser.