Why treat network configuration as code

In many network teams, a change still looks like this: an engineer opens an SSH (Secure Shell) session to the router, types commands, checks that things still work, and saves.

R1# configure terminal
R1(config)# interface GigabitEthernet0/1
R1(config-if)# description Uplink to ISP-B
R1(config-if)# ip address 203.0.113.2 255.255.255.252
R1(config-if)# no shutdown
R1(config-if)# end
R1# write memory

That works, and the command line will always be needed for troubleshooting. But as the only way to change a network it has well-known problems: a typo on the command line (CLI) goes straight into production, the same change is typed slightly differently on forty switches, nobody but the engineer saw it beforehand, and three months later no one knows who changed what, or why. The device's running configuration says what is, never what was intended.

Treating the configuration as code fixes those problems with the tools developers already use:

  • Consistency: configurations are generated from one template and data, so forty switches get exactly the same lines.
  • Review: a change is a pull request; a colleague reads the diff before anything touches a device.
  • History: git log and git blame answer who changed what, when and why (the commit message).
  • Rollback: the previous intended state is one git revert away.
  • Testing: a pipeline checks every change automatically before it is deployed.

NetDevOps is the name for applying these DevOps practices to networks: configuration in version control, automated testing and deployment through pipelines, and network and software engineers working the same way. It is a description of a practice rather than a standard, and most teams get there step by step: first nightly backups of running configurations into Git, then templates for new devices, then automated compliance checks, and only then automated deployment. The Network Automation page explains the building blocks (APIs, application programming interfaces; YAML; Ansible); this page is about the Git and pipeline side.

A repository for a network team

A layout that works well for an Ansible-based team keeps data, templates, playbooks and tests apart:

network-config/
├── .github/workflows/network-ci.yml
├── ansible.cfg
├── requirements.txt              pinned Python tools (ansible-core, netaddr, linters, pybatfish)
├── collections/requirements.yml  pinned Ansible collections (cisco.ios, ansible.utils)
├── inventory/
│   ├── production.yml            which devices exist, in which groups
│   ├── lab.yml
│   ├── group_vars/
│   │   ├── all/
│   │   │   ├── main.yml          NTP, syslog and DNS servers for every device
│   │   │   └── vault.yml         credentials, encrypted with Ansible Vault
│   │   └── access_switches.yml
│   └── host_vars/
│       ├── r1.yml                per-device data: hostname, interfaces, addresses
│       └── sw1.yml
├── templates/
│   ├── base.j2
│   └── access_ports.j2
├── golden/
│   └── base_ios.cfg              lines every IOS device must have
├── playbooks/
│   ├── site.yml
│   ├── pre_checks.yml
│   └── post_checks.yml
├── scripts/
│   ├── render.py
│   ├── compliance.py
│   └── vault-pass-from-env.sh    prints the Vault password from an environment variable
└── tests/
    └── batfish_checks.py
  • inventory/ lists the devices; Ansible automatically loads group_vars/ and host_vars/ that sit next to the inventory file.
  • templates/ holds the configuration templates; golden/ the compliance baseline.
  • playbooks/ holds what Ansible runs; tests/ and scripts/ what the pipeline runs.
  • Credentials are never in plain text: vault.yml is encrypted, and the vault password comes from the pipeline's secret store. A .gitignore keeps local files such as retry files and rendered output out of the repository.

The source of truth

Automation needs to know what the network should look like: which devices exist, their management addresses, which VLAN (virtual local area network) each access port belongs to, which prefixes are allocated where. A source of truth is the one authoritative place for that intended state; configurations are generated from it, and the live network is compared against it. If the source of truth and a device disagree, the device is wrong (or the source of truth must be updated first, through a reviewed change).

  • YAML files in Git (the host_vars and group_vars above) are the simplest source of truth: versioned, reviewable, and enough for a few dozen devices.
  • Dedicated tools such as NetBox and Nautobot model sites, racks, devices, cables, interfaces, IP addresses (IPAM, IP address management) and VLANs in a database with a web interface and an API. Automation reads its data from that API. They scale further and stop two spreadsheets from contradicting each other, at the cost of running another application.

Many teams combine them: NetBox or Nautobot for inventory and addressing, Git for templates, policies and the golden configuration. What matters is that every piece of data has exactly one authoritative home.

# inventory/host_vars/sw1.yml
hostname: SW1
mgmt_ip: 192.0.2.11/24
access_ports:
  - name: GigabitEthernet1/0/1
    description: Floor 2 desk 14
    vlan: 10
  - name: GigabitEthernet1/0/2
    description: Floor 2 phone 14
    vlan: 20

Configuration templates

