This series built a set of open source tools, one at a time: Oxidized for config history, Batfish for offline checks and NetBox with Ansible for intent. Earlier posts covered monitoring: Zabbix, Grafana with Loki and Akvorado. Each one works on its own. The question for this last post is simple: what does one change look like when it goes through all of them?
I ran one real change, CHG-1045, from start to finish in my home lab: two Cisco IOS XE 17.18 WAN routers and an IOS XE access switch, with Linux running the tools. It's deliberately small. I renamed the description on wan-rtr1's WAN link, Ethernet0/1. That was enough to watch every tool react, and it's safe to run on a live network.

The stack and versions
Everything here is free and open source, and nothing needs a licence key:
- NetBox 4.7.4 (Apache-2.0): the intended state.
- Ansible: ansible-core 2.21.5 (GPL-3.0) with netbox.netbox 3.23.1 and cisco.ios 11.6.0. It reads the inventory from NetBox and pushes the change.
- Batfish (allinone image built 2026-08-27, Apache-2.0): checks the candidate config offline.
- Oxidized 0.37.0 (Apache-2.0): records what actually ended up on the device, in Git.
- Zabbix 7.0.32 (AGPL-3.0): SNMP monitoring and alerting.
- Grafana 13.0.2, Loki 3.7.9 (both AGPL-3.0) and Alloy v1.21.3 (Apache-2.0): syslog search.
- Akvorado v2026.10.1 (AGPL-3.0): NetFlow analytics on ClickHouse.
The AGPL licences only matter if you modify the code and offer it to others as a service. Running them internally is fine. I didn't have to skip any tool for licensing or lab reasons.
The workflow
- Intent. Change NetBox, not the router.
- Check. Build a candidate from the current backup and run it through Batfish.
- Push. Ansible check mode, then apply, then re-run to prove nothing else changes.
- Record. Oxidized commits the new running config.
- Observe. Loki shows the config event, and Zabbix and Akvorado pick up the new interface name.

1. Intent in NetBox
The change starts as data. I updated the interface in NetBox with the same collection the source-of-truth post used. Clicking it in the UI works just as well:
- name: CHG-1045 - intent change in NetBox
hosts: localhost
gather_facts: false
tasks:
- netbox.netbox.netbox_device_interface:
netbox_url: http://127.0.0.1:8000
netbox_token: "{{ lookup('env', 'NETBOX_TOKEN') }}"
data:
device: wan-rtr1
name: Ethernet0/1
description: "WAN: to wan-rtr2 AS65002 | CHG-1045"2. Check with Batfish
The base snapshot is Oxidized's latest copy of each device, so Batfish starts from what is really running, not what someone thinks is running. The candidate is the same files with the change applied:
for n in wan-rtr1 wan-rtr2 acc-sw1; do
git --git-dir=configs.git show HEAD:$n > cap-base/configs/$n.cfg
done
cp -r cap-base cap-cand
sed -i '/^interface Ethernet0\/1$/,/^!/ s/^ description .*/ description WAN: to wan-rtr2 AS65002 | CHG-1045/' \
cap-cand/configs/wan-rtr1.cfgbf.init_snapshot("/snap/cap-base", name="cap-base", overwrite=True)
bf.init_snapshot("/snap/cap-cand", name="cap-cand", overwrite=True)
bf.q.bgpSessionStatus().answer(snapshot="cap-cand")
bf.q.differentialReachability(headers=HeaderConstraints(dstIps="10.0.0.0/8")
).answer(snapshot="cap-cand", reference_snapshot="cap-base")### bgp sessions (cand)
Node Remote_Node Established_Status
wan-rtr1 wan-rtr2 ESTABLISHED
wan-rtr2 wan-rtr1 ESTABLISHED
### differentialReachability dst 10.0.0.0/8: 0 flows differThat's the expected answer for a description change: nothing about forwarding moves. One honest gap: I built the candidate with sed to match what NetBox would push. In a fuller setup you'd render the candidate from NetBox with the same templates Ansible uses, so the thing you test is exactly the thing you push. For bigger changes, that's the step that really earns its keep, as the Batfish post showed with a shadowed ACL line and a null route.
3. Push with Ansible
The playbook is the one from the NetBox post. NetBox supplies the inventory and the intended descriptions, and ios_interfaces merges them:
ansible-playbook -i nb_inventory.yml intent.yml --check --diff
ansible-playbook -i nb_inventory.yml intent.yml
ansible-playbook -i nb_inventory.yml intent.ymlCheck mode said only wan-rtr1 would change, with two commands: interface Ethernet0/1 and the new description. The real run changed exactly that, and the re-run reported changed=0 on all three devices. I didn't save to startup config; that's a separate, deliberate step.
4. Record with Oxidized
I triggered a fetch for wan-rtr1 rather than waiting for the next scheduled poll. Oxidized committed the diff about 30 seconds after the push:
63dd5ee 2026-11-03 16:31:14 +0000 update /wan-rtr1
- description to wan-rtr2
+ description WAN: to wan-rtr2 AS65002 | CHG-1045That commit is the closing record for the change ticket. It's also the base snapshot for the next Batfish run, which closes the loop.
5. Observe: Loki, Zabbix and Akvorado
Loki. The router logged the config change, and Alloy shipped it to Loki. In Grafana Explore it's one query:
{host="wan-rtr1"} |= "CONFIG_I"
This is the cross-check you want: the time matches the Ansible run, and the source is the automation host. A CONFIG_I from any other address, or at a time with no change ticket, is worth a look.
Zabbix. The Cisco SNMP template names interface items from ifAlias, so after interface discovery ran, the items were renamed on their own:
Interface Et0/1(WAN: to wan-rtr2 AS65002 | CHG-1045): Bits receivedThat discovery rule runs every hour by default. Mine had already picked up the change when I checked a few minutes later, but don't expect it to be instant.
Akvorado. Akvorado enriches flows with interface names and descriptions over SNMP. The flows through wan-rtr1 now show the new description, with traffic from the LAN going out the WAN link and the replies coming back:

A caveat: I had restarted Akvorado shortly before the change, so its SNMP cache was empty and it fetched the new description straight away. On a long-running collector, the old name stays until the cached entry is refreshed. The traffic is a synthetic load generator in the lab, not real users.
The descriptions are why this is more than cosmetic. Once they come from one place, the same name shows up in the backup diff, the Zabbix item, the flow table and the Grafana panel. Nobody has to translate "Et0/1 on rtr1" in the middle of an incident.
What this workflow doesn't do
- It isn't one product. Nine components across seven projects, each with its own UI, storage and upgrade cycle. For a small network, Oxidized plus Zabbix may be all you need. Add the rest when a real gap justifies it.
- The glue is manual here. I ran the steps by hand. In production you'd put steps 2 to 4 in a pipeline (see the Git pipeline post) and gate the push on Batfish passing.
- Memory adds up. NetBox used about 1.85 GiB idle, Batfish wants a few GB when it's working, and Akvorado's ClickHouse and Kafka want more. It ran fine on one lab server, but it's not a laptop stack.
- Batfish didn't parse everything. All three IOS XE configs were partly unrecognised (NetFlow, some SNMP and logging lines). The forwarding parts parsed, which is what this check relied on.
- Drift still needs a human. If someone edits a router by hand, Oxidized and Loki will show it, and the next Ansible run will report changed. Nothing here decides for you whether NetBox or the router is right.
The series
Config and change control, in order:
- Oxidized: Automatic Cisco IOS XE Config Backups to Git, with Real Diffs
- Batfish for Cisco IOS XE: Catch a Bad ACL and a Null Route Before They Go Live
- NetBox as a Source of Truth for Ansible: Pushing Intent to IOS XE
- This post: the full workflow.
The monitoring pieces used here:
- Add Cisco IOS XE Routers and Switches to Zabbix over SNMPv3
- Cisco IOS XE Syslog into Loki with Grafana Alloy
- Akvorado on Cisco IOS XE: NetFlow Analytics with ClickHouse
Now live: LibreNMS vs Zabbix on Cisco IOS XE, the same three devices in a second monitoring tool.