Dynamic Oxidized Inventory from NetBox

Oxidized is the go-to tool for network configuration backups. It polls your devices over SSH, grabs the running config, and commits it to a Git repo. But out of the box, it expects a static CSV or YAML file listing your devices. Every time you rack a new switch or decommission an old one, someone has to update that file.

I replaced all of that with a NetBox API integration. Now Oxidized dynamically pulls its device inventory from NetBox and backs up configs to a Git repository with automatic pushes to GitLab.

The problem with static inventory

The default Oxidized setup uses a router.db file:

switch01:junos:ssh
switch02:junos:ssh
router01:junos:ssh

This works fine for a handful of devices. But in a growing datacenter environment:

  • New devices get deployed and nobody remembers to add them to the backup list
  • Decommissioned devices cause failed polls that clutter the logs
  • There’s no single source of truth — NetBox says one thing, the backup list says another
  • No metadata about device grouping, site, or role

The fix is obvious: NetBox already knows every device in the network. Oxidized should just ask it.

NetBox as the inventory source

Oxidized supports HTTP-based inventory sources natively. The config tells it to query the NetBox API for devices matching specific criteria:

source:
  default: http
  http:
    url: https://netbox.example.com/api/dcim/devices/?has_primary_ip=True&cf_backup_enable=True&limit=0
    scheme: https
    headers:
      Authorization: Token <netbox-api-token>
    pagination: false
    hosts_location: results
    map:
      name: name
      model: device_type.slug
      ip: primary_ip.address
      group: site.slug

Let’s break this down.

The query

The URL filters devices to only those that:

  • has_primary_ip=True — have a management IP assigned. No IP, no SSH, no point backing up.
  • cf_backup_enable=True — have a custom field backup_enable set to true. This is the opt-in flag. Not every device in NetBox needs to be backed up (PDUs, patch panels, etc.).
  • limit=0 — return all matching devices in a single response (no pagination).

The field mapping

Oxidized needs to know which JSON fields map to its internal model:

  • name: name — the device hostname from NetBox becomes the Oxidized node name
  • model: device_type.slug — the device type slug (e.g., qfx5120-48y-afi) is used to select the correct Oxidized model
  • ip: primary_ip.address — the management IP Oxidized connects to
  • group: site.slug — the site code (e.g., ams1, bru2) groups configs in subdirectories

Model mapping

NetBox device type slugs don’t match Oxidized model names directly. A model_map translates them:

model_map:
  juniper-junos: junos
  ex4100-48t: junos
  mx204: junos
  qfx5120-48y-afi: junos
  srx300: junos
  srx340: junos
  ex3400-24t: junos
  ex3400-48p: junos

Every Juniper device type maps to the junos model. If you add Cisco or Arista devices later, you’d add mappings like catalyst-9300: ios or dcs-7050: eos.

Git output with remote push

Configs are stored in a Git repository inside the container, and automatically pushed to GitLab after every successful poll:

output:
  default: git
  branch: master
  git:
    user: svc_oxidized
    email: noc@example.com
    single_repo: true
    repo: /home/oxidized/.config/oxidized/repos/configs.git

hooks:
  git_push:
    type: githubrepo
    events: [post_store]
    remote_repo: https://gitlab.example.com/networking/config-backups.git
    username: oauth2
    password: <gitlab-pat>

The post_store hook fires after every config commit. It pushes to the remote GitLab repo, giving you a full audit trail of every config change on every device — who changed what, when, with diffs.

The single_repo: true setting puts all device configs in one repository. The group field from NetBox (the site slug) creates subdirectories:

configs.git/
├── ams1/
│   ├── ams1leaf01.mgmt.example.com
│   ├── ams1leaf02.mgmt.example.com
│   └── ams1router01.mgmt.example.com
├── ams2/
│   ├── ams2leaf01.mgmt.example.com
│   └── ...
└── bru1/
    └── ...