A configuration template is a device configuration with placeholders and simple logic (loops, conditions) that is filled in with data from the source of truth to produce the actual configuration. The template holds the syntax and the team's standards; the data holds what differs per device. In the Ansible and Python world, templates are written in Jinja2. This is the Jinja2 template for the access ports in templates/access_ports.j2:

hostname {{ hostname }}
!
{% for port in access_ports %}
interface {{ port.name }}
 description {{ port.description }}
 switchport mode access
 switchport access vlan {{ port.vlan }}
 spanning-tree portfast
 no shutdown
!
{% endfor %}
interface Vlan99
 ip address {{ mgmt_ip | ansible.utils.ipaddr('address') }} {{ mgmt_ip | ansible.utils.ipaddr('netmask') }}
!

{{ … }} inserts a value, {% for %} repeats a block per port, and | passes a value through a filter (here: split 192.0.2.11/24 into address and mask). Jinja2 is a separate package; to show the same idea with only the standard library, the example below renders the data from sw1.yml with string.Template and the ipaddress module:

import ipaddress
from string import Template

ACCESS_PORT = Template("""\
interface $name
 description $description
 switchport mode access
 switchport access vlan $vlan
 spanning-tree portfast
 no shutdown
!""")

sw1 = {  # the data from inventory/host_vars/sw1.yml
    "hostname": "SW1",
    "mgmt_ip": "192.0.2.11/24",
    "access_ports": [
        {"name": "GigabitEthernet1/0/1", "description": "Floor 2 desk 14", "vlan": 10},
        {"name": "GigabitEthernet1/0/2", "description": "Floor 2 phone 14", "vlan": 20},
    ],
}


def render(device):
    lines = [f"hostname {device['hostname']}", "!"]
    for port in device["access_ports"]:
        if not 1 <= port["vlan"] <= 4094:
            raise ValueError(f"{port['name']}: invalid VLAN {port['vlan']}")
        lines.append(ACCESS_PORT.substitute(port))
    mgmt = ipaddress.ip_interface(device["mgmt_ip"])
    lines += ["interface Vlan99", f" ip address {mgmt.ip} {mgmt.netmask}", "!"]
    return "\n".join(lines)


print(render(sw1))
hostname SW1
!
interface GigabitEthernet1/0/1
 description Floor 2 desk 14
 switchport mode access
 switchport access vlan 10
 spanning-tree portfast
 no shutdown
!
interface GigabitEthernet1/0/2
 description Floor 2 phone 14
 switchport mode access
 switchport access vlan 20
 spanning-tree portfast
 no shutdown
!
interface Vlan99
 ip address 192.0.2.11 255.255.255.0
!

Note the check on the VLAN number: rendering is the first place where bad data can be caught, long before a device sees it. The output is a configuration file that can be reviewed in the pull request, compared with the golden configuration, analyzed by Batfish and pushed by Ansible. Commands like these are explained on the Switching and VLANs page.

Golden configuration and compliance

A golden configuration is the approved baseline that devices of a given role must match: the NTP (Network Time Protocol) and syslog servers, SSH-only management, a disabled HTTP (Hypertext Transfer Protocol) server, the login banner, the standard AAA (authentication, authorization and accounting) settings. A compliance check compares each device's actual configuration with it and reports the differences, which is how you find configuration drift: the change someone made by hand at 02:00 during an incident and never wrote down.

The core of a compliance check is a diff. Python's difflib produces the same unified format as git diff:

import difflib

golden = """\
service password-encryption
no ip http server
ip ssh version 2
ntp server 192.0.2.123
logging host 198.51.100.50
banner login ^C Authorized access only ^C""".splitlines()

running_r1 = """\
service password-encryption
ip http server
ip ssh version 2
ntp server 203.0.113.9
logging host 198.51.100.50
banner login ^C Authorized access only ^C""".splitlines()

diff = list(difflib.unified_diff(golden, running_r1, "golden/base_ios.cfg",
                                 "R1 running-config", lineterm="", n=0))
for line in diff:
    print(line)

missing = [line for line in golden if line not in running_r1]
print(f"R1: {'COMPLIANT' if not missing else 'NOT COMPLIANT'}, "
      f"{len(missing)} required line(s) missing")
--- golden/base_ios.cfg
+++ R1 running-config
@@ -2 +2 @@
-no ip http server
+ip http server
@@ -4 +4 @@
-ntp server 192.0.2.123
+ntp server 203.0.113.9
R1: NOT COMPLIANT, 2 required line(s) missing

