Network Automation with Git and CI/CD
Treating network configuration as code: configs and templates in Git, a source of truth, Ansible, and pipelines that validate network changes before they reach a device.
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 logandgit blameanswer who changed what, when and why (the commit message). -
Rollback: the previous intended state is one
git revertaway. - 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 loadsgroup_vars/andhost_vars/that sit next to the inventory file. -
templates/holds the configuration templates;golden/the compliance baseline. -
playbooks/holds what Ansible runs;tests/andscripts/what the pipeline runs. -
Credentials are never in plain text:
vault.ymlis encrypted, and the vault password comes from the pipeline's secret store. A.gitignorekeeps 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_varsandgroup_varsabove) 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_varsis 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 -vagainst the lab, or against production in check mode, to see exactly which commandsios_configwould send (in itsupdatesreturn 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
validatejob 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
deployjob runs only for pushes tomain, on a self-hosted runner in the management network (devices should not be reachable from the internet), behind thenetwork-productionenvironment: 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.