> ## Content Index
> Fetch the complete content index at: https://www.pinglabz.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# One Change, Every Tool: NetBox, Batfish, Ansible, Oxidized, Zabbix, Loki and Akvorado
- URL: https://www.pinglabz.com/config-open-source-workflow-capstone/
- Published: 2026-11-03T16:37:00.000Z
- Updated: 2026-11-03T17:35:55.000Z
- Description: One real IOS XE change through a fully open source workflow: intent in NetBox, a Batfish check, an Ansible push, the Oxidized commit, then Loki, Zabbix and Akvorado all seeing it. With versions and honest limits.
- Author: Jaime
- Tags: Grafana, Monitoring, SNMP, Cisco IOS XE, Linux, Tools

This series built a set of open source tools, one at a time: [Oxidized](https://www.pinglabz.com/config-oxidized-git-backups/) for config history, [Batfish](https://www.pinglabz.com/config-batfish-offline-validation/) for offline checks and [NetBox with Ansible](https://www.pinglabz.com/config-netbox-source-of-truth/) for intent. Earlier posts covered monitoring: [Zabbix](https://www.pinglabz.com/zabbix-cisco-ios-xe-snmpv3/), [Grafana with Loki](https://www.pinglabz.com/grafana-loki-syslog-ios-xe/) and [Akvorado](https://www.pinglabz.com/flow-akvorado-ios-xe/). 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.

[![Lab diagram: change CHG-1045 moves through five stages. 1 Intent edits wan-rtr1 Et0/1 in NetBox 4.7.4; 2 Check runs Batfish, with 0 flows differing and BGP up; 3 Push uses Ansible check mode, apply and a re-run with changed=0; 4 Record is the Oxidized Git commit at 16:31 UTC, which feeds back to Check as the next base snapshot. Ansible pushes the new description, "WAN: to wan-rtr2 AS65002 | CHG-1045", to wan-rtr1, which feeds 5 Observe: syslog via Alloy into Loki and Grafana shows the CONFIG_I event, Zabbix 7.0.32 renames the item from SNMP ifAlias on its hourly discovery, and Akvorado v2026.10.1 shows the new interface name from NetFlow and SNMP. A strip shows the lab: acc-sw1 on Et0/2 (LAN: to acc-sw1 Vlan10) of wan-rtr1, and wan-rtr2 on Et0/1. Callouts say one name now shows up everywhere and the glue is manual here, so pipeline steps 2 to 4. A footnote gives times in UTC and NetBox at about 1.85 GiB idle.](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/diagram-topology-42.png)](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/diagram-topology-42.png?ref=pinglabz.com)

CHG-1045 from intent in NetBox to a Batfish check, an Ansible push and an Oxidized commit, then visible in Loki, Zabbix and Akvorado.

## 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

1. **Intent.** Change NetBox, not the router.
2. **Check.** Build a candidate from the current backup and run it through Batfish.
3. **Push.** Ansible check mode, then apply, then re-run to prove nothing else changes.
4. **Record.** Oxidized commits the new running config.
5. **Observe.** Loki shows the config event, and Zabbix and Akvorado pick up the new interface name.

![Terminal summary of CHG-1045: NetBox change, Batfish check, Ansible check, apply and re-run, Oxidized commit, and the Loki, Zabbix and Akvorado results](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/cap-chain.png)

CHG-1045 from start to finish, with times in UTC.

## 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.cfg
```

```
bf.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 differ
```

That'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.yml
```

Check 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-1045
```

That 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"
```

![Grafana Explore with a Loki query showing the SYS-5-CONFIG_I message from wan-rtr1 at 08:30:58](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/cap-loki.png)

The config change event from wan-rtr1, found in Loki through Grafana Explore.

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 received
```

That 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:

![Akvorado sankey for wan-rtr1 showing flows between LAN: to acc-sw1 Vlan10 and WAN: to wan-rtr2 AS65002 | CHG-1045](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/cap-akv.png)

Akvorado flows on wan-rtr1, labelled with the descriptions from NetBox.

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](https://www.pinglabz.com/ansible-git-pipeline-ci/)) 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:

1. [Oxidized: Automatic Cisco IOS XE Config Backups to Git, with Real Diffs](https://www.pinglabz.com/config-oxidized-git-backups/)
2. [Batfish for Cisco IOS XE: Catch a Bad ACL and a Null Route Before They Go Live](https://www.pinglabz.com/config-batfish-offline-validation/)
3. [NetBox as a Source of Truth for Ansible: Pushing Intent to IOS XE](https://www.pinglabz.com/config-netbox-source-of-truth/)
4. This post: the full workflow.

The monitoring pieces used here:

- [Add Cisco IOS XE Routers and Switches to Zabbix over SNMPv3](https://www.pinglabz.com/zabbix-cisco-ios-xe-snmpv3/)
- [Cisco IOS XE Syslog into Loki with Grafana Alloy](https://www.pinglabz.com/grafana-loki-syslog-ios-xe/)
- [Akvorado on Cisco IOS XE: NetFlow Analytics with ClickHouse](https://www.pinglabz.com/flow-akvorado-ios-xe/)

Now live: [LibreNMS vs Zabbix on Cisco IOS XE](https://www.pinglabz.com/obs-librenms-vs-zabbix/), the same three devices in a second monitoring tool.