R1 has its HTTP server switched on and points at the wrong NTP server (each documentation address stands in for a real one). Each - line is what the golden configuration requires, each + line what the device has instead. Real tools work the same way but understand the configuration's structure: IOS configurations are hierarchical (lines under interface belong to that interface), so serious compliance tools parse sections instead of comparing flat lines, and they support "must contain", "must not contain" and per-role rules. Nautobot's Golden Configuration app is one example. The fix for drift is either to correct the device or, if the manual change was right, to update the source of truth through a pull request.

The configuration lines themselves are covered on the networking pages: NTP and syslog on Cisco devices, and management plane security.

Ansible for network changes

Ansible is the most common tool for pushing configuration to network devices: agentless, connecting over SSH (for Cisco IOS with the ansible.netcommon.network_cli connection), with tasks written in YAML. Two pieces matter here.

The inventory

An Ansible inventory is the list of hosts Ansible manages, organized in groups, with the variables that say how to reach them. Groups let one playbook target "all access switches" or "all routers in Amsterdam":

# inventory/production.yml
all:
  children:
    routers:
      hosts:
        r1:
          ansible_host: 192.0.2.1
        r2:
          ansible_host: 192.0.2.2
    access_switches:
      hosts:
        sw1:
          ansible_host: 192.0.2.11
        sw2:
          ansible_host: 192.0.2.12
  vars:
    ansible_connection: ansible.netcommon.network_cli
    ansible_network_os: cisco.ios.ios
    ansible_user: netauto
    ansible_password: "{{ vault_netauto_password }}"   # defined in the encrypted vault.yml

Keeping a separate lab.yml inventory with lab devices lets the same playbooks run against a test network first. How the encrypted vault.yml works: vault variables.

The playbook and idempotence

This playbook renders the template from above for each access switch and applies it with the cisco.ios.ios_config module, which compares the rendered lines with the device's running configuration and sends only the lines that are missing:

# playbooks/access_ports.yml
---
- name: Configure access ports from the source of truth
  hosts: access_switches
  gather_facts: false
  tasks:
    - name: Apply rendered access-port configuration
      cisco.ios.ios_config:
        src: ../templates/access_ports.j2
        diff_against: running
        save_when: modified
ansible-playbook -i inventory/lab.yml playbooks/access_ports.yml --check -v   # dry run: which commands would be sent?
ansible-playbook -i inventory/lab.yml playbooks/access_ports.yml --diff       # apply, showing the changes

--check runs in check mode: ios_config works out which commands it would send without sending them; run with -v (or register the result) to see them in its updates return value. For ios_config, --diff only shows a diff when the task sets diff_against, as here with diff_against: running on a real run (that option does not work in check mode). save_when: modified copies the running configuration to the startup configuration only when they differ (save_when: changed would save only when this task changed something).

The property that makes this safe to automate is idempotence: applying the same operation once or many times gives the same result. The first run adds the missing lines and reports "changed"; a second run finds nothing to do and reports "ok", changing nothing. That is what lets a pipeline run the same playbook on every merge, and what makes a non-zero "changed" count on an unchanged repository a useful alarm: something on the device drifted. Not every module behaves this way: ios_command simply sends the commands it is given, every time, without comparing anything with the device's state, so it is meant for show commands; leave configuration to ios_config or the resource modules (such as ios_vlans and ios_interfaces). Idempotence, check mode and handlers for server playbooks: Playbooks and Idempotency.

Validating changes in a pipeline

Each check below catches a different kind of mistake, from cheapest to most thorough:

  • yamllint checks that every YAML file parses and follows layout rules: a wrong indent in host_vars is caught before Ansible reads it.
  • ansible-lint checks playbooks for deprecated syntax, missing names and risky patterns.
  • Rendering and compliance: render every device's configuration from the templates (failing on bad data, as in the example above) and compare it with the golden configuration.
  • Batfish: analyze the network as a whole before the change.
  • Lab or check-mode run: ansible-playbook --check -v against the lab, or against production in check mode, to see exactly which commands ios_config would send (in its updates return value).

Batfish: pre-change analysis

Batfish is an open-source network configuration analysis tool. It reads the configuration files of all devices, builds a model of the network (interfaces, routing, access lists, NAT: network address translation) without touching any device, and answers questions about it: can host 192.0.2.50 reach 198.51.100.10 on TCP (Transmission Control Protocol) port 443? Which ACL (access control list) line permits that? Are any BGP (Border Gateway Protocol) sessions configured on one side only? Does any interface have a duplicate IP address? Because it works on configuration files, a pipeline can run it on the candidate configurations from a pull request: the change is analyzed before it exists anywhere. Its Python client, pybatfish, turns those questions into assertions that fail the pipeline. Batfish runs as a server (a container) that the client talks to, and it is actively maintained as of 2026.

