The Oxidized post records what the configs are. The Batfish post checks what a change would do. Neither one says what the network is supposed to look like. A source of truth does that. You record the intended state once, in a database, and your automation reads from it instead of from a spreadsheet or someone's memory.
I loaded my home lab into NetBox: two Cisco IOS XE 17.18 WAN routers and an IOS XE access switch. Then I had Ansible read its inventory straight from NetBox and push one piece of intent, the interface descriptions. Everything here was built in my home lab.

NetBox or Nautobot?
Both are free, open source and actively maintained, and Nautobot started life as a fork of NetBox. I checked both before picking one:
- NetBox v4.7.4, Apache-2.0. I ran it with the official netbox-docker project, version 5.1.1 (also Apache-2.0).
- Nautobot v3.2.8, Apache-2.0.
I went with NetBox for two reasons. First, the netbox.netbox Ansible collection (v3.23.1, GPL-3.0) is certified, and its dynamic inventory plugin is exactly the piece this post needs. Second, netbox-docker made it easy to run on a small box. Nautobot has an Ansible collection and a strong app ecosystem (Golden Config, for one), so it's a fair pick too. I didn't deploy Nautobot, so I can't give you a memory number for it.
Running NetBox
git clone -b release https://github.com/netbox-community/netbox-docker.git
cd netbox-docker
# docker-compose.override.yml: publish 127.0.0.1:8000:8080, set SUPERUSER_NAME/PASSWORD
docker compose -p pl-netbox up -dThe stack is five containers: NetBox, a background worker, PostgreSQL 18 and two Valkey 9.1 instances. Two things to know:
- First boot is slow. The database migrations took about 9 minutes, and the healthcheck reported the container as unhealthy until they finished. Give it time before you assume it's broken.
- Memory. Once idle with this small dataset,
docker statsshowed about 1.85 GiB in total: 1.4 GiB for NetBox itself, 259 MiB for the worker, 155 MiB for PostgreSQL and under 30 MiB for the two Valkey containers.
I created an API token for automation in the UI. NetBox 4.7 uses the v2 token format (they start with nbt_). Keep it out of your playbooks: export it as an environment variable.
Loading the lab into NetBox
You can click everything in by hand. I used the same collection to load it, so the source of truth can be rebuilt from Git:
ansible-galaxy collection install netbox.netbox
pip install pynetbox # 7.9.0, the collection needs it- name: Load the home-lab network into NetBox
hosts: localhost
gather_facts: false
module_defaults:
group/netbox.netbox.netbox:
netbox_url: http://127.0.0.1:8000
# netbox_token comes from the NETBOX_TOKEN environment variable
vars:
devices:
- {name: wan-rtr1, role: wan-router, type: ios-xe-virtual-router, mgmt: 10.66.20.11/24}
- {name: wan-rtr2, role: wan-router, type: ios-xe-virtual-router, mgmt: 10.66.20.12/24}
- {name: acc-sw1, role: access-switch, type: ios-xe-virtual-switch, mgmt: 10.66.20.21/24}
interfaces:
- {device: wan-rtr1, name: Ethernet0/1, type: 1000base-t, description: to wan-rtr2, ip: 10.0.12.1/30}
- {device: wan-rtr1, name: Ethernet0/2, type: 1000base-t, description: "LAN: to acc-sw1 Vlan10", ip: 10.0.10.1/24}
# ...and the rest
tasks:
- netbox.netbox.netbox_site:
data: {name: home-lab, slug: home-lab, status: active}
# manufacturer, device types, roles and platform the same way
- netbox.netbox.netbox_device:
data:
name: "{{ item.name }}"
device_type: "{{ item.type }}"
device_role: "{{ item.role }}"
platform: cisco-ios-xe
site: home-lab
status: active
loop: "{{ devices }}"
- netbox.netbox.netbox_device_interface:
data:
device: "{{ item.device }}"
name: "{{ item.name }}"
type: "{{ item.type }}"
description: "{{ item.description }}"
loop: "{{ interfaces }}"
# then IP addresses, and primary_ip4 set to the management addressOne gotcha: NetBox itself renamed the device field to role, but the netbox_device module rejected role in my run. It wanted device_role. If you're copying from the NetBox API docs, that's the one that bites.

