← blog · 2026-09-23
Ansible for Networks: Config State You Can Diff
Why Ansible for networking is IaC, not scripted CLI — config as declared state, and a baseline you can diff instead of chase.
tags: automation, ansible, networking, iac
The problem: everyone’s source of truth is a show run
I’ve been using Ansible at work to get a handle on a handful of campus networks with branch offices attached to them. We’ve had Ansible in the datacenter for a while — nobody there blinks at “configuration as code” anymore. Campus, though, got left behind, and it shows.
Here’s what that looks like in practice: the “source of truth” for any given switch is whatever’s currently running on it. If you want to know what changed last month, you ask around. If you’re lucky, someone remembers. There’s one shared local admin account that’s been passed down like a family recipe, changes get pushed over SSH by whoever’s on call, and there’s no record connecting a change to a person, a ticket, or a reason. Drift isn’t an edge case, it’s the default state. Two switches that are supposed to be identical branch closets slowly grow apart, one config line at a time, until nobody’s sure which one is “correct” anymore — assuming either still is.
None of this is a skills problem. It’s an ownership problem, and ownership problems get fixed by making the state explicit somewhere other than the device itself.
Why Ansible for networking (and why “IaC” actually matters here)
It’s tempting to think of Ansible-for-network as “a way to run CLI commands from a script instead of typing them.” That’s not wrong, but it undersells what you get, and it’s the wrong mental model to build habits around.
The real shift is this: instead of describing a sequence of commands to run, you describe the state you want the device to end up in, and you let the module figure out the diff. That’s the actual meaning of “infrastructure as code” for a switch — not that the config lives in Git (though it should), but that you’ve stopped thinking in verbs and started thinking in nouns.
Compare these two mental models:
Scripted CLI (imperative):
configure terminal
interface GigabitEthernet1/0/12
switchport access vlan 20
description Branch-Printer
exit
Run this twice and you might get an error on the second run, or a harmless no-op, or — if the interface config has since diverged — something you didn’t expect. The script doesn’t know what’s already there. It just knows what to type.
Declared state (Ansible, ios_config):
- name: Ensure access port config on Gi1/0/12
cisco.ios.ios_config:
lines:
- switchport access vlan 20
- description Branch-Printer
parents: interface GigabitEthernet1/0/12
Run this once, run it a hundred times — the outcome is the same, because the module checks current state before it decides whether to push anything. That’s idempotence, and it’s the whole point. You’re not scripting a change, you’re declaring a target and letting the tool close the gap. If the gap is already closed, nothing happens, and you find out nothing happened.
That distinction is the difference between “automation that saves typing” and automation you can actually trust to run unattended.
Design: inventory, a baseline, and a shape you can reuse
Before any playbook, you need two things sorted: how devices are grouped, and what “correct” means for each group.
Inventory first. Group by role, not by building:
[campus_access]
sw-branch01-ac1 ansible_host=10.20.1.11
sw-branch02-ac1 ansible_host=10.20.2.11
[campus_dist]
sw-branch01-dist1 ansible_host=10.20.1.2
[campus:children]
campus_access
campus_dist
[campus:vars]
ansible_network_os=cisco.ios.ios
ansible_connection=network_cli
Then a baseline — the boring stuff every access switch in the fleet should agree on: NTP servers, logging hosts, AAA/RADIUS config, banner, SNMP location string, maybe a default VLAN policy. This is the part that used to drift silently, and it’s the highest-leverage thing to templatize first, precisely because it’s boring and nobody wants to hand-verify it across forty closets.
A minimal playbook shape:
- name: Enforce campus access baseline
hosts: campus_access
gather_facts: no
tasks:
- name: Baseline NTP servers
cisco.ios.ios_config:
lines:
- ntp server 10.10.10.10
- ntp server 10.10.10.11
- name: Baseline RADIUS
cisco.ios.ios_config:
lines:
- radius server RAD-PRIMARY
- address ipv4 10.10.20.5 auth-port 1812 acct-port 1813
- key {{ radius_key }}
Variables (like radius_key) live in group_vars/campus.yml, vaulted. Nothing device-specific is hardcoded into the task — the task describes policy, the inventory + vars supply the specifics.
Build: the baseline role, and diffing before you commit
The actual unit of work I built out first was a campus_baseline role — NTP, logging, AAA/RADIUS, banner, SNMP. Nothing exciting, which is exactly why it was the right starting point: low risk, high drift-potential, easy to verify.
The loop that makes this trustworthy is: template → check_mode → diff → apply.
ansible-playbook baseline.yml --check --diff --limit sw-branch01-ac1
--check tells you what would change without touching the device. --diff shows you the actual config lines that would be added or removed. This is the step that turns “I hope this playbook does what I think” into “I can see exactly what it’s about to do before it does it” — which, if you’re pushing to production closet switches during business hours, matters more than almost anything else in the workflow.
Once the diff looks right, drop --check and run it for real. Same playbook, same inventory, no rewriting.
Gotcha worth knowing up front: on IOS, ios_config sometimes reports changed: true even when the resulting config is functionally identical — line ordering, whitespace, or a re-sent key line that the device can’t tell you it already has (because it’s encrypted on the device and Ansible has no way to compare it) will trip a false positive. Don’t take changed at face value on your first few runs; read the actual diff. You can tighten this up with match: line vs match: none settings and by being deliberate about which lines you send, but expect some noise before you dial it in.
Tradeoffs: Ansible converges, it doesn’t react
It’s worth being honest about what this doesn’t do. Ansible is not a control loop. It doesn’t watch the network and correct drift the moment it happens — it converges state at the moment you run it, and then it’s done thinking about that device until you run it again. If someone hand-jams a change five minutes after your playbook runs, Ansible has no idea until the next run.
It’s also overkill for a lot of day-to-day work. If you need to check one switch’s interface counters right now, SSH-ing in and typing show interface is faster than writing a task, running a playbook, and parsing the output. Ansible earns its keep on repeatable, declared, fleet-wide state — not on “quick, what’s the status of this one port.”
Those two limitations point at the same thing: Ansible answers “what should this be,” on your schedule. It’s not the tool for “what is this, right now.”
Results
The visible output of all this is unglamorous but real: a Git repo where the campus baseline lives as YAML, with commit history showing who changed the RADIUS key and when, instead of a Slack message nobody can find six months later. Onboarding a new branch switch stopped being “SSH in and copy-paste from the switch next to it” and became “add it to inventory, run the playbook.” One push, one diff review, and forty access switches agree with each other again.
That’s the actual win — not “we automated typing,” but “we made the intended state a thing that exists outside of any single switch’s running-config.”
Reproducing this
I built and broke all of this against an EVE-NG lab running on a Proxmox cluster before touching anything real — cheap way to find out that your ios_config line ordering assumptions are wrong before you find out on a live switch. If you want to follow along, standing up a small IOS/IOS-XE topology in EVE-NG and pointing an Ansible control node at it will get you 90% of the way to the real thing, minus the part where a bad diff takes down a building.
Takeaway, and what’s next
Ansible answers one question well: what should the network be? It’s a declared-state tool, and once you stop treating it as “CLI-in-a-loop” and start treating it as config-as-data, the diffing and idempotence stop feeling like bonus features and start feeling like the entire point.
What it doesn’t answer is: what is the network, right now? That’s a different problem — live state, not declared state — and it’s what I’m covering next, using Netmiko to pull real-time facts instead of enforcing a target config.