pyATS, Genie and Nornir

  • pyATS is Cisco's Python test framework for networks, and Genie its library of parsers and models. A typical use is a snapshot: "learn" the state of a feature (interfaces, routing, OSPF (Open Shortest Path First) neighbors) from live devices before and after a change and diff the two.
  • Nornir is a Python automation framework: instead of writing YAML playbooks, you write Python that runs tasks against an inventory in parallel. Teams that prefer code over YAML use it with libraries such as Netmiko or NAPALM to connect to devices.

A pipeline for the network repository

This GitHub Actions workflow validates every pull request and deploys merged changes after approval. Its syntax is explained on the GitHub Actions page:

# .github/workflows/network-ci.yml
name: network-ci

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  validate:
    runs-on: ubuntu-24.04
    timeout-minutes: 20
    services:
      batfish:
        image: batfish/allinone      # pin a specific tag or digest in real use
        ports:
          - "9996:9996"
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false
      - uses: actions/setup-python@v7
        with:
          python-version: "3.12"
          cache: pip
      - run: pip install -r requirements.txt
      - run: ansible-galaxy collection install -r collections/requirements.yml
      - run: yamllint .
      - run: ansible-lint
      - name: Render all device configurations
        run: python scripts/render.py --out build/configs
      - name: Compliance check against the golden configuration
        run: python scripts/compliance.py build/configs
      - name: Batfish pre-change analysis
        run: python tests/batfish_checks.py build/configs
      - uses: actions/upload-artifact@v7
        with:
          name: rendered-configs
          path: build/configs

  deploy:
    needs: validate
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: [self-hosted, network-mgmt]   # a runner inside the management network
    environment: network-production        # required reviewers approve here
    concurrency: network-production        # one network deployment at a time
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false
      - run: pip install -r requirements.txt
      - run: ansible-galaxy collection install -r collections/requirements.yml
      - name: Pre-checks
        run: ansible-playbook -i inventory/production.yml playbooks/pre_checks.yml
      - name: Deploy
        run: ansible-playbook -i inventory/production.yml playbooks/site.yml --diff --vault-password-file scripts/vault-pass-from-env.sh
        env:
          VAULT_PASSWORD: ${{ secrets.ANSIBLE_VAULT_PASSWORD }}   # environment secret
      - name: Post-checks
        run: ansible-playbook -i inventory/production.yml playbooks/post_checks.yml
  • The validate job needs no access to any device: it works entirely on files, so it can run on a GitHub-hosted runner for every pull request. Batfish runs next to it as a service container.
  • The rendered configurations are uploaded as an artifact, so reviewers can read the exact device configuration a pull request produces.
  • The deploy job runs only for pushes to main, on a self-hosted runner in the management network (devices should not be reachable from the internet), behind the network-production environment: required reviewers approve, and only then does the job receive the vault password.
  • The vault password arrives as an environment variable read by a small script, never written to disk or into the file.

Pre- and post-checks

A deployment that "succeeded" only means the commands were accepted. Pre- and post-checks verify that the network still does its job:

  • Before: record the state that must survive the change, such as interfaces that are up, routing neighbors, the number of routes, reachability of key addresses. If the network is already unhealthy, stop: deploying into an incident makes it worse and muddles the analysis.
  • After: collect the same state and compare. An OSPF neighbor that disappeared, an interface that went down or a route count that dropped by half fails the job, and someone is alerted while the change is still fresh.

In Ansible these are playbooks with ios_command or ios_facts and assert tasks; with pyATS they are before-and-after snapshots and a diff.

Rollback

If the post-checks fail, there are two levels of undo. In Git, git revert creates a new commit that restores the previous intended state, and the pipeline deploys it like any other change, with history intact. On the device, Cisco IOS can restore a saved configuration as a whole, which also removes lines that were added:

R1# copy running-config flash:pre-change.cfg
R1# configure replace flash:pre-change.cfg

Both commands ask for confirmation; in automation, use configure replace flash:pre-change.cfg force to skip the prompt.

The first command (run as a pre-check) saves the current configuration; the second replaces the running configuration with that file if the change must be undone. Because ios_config only adds and changes lines, reverting the Git commit alone does not always remove what the change added, so automated rollbacks often use a configuration replace like this one. Test the rollback route in the lab, not for the first time during an outage.

Network changes as standard changes

In most organizations a network change goes through change enablement, often with a change advisory board. A pipeline like the one above changes the risk picture: every change is peer-reviewed, linted, rendered, checked against the golden configuration, analyzed by Batfish, approved, deployed identically, verified by post-checks and recorded in Git and in the run log. Recurring changes of that kind, such as adding a VLAN to access ports or changing a port description, are good candidates to be pre-authorized as standard changes. That frees the change authority to spend its time on the changes that really need a discussion, like a new routing design. See also pipelines and change enablement.