Every file is the full device config, versioned by Git. git log on any file shows the complete change history.

Docker Compose setup

The whole thing runs in a single container:

services:
  oxidized:
    image: oxidized/oxidized:latest
    container_name: oxidized
    restart: unless-stopped
    ports:
      - "8888:8888"
    volumes:
      - ./config:/home/oxidized/.config/oxidized

The config/ directory contains the Oxidized config file with the NetBox source, model map, and Git output settings. The built-in web UI on port 8888 lets you browse nodes, view configs, and trigger manual polls.

The custom field trick

The key to making this work cleanly is the cf_backup_enable custom field in NetBox. It’s a boolean field on the Device model:

  • True — device is included in Oxidized’s inventory
  • False / unset — device is excluded

This means you can onboard a new device in NetBox, set backup_enable = true, and Oxidized picks it up on the next poll cycle (default: every hour). No config files to edit, no containers to restart.

It also means you can temporarily exclude a device — maybe it’s being reimaged or has known SSH issues — without removing it from NetBox.

The full Oxidized config

Here’s the complete config for reference:

---
interval: 3600
threads: 30
timeout: 20
retries: 2
prompt: !ruby/regexp /^([\w.@()-]+[#>]\s?)$/
username: svc_oxidized
password: <service-account-password>
extensions:
  oxidized-web:
    load: true
    listen: 0.0.0.0
    port: 8888
input:
  default: ssh
  ssh:
    secure: false
output:
  default: git
  branch: master
  git:
    user: svc_oxidized
    email: noc@example.com
    single_repo: true
    repo: /home/oxidized/.config/oxidized/repos/configs.git
source:
  default: http
  http:
    url: https://netbox.example.com/api/dcim/devices/?has_primary_ip=True&cf_backup_enable=True&limit=0
    scheme: https
    headers:
      Authorization: Token <netbox-api-token>
    pagination: false
    hosts_location: results
    map:
      name: name
      model: device_type.slug
      ip: primary_ip.address
      group: site.slug
model_map:
  juniper-junos: junos
  ex4100-48t: junos
  mx204: junos
  qfx5120-48y-afi: junos
  srx300: junos
  srx340: junos
  ex3400-24t: junos
  ex3400-48p: junos
hooks:
  git_push:
    type: githubrepo
    events: [post_store]
    remote_repo: https://gitlab.example.com/networking/config-backups.git
    username: oauth2
    password: <gitlab-pat>

What I learned

  • Let NetBox be the source of truth. Once you accept that NetBox knows your inventory, everything else follows. Oxidized, monitoring, DNS — they should all derive from the same source.
  • The model_map is essential. NetBox device type slugs are never going to match Oxidized model names. Build the map early and extend it as you add new device types.
  • Git push hooks are fragile. The githubrepo hook in Oxidized works but fails silently if credentials expire. Monitor your remote repo to make sure pushes are actually landing.
  • The custom field is the on/off switch. Having backup_enable as a boolean in NetBox gives you a clean way to include or exclude devices without touching Oxidized’s config at all.
  • secure: false is intentional. Oxidized’s SSH client will reject connections if host keys aren’t pre-loaded. In a lab or fast-moving environment where devices get reimaged regularly, disabling strict host key checking avoids constant manual intervention. In production, consider pre-populating known hosts instead.

What’s next

  • Webhook-triggered polls — have NetBox fire a webhook when a device is created or updated, triggering an immediate Oxidized poll instead of waiting for the next hour
  • Config diff notifications — send a Slack/Teams message when a device config changes, with a link to the GitLab diff
  • Multi-vendor expansion — the model map currently only covers Juniper. Adding Cisco IOS-XE and Arista EOS is just a matter of extending the map and testing the Oxidized models

The whole setup is a Docker Compose file and a single YAML config. The hardest part was getting the Oxidized HTTP source config right — the field mapping documentation is sparse. Once that clicked, everything else fell into place.