NetBox as a Source of Truth for Ansible: Pushing Intent to IOS XE

I loaded my home lab into NetBox 4.7, used it as the live Ansible inventory, and pushed interface descriptions to IOS XE: check mode, apply, an idempotent re-run, and Oxidized recording the change.

NetBox as a Source of Truth for Ansible: Pushing Intent to IOS XE

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.

Lab diagram: an Ansible load playbook kept in Git fills NetBox 4.7.4 with the site, devices, interfaces and primary IPs. The nb_inventory plugin turns NetBox into the Ansible inventory, grouped by roles and platforms with interfaces as host variables. An intent playbook merges interface descriptions with ios_interfaces, runs in check mode to show the commands, applies them so only wan-rtr1 changes, and a re-run reports changed=0 on all three devices. Oxidized then records the diff as a Git commit, and a dashed orange loop back to NetBox notes that drift shows up there. A highlighted box shows wan-rtr1 Ethernet0/2 going from "to acc-sw1 | CHG-1042" on the router to the NetBox intent "LAN: to acc-sw1 Vlan10"; wan-rtr1 10.66.20.11, wan-rtr2 10.66.20.12 and acc-sw1 10.66.20.21 are listed. Callouts say the API token comes from an environment variable and that the module wants device_role, not role. A footnote says router credentials stay in Ansible vars, not NetBox, and NetBox uses about 1.85 GiB of RAM idle.
NetBox holds the intent, nb_inventory feeds Ansible, check mode shows the commands, and Oxidized records the diff. Only wan-rtr1 changed.

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

The 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 stats showed 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 address

One 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.

NetBox 4.7 device list showing wan-rtr1, wan-rtr2 and acc-sw1 in site home-lab with roles, device types and primary IPs
The three lab devices in NetBox, with role, device type and management IP.

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 interface list for wan-rtr1 showing the intended descriptions, including Ethernet0/2 LAN: to acc-sw1 Vlan10
wan-rtr1's interfaces and their intended descriptions in NetBox.

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_TOKEN exported, the plugin got "Permission denied". It worked once I exported NETBOX_API_TOKEN too, 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_cli

Pushing 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.changed

I 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
Ansible check run showing the commands for wan-rtr1 Ethernet0/2, the real run changing one device, an idempotent second run and the Oxidized git diff
Check, apply, re-run with changed=0, then the Oxidized commit that recorded it.

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: merged only adds or changes. It won't remove a description that isn't in NetBox. replaced or overridden will, 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.

Read next