Note the intent I put in: wan-rtr1's Ethernet0/2 should be described as LAN: to acc-sw1 Vlan10. On the router it was still to acc-sw1 | CHG-1042, left over from the change in the Oxidized post. That's the drift we want Ansible to fix.

NetBox as the Ansible inventory
No more hosts.yml. The inventory plugin asks NetBox for the devices and builds groups from their roles and platforms:
plugin: netbox.netbox.nb_inventory
api_endpoint: http://127.0.0.1:8000
validate_certs: false
interfaces: true
group_by:
- device_roles
- platforms
query_filters:
- site: home-lab
- status: active
compose:
ansible_network_os: "'cisco.ios.ios'"Each host gets ansible_host from its primary IP, and interfaces: true attaches the interface data, including descriptions, as host variables. Two things tripped me up:
- With only
NETBOX_TOKENexported, the plugin got "Permission denied". It worked once I exportedNETBOX_API_TOKENtoo, so I set both from the same file. - Group names keep the slug's hyphens, so the group is
platforms_cisco-ios-xe. Ansible warns about the characters, but it works.
The router credentials still live in an Ansible vars file (placeholder shown), not in NetBox:
ansible_user: <ROUTER_USER>
ansible_password: <ROUTER_PASSWORD>
ansible_connection: ansible.netcommon.network_cliPushing the intent
- name: Push interface descriptions from NetBox to the devices
hosts: platforms_cisco-ios-xe
gather_facts: false
tasks:
- name: Build the intended interface list from NetBox
ansible.builtin.set_fact:
intended: "{{ intended | default([]) + [{'name': item.name, 'description': item.description}] }}"
loop: "{{ interfaces | selectattr('description') | list }}"
loop_control: {label: "{{ item.name }}"}
- name: Merge descriptions onto the device
cisco.ios.ios_interfaces:
config: "{{ intended }}"
state: merged
register: push
- name: Show the commands
ansible.builtin.debug:
var: push.commands
when: push.changedI ran it with --check first. --diff didn't print anything for ios_interfaces, so the extra debug task is the useful part: it shows the exact commands that would go to the device. Then I ran it for real, and a second time to show nothing else changes:
export NETBOX_TOKEN=$(cat ~/.netbox-token) NETBOX_API_TOKEN=$NETBOX_TOKEN
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
Only wan-rtr1 needed a change, and only two commands went out: interface Ethernet0/2 and description LAN: to acc-sw1 Vlan10. The second run reported changed=0 on all three devices. I then triggered an Oxidized fetch for wan-rtr1 (curl http://A minute later, Oxidized picked up the new config and committed the difflt;oxidizedA minute later, Oxidized picked up the new config and committed the diffgt;:8888/node/next/wan-rtr1), and it committed the diff, so the change is recorded in the backup history too. I didn't save the change to the startup config; that's a separate step you'd add with ios_config: save_when: modified.
Limits and honest notes
- I only pushed interface descriptions. NetBox can hold VLANs, prefixes, IPs and config contexts, and you'd normally render full config sections from templates. Descriptions are a small, safe place to start, and they show the whole loop.
- The device types are generic (
ios-xe-virtual-router,ios-xe-virtual-switch) because these are virtual IOS XE devices, not a real hardware model from the device type library. state: mergedonly adds or changes. It won't remove a description that isn't in NetBox.replacedoroverriddenwill, but test them in check mode first.- NetBox doesn't push anything itself. If someone changes a device by hand, NetBox doesn't know. You find out from Oxidized, or the next time the playbook reports changed.
- I didn't measure Nautobot, so the memory comparison is one-sided.
What's next
That's the four pieces: Oxidized for history, Batfish for checks, NetBox for intent and Ansible to push. The last post in this series puts them together with the monitoring stack, Akvorado, Zabbix and Grafana, into one change workflow from start to finish.
Now live: one change through every tool in the series: NetBox, Batfish, Ansible, Oxidized, Zabbix, Loki and Akvorado.