Network automation sounds like something reserved for hyperscalers with dedicated developer teams, but the reality is much more modest and much more useful. If you have ever logged into twenty switches one by one to check a VLAN, copy a running config or add an NTP server, you already have an automation use case. This guide walks you through the exact path that works for most network admins: a working Python script with Netmiko, a first Ansible playbook using the cisco.ios collection, a clean inventory, and a Git workflow that keeps every change reviewable and reversible.
Why automate, and where to start
The best reason to automate is not speed. It is consistency. When a human configures forty access switches by hand, small differences creep in: a missing logging host line here, a typo in a banner there, an NTP server that was decommissioned two years ago but still lives on half the fleet. Those differences are invisible until something breaks, and then they turn a ten-minute fix into an afternoon of investigation. Automation turns your standards into code, and code does the same thing every time.
The second reason is that automation forces you to understand your network as data. To write a playbook you need an inventory, which means you need to know what devices you have, what role each plays and how to reach them. That is the same knowledge good documentation needs, which is why automation and the practices in our network documentation guide reinforce each other.
Pick read-only tasks first
Your first automation projects should not change anything. Read-only tasks build confidence and deliver value immediately, with almost zero risk:
- Back up running configurations from every device every night.
- Collect software versions and serial numbers for an inventory or support-contract review.
- Check interface error counters across the whole access layer in one run.
- Audit whether every device has the correct NTP, syslog and SNMP settings.
- Find which switch port a MAC address lives on by searching every MAC address table.
Once those run reliably, move to low-risk changes such as adding an NTP server or updating a login banner, and only then to changes that affect forwarding, such as VLANs or access control lists.
Setting up a safe automation workstation
Automation code should run from a predictable environment, not from whatever Python happens to be installed on your laptop. A Linux machine, a small virtual machine, or Windows Subsystem for Linux all work well. The key habit is to use a Python virtual environment per project so that library versions do not collide.
mkdir net-automation && cd net-automation
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install netmiko ansible-core ansible-pylibssh
ansible-galaxy collection install cisco.ios ansible.netcommon
pip freeze > requirements.txt
Netmiko installs its dependencies automatically, including Paramiko for SSH and the TextFSM templates used to parse show commands into structured data. The ansible-core package gives you the Ansible engine, while the cisco.ios and ansible.netcommon collections provide the modules and the network_cli connection plugin. The ansible-pylibssh package is optional but gives network_cli a faster SSH transport than the Paramiko fallback.
Prepare the devices
Your devices need SSH enabled, a reachable management address, and an account for automation. Avoid a shared “automation” account if you can; per-user accounts authenticated through TACACS+ or RADIUS mean every change is logged against a real person. In a lab, a local user is fine:
hostname acc-sw1
ip domain name lab.example.com
crypto key generate rsa modulus 2048
ip ssh version 2
username netadmin privilege 15 secret Use-A-Strong-Secret
line vty 0 15
login local
transport input ssh
Your first Netmiko script
Netmiko is a Python library that handles the tedious parts of talking to network devices over SSH: logging in, detecting the prompt, entering enable mode, disabling paging and waiting for output to finish. You give it a dictionary describing the device and it gives you a connection object with methods such as send_command() and send_config_set().
The script below reads a list of devices from a CSV file, connects to each one, collects the software version, saves the running configuration to a timestamped file, and keeps going if one device fails. Create a devices.csv file first:
host,role
192.0.2.1,core
192.0.2.11,access
192.0.2.12,access
Then save the following as backup.py:
#!/usr/bin/env python3
"""Back up running configs and report IOS versions for devices in devices.csv."""
import csv
import getpass
import os
from datetime import datetime
from pathlib import Path
from netmiko import ConnectHandler
from netmiko.exceptions import (
NetmikoAuthenticationException,
NetmikoTimeoutException,
)
USERNAME = os.environ.get("NET_USER") or input("Username: ")
PASSWORD = os.environ.get("NET_PASS") or getpass.getpass("Password: ")
BACKUP_DIR = Path("backups")
BACKUP_DIR.mkdir(exist_ok=True)
def load_devices(path):
with open(path, newline="") as f:
return list(csv.DictReader(f))
def backup_device(host):
device = {
"device_type": "cisco_ios",
"host": host,
"username": USERNAME,
"password": PASSWORD,
"secret": PASSWORD,
}
with ConnectHandler(**device) as conn:
conn.enable()
hostname = conn.find_prompt().rstrip("#>")
parsed = conn.send_command("show version", use_textfsm=True)
config = conn.send_command("show running-config")
version = "unparsed"
if isinstance(parsed, list) and parsed:
version = parsed[0].get("version", "unknown")
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
(BACKUP_DIR / f"{hostname}_{stamp}.cfg").write_text(config)
return hostname, version
def main():
failures = []
for row in load_devices("devices.csv"):
host = row["host"]
try:
hostname, version = backup_device(host)
print(f"OK {host:15} {hostname:20} {version}")
except (NetmikoTimeoutException, NetmikoAuthenticationException) as exc:
print(f"FAIL {host:15} {type(exc).__name__}")
failures.append(host)
if failures:
print(f"\n{len(failures)} device(s) failed: {', '.join(failures)}")
if __name__ == "__main__":
main()
What the script is doing
A few details are worth understanding rather than copying blindly. The device_type value tells Netmiko which driver to use; cisco_ios covers both IOS and IOS-XE. The with block guarantees the SSH session closes even if a command fails, which matters because many devices limit the number of concurrent VTY sessions. The use_textfsm=True argument asks Netmiko to parse the output with a TextFSM template; when a template exists you get a list of dictionaries, and when it does not you get the raw string back, which is why the script checks the type before reading the version.
Error handling is the difference between a demo and a tool. Without the try/except, one unreachable switch would stop the entire run and leave you guessing which devices were backed up. Catching the specific timeout and authentication exceptions lets the script finish and report exactly which hosts need attention.
Run it with credentials in environment variables so you are not typing them every time:
export NET_USER=netadmin
read -s NET_PASS && export NET_PASS
python backup.py
Making a change with Netmiko
Pushing configuration is just as simple. The send_config_set() method enters configuration mode, sends each line, and exits again; save_config() writes the running configuration to startup:
commands = [
"ntp server 192.0.2.123",
"logging host 192.0.2.60",
]
with ConnectHandler(**device) as conn:
conn.enable()
output = conn.send_config_set(commands)
print(output)
conn.save_config()
This works, but notice what it does not do: it does not check whether the lines are already present, it does not show you a diff before applying, and it does not tell you whether anything actually changed. You could build all of that yourself, and many engineers do. For standard configuration tasks, though, Ansible already provides it.
From script to tool: habits that scale
Before moving on to Ansible, it is worth adopting a few habits that will make every script you write more trustworthy. None of them require advanced Python.
- Separate data from logic. Device lists, NTP servers and VLAN definitions belong in CSV or YAML files, not hardcoded in the script. When the data changes, the code should not need to.
- Log what you did. Print or write a log line for every device: success, failure and what changed. When someone asks next month why a switch has a new syslog server, you want an answer.
- Add a dry-run flag. A simple
--dry-runargument that prints commands instead of sending them catches mistakes before they reach a device. - Parallelize carefully. A thread pool with five to ten workers turns a fifteen-minute serial backup into a two-minute job, but do not hammer TACACS+ servers or slow control planes with hundreds of simultaneous logins.
- Prefer structured output. Parsing raw text with string slicing breaks the first time a vendor changes a column width. TextFSM templates, or APIs that return JSON, are far more robust.
git diff between two days shows exactly what someone changed on a switch, even if they forgot to tell anyone.Ansible for network devices: inventory and variables
Ansible is an agentless automation engine. For servers it typically copies Python modules to the target and runs them there, but network devices cannot run Python, so network modules execute on your workstation (the control node) and talk to devices over SSH or an API. That is why network playbooks use a special connection type and why the ansible_network_os variable matters: it tells Ansible which platform plugins to load.
Project layout
A small, conventional layout keeps everything predictable:
net-automation/
├── ansible.cfg
├── inventory/
│ ├── hosts.yml
│ └── group_vars/
│ └── ios/
│ ├── vars.yml
│ └── vault.yml
├── playbooks/
│ ├── backup.yml
│ └── baseline.yml
├── backup.py
├── devices.csv
└── requirements.txt
The ansible.cfg file tells Ansible where the inventory is, so you do not need to pass -i on every run:
[defaults]
inventory = inventory/hosts.yml
retry_files_enabled = False
interpreter_python = auto_silent
[persistent_connection]
command_timeout = 60
The inventory
The inventory lists your devices and groups them. Groups are where the power comes from: you can target all access switches, a single site, or every IOS device with one word. YAML inventories are easier to read than the older INI format once you have nested groups:
all:
children:
ios:
children:
core:
hosts:
core-sw1:
ansible_host: 192.0.2.1
access:
hosts:
acc-sw1:
ansible_host: 192.0.2.11
acc-sw2:
ansible_host: 192.0.2.12
Connection settings and shared data live in group_vars. Every host in the ios group inherits these values:
# inventory/group_vars/ios/vars.yml
ansible_network_os: cisco.ios.ios
ansible_connection: ansible.netcommon.network_cli
ansible_user: "{{ vault_net_user }}"
ansible_password: "{{ vault_net_pass }}"
ansible_become: true
ansible_become_method: enable
ansible_become_password: "{{ vault_net_pass }}"
ntp_servers:
- 192.0.2.123
- 192.0.2.124
syslog_host: 192.0.2.60
The credentials themselves live in vault.yml, encrypted with ansible-vault. Create it with ansible-vault create inventory/group_vars/ios/vault.yml and add the two vault_ variables. The file on disk is ciphertext, so it can be committed safely as long as the vault password itself is not. Prefixing vaulted variables with vault_ and referencing them from a plain file is a common convention that makes it obvious where a value comes from.
Test connectivity before writing any playbook:
ansible ios -m cisco.ios.ios_command -a "commands='show clock'" --ask-vault-pass
Writing your first playbook
A playbook is a YAML file that describes which hosts to target and which tasks to run, in order. The playbook below gathers basic facts, then ensures every IOS device has the standard NTP servers, syslog host and log timestamps, and saves the configuration only when something actually changed.
---
- name: Apply NTP and logging baseline to IOS devices
hosts: ios
gather_facts: false
tasks:
- name: Gather minimal device facts
cisco.ios.ios_facts:
gather_subset: min
- name: Report software version
ansible.builtin.debug:
msg: "{{ inventory_hostname }} runs {{ ansible_net_version }}"
- name: Configure NTP servers
cisco.ios.ios_config:
lines:
- "ntp server {{ item }}"
loop: "{{ ntp_servers }}"
notify: Save configuration
- name: Configure logging
cisco.ios.ios_config:
lines:
- service timestamps log datetime msec localtime show-timezone
- "logging host {{ syslog_host }}"
notify: Save configuration
handlers:
- name: Save configuration
cisco.ios.ios_config:
save_when: modified
Several important ideas are packed into those lines. Module names are fully qualified (cisco.ios.ios_config rather than just ios_config), which avoids ambiguity when several collections are installed. The ios_config module compares the lines you supply against the running configuration and only sends the ones that are missing, so running the playbook twice produces “changed” the first time and “ok” the second. That property is called idempotence, and it is the main reason to use Ansible rather than a loop of raw commands. The handler runs once at the end of the play, and only if at least one task reported a change; save_when: modified then writes memory only if running and startup configurations differ.
ios_config matches your lines against the text of the running configuration. If you push a command that IOS stores in a different form, or a default that never appears in show running-config, the task reports “changed” on every run. When that happens, check how the device actually displays the line and write it exactly that way.Resource modules: declare the state, not the commands
For common features, the cisco.ios collection also provides resource modules that work with structured data instead of CLI lines. With ios_vlans, you describe the VLANs you want and the module works out the commands:
- name: Ensure standard VLANs exist on access switches
cisco.ios.ios_vlans:
config:
- vlan_id: 10
name: USERS
- vlan_id: 20
name: VOICE
- vlan_id: 99
name: MGMT
state: merged
The state parameter controls behavior: merged adds or updates what you list, replaced makes each listed VLAN match exactly, overridden removes anything not in your list, and deleted removes what you specify. Treat overridden with great respect; it is exactly what you want for a fully managed device and exactly what you do not want when someone has added a VLAN by hand that the file does not know about. If VLAN design is still new to you, review VLANs explained before automating it.
Run it safely
ansible-playbook playbooks/baseline.yml --check --diff --limit acc-sw1 --ask-vault-pass
ansible-playbook playbooks/baseline.yml --limit acc-sw1 --ask-vault-pass
ansible-playbook playbooks/baseline.yml --limit access --ask-vault-pass
Check mode with --diff shows what would change without touching the device. Limiting to a single host, then a single group, then everything, is a simple staged rollout that catches surprises while the blast radius is still small.
A Git workflow for network changes
Once your network is described by scripts, inventories and variables, Git becomes your change history, your review tool and your undo button. You do not need to become a software developer; a handful of commands covers ninety percent of daily use.
Initialize the repository
git init
cat > .gitignore <<'EOF'
.venv/
__pycache__/
*.retry
.vault_pass
backups/
EOF
git add .
git commit -m "Initial automation project: inventory, baseline playbook, backup script"
Ignoring backups/ keeps device configs out of your code repository; store those in a separate, access-controlled repository, because running configurations contain password hashes, SNMP settings and other sensitive details.
Branch, review, merge
Treat every change as a short-lived branch. The workflow looks like this:
- Create a branch named after the change:
git switch -c change/add-ntp-server. - Edit the variables or playbook, then run it in check mode against a lab or pilot device.
- Commit with a message that explains why, not just what:
git commit -am "Add second NTP server per new time-source standard". - Push the branch and open a pull request (or merge request) on your Git platform.
- A colleague reviews the diff, ideally with the check-mode output pasted into the request.
- After approval and within the change window, merge to
mainand run the playbook frommain.
The rule that makes this work is simple: production runs only from the main branch. That guarantees what is on the network matches what was reviewed. Add linting with yamllint and ansible-lint, either as pre-commit hooks or as a CI pipeline step, so obvious mistakes such as bad indentation or deprecated module names are caught before a human even looks at the change.
git log --grep takes you straight from a ticket to the exact lines that changed, and from a line back to the ticket.Testing and rolling out changes safely
Automation amplifies whatever you give it. A correct change reaches the whole network in minutes, and so does a wrong one. A few guardrails keep you out of trouble:
- Always back up first. Run your backup script or a backup playbook immediately before any change run, so you have a known-good config to compare or restore.
- Protect management access. Be extra careful with changes to VTY lines, AAA, management ACLs or the management VLAN. If a change locks you out, automation cannot fix it. On IOS,
reload in 10before a risky change gives you a scheduled rollback if you lose access; cancel it withreload cancelonce you confirm the device is reachable. - Use serial batches. Ansible’s
serialkeyword runs a play against a few hosts at a time, andmax_fail_percentagestops the run if too many fail. - Verify, do not assume. Add tasks after the change that run show commands and assert the expected state, for example that NTP is synchronized or that an interface is up.
- Know your troubleshooting process. When an automated change causes an issue, the same disciplined approach applies as with manual changes; see our network troubleshooting methodology.
Where to go next
Once these basics feel comfortable, the natural next steps are templating full configurations with Jinja2, adopting a source of truth such as an IPAM or DCIM platform that feeds your inventory dynamically, and exploring model-driven interfaces such as NETCONF and RESTCONF on platforms that support them. Each step builds on the same foundations: clean data, idempotent tasks, version control and staged rollouts. Automation is also one of the clearest skills that separates an administrator from an engineer, which is why it features prominently on the road from network admin to network engineer.
Key takeaways
- Start with read-only automation such as backups and audits; it delivers value with almost no risk.
- Netmiko handles SSH sessions, prompts and paging, so a short Python script can back up or query an entire network.
- Ansible with the
cisco.ioscollection andnetwork_cligives you idempotent configuration, check mode and diffs out of the box. - Keep data in inventories and variables, keep secrets in ansible-vault or environment variables, and never commit passwords.
- Use Git branches and reviews for every change, and run production only from the main branch.
- Roll out in stages: one lab or pilot device, then one group, then everything.
Automation works best on a network that is already consistent and well understood. If you are not sure where your network stands, a Network Health Assessment identifies the configuration drift, gaps and quick wins worth automating first. Have a specific automation project in mind? Get in touch and let us talk it through.