Netmiko script to back up Cisco configs across 50 switches
Production-ready Netmiko scripts to back up Cisco IOS configs across 50 switches. Single-device plus a threaded fleet version with alerting.
Most "Netmiko backup scripts" you'll find online are 10 lines and work great in someone's lab. They fall apart the first time you point them at a real fleet — authentication quietly fails, the file ends up empty, and your "backup" is a 47-byte stub.
This is the production-ready version. Two scripts: a single-device version you can verify on your desk, and a threaded fleet version you can put in cron and forget about (mostly).
Why Netmiko, not raw SSH or expect
Netmiko wraps Paramiko (Python SSH) and adds the things you actually need for network gear: vendor-aware prompt handling, automatic enable-mode entry, command timing, output buffering. Rolling this yourself with Paramiko takes a week of debugging per vendor. Rolling it with expect works until you port it to a new platform.
For most Cisco shops, Netmiko is the right answer. For multi-vendor fleets at scale, you eventually graduate to Nornir or a managed tool — we'll cover that at the end.
Related: Cisco IOS configuration backup: the complete guide — the operational context for what we're building here.
What you'll need
- Python 3.9 or newer
- Netmiko 4.x (
pip install netmiko) - A read-only SSH user on each device with privilege 15
- A flat text file or YAML with your device list (one host per line, or richer records)
If you don't have a backup user yet, set one up first:
! On each device
username backup privilege 15 algorithm-type scrypt secret <redacted>
ip scp server enable
ip ssh version 2
ip ssh server algorithm authentication keyboard
ip ssh server algorithm authentication publickey
line vty 0 15
transport input ssh
Step 1: prove it works (5 lines)
Before you write anything fancy, prove the connection. From any machine with Python and Netmiko installed:
from netmiko import ConnectHandler
device = {
"device_type": "cisco_xe",
"host": "10.0.1.1",
"username": "backup",
"password": "your-password",
}
with ConnectHandler(**device) as conn:
print(conn.send_command("show running-config"))
A few things to note:
device_type: "cisco_xe"is the modern preference."cisco_ios"still works butcisco_xeis what Netmiko 4.x is optimized around.- The connection keyword is
host, notipas in Netmiko 3.x. If you're porting old code, this is the breaking change that bites. with ConnectHandler(...) as conn:ensures the SSH session closes cleanly even if the script errors.
If this prints a few hundred lines of running-config, your setup is good. If it errors, fix connectivity first — no amount of clever script will save a broken SSH path.
Step 2: production-ready single-device script
Here's the version you actually want to run. Save as backup_one.py.
#!/usr/bin/env python3
"""
Netmiko config backup — single device, production-ready.
Target: Cisco IOS-XE 17.x (tested on 17.09.04a)
Tested on: Netmiko 4.x, Python 3.11
"""
import os
import sys
from datetime import datetime
from pathlib import Path
from netmiko import ConnectHandler
# --- Configuration from environment ----------------------------------------
DEVICE = {
"host": os.environ["DEVICE_HOST"],
"device_type": "cisco_xe",
"username": os.environ["DEVICE_USER"],
"password": os.environ["DEVICE_PASS"],
"timeout": 30,
}
BACKUP_DIR = Path(os.environ.get("BACKUP_DIR", "/var/backups/cisco"))
# ---------------------------------------------------------------------------
def backup_device():
host = DEVICE["host"]
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
device_dir = BACKUP_DIR / host
device_dir.mkdir(parents=True, exist_ok=True)
outfile = device_dir / f"running-config-{timestamp}.cfg"
try:
with ConnectHandler(**DEVICE) as conn:
# Sanity check: confirm the prompt matches what we expect
prompt_host = conn.find_prompt().strip("#<>")
if prompt_host and prompt_host != host:
print(f"WARN: prompt host {prompt_host!r} != expected {host!r}")
config = conn.send_command("show running-config")
outfile.write_text(config)
size = outfile.stat().st_size
if size < 500:
# Catches the "SSH broke 11 months ago, file is empty" failure mode
raise ValueError(f"Config suspiciously small: {size} bytes")
print(f"OK: {host} -> {outfile} ({size} bytes)")
return 0
except Exception as e:
print(f"FAIL: {host}: {e}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(backup_device())
Run it:
DEVICE_HOST=10.0.1.1 \
DEVICE_USER=backup \
DEVICE_PASS='redacted' \
python3 backup_one.py
What's different from the 5-line version, and why each thing is there:
- Credentials in env vars, not the script. A grep over your repo won't reveal your password.
.envfiles or systemd unit files keep them out of the code. - Hostname sanity check.
find_prompt()returns the device's prompt. If your CSV sayscore-sw-01but you SSH'd intocore-sw-02, the script warns you before writing a backup labeled with the wrong hostname. - File-size sanity check. This is the silent-failure killer. If the SSH session authenticated but the command returned nothing (broken ACL, broken user, broken device), the file lands as a tiny stub. Threshold of 500 bytes catches most of these; tune it for your environment.
- Exit code matters. Returns 0 on success, 1 on any failure. The cron wrapper below uses this to decide whether to alert.
Step 3: from 1 device to 50 (threaded)
Single-device script is fine for testing. For 50 switches, you're waiting 50 × 10 seconds = 8 minutes serially. With threading: 8 minutes / 20 concurrent = 25 seconds.
#!/usr/bin/env python3
"""
Netmiko config backup — multi-device, threaded.
Reads target list from YAML.
"""
import os
import sys
import yaml
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from pathlib import Path
from netmiko import ConnectHandler
USERNAME = os.environ["DEVICE_USER"]
PASSWORD = os.environ["DEVICE_PASS"]
BACKUP_DIR = Path(os.environ.get("BACKUP_DIR", "/var/backups/cisco"))
TARGETS_FILE = os.environ.get("TARGETS_FILE", "/etc/cisco-backup/targets.yaml")
MAX_WORKERS = int(os.environ.get("MAX_WORKERS", "20"))
def backup_one(host: str) -> tuple[str, bool, str]:
"""Backup a single device. Returns (host, success, message)."""
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
device_dir = BACKUP_DIR / host
device_dir.mkdir(parents=True, exist_ok=True)
outfile = device_dir / f"running-config-{timestamp}.cfg"
try:
device = {
"device_type": "cisco_xe",
"host": host,
"username": USERNAME,
"password": PASSWORD,
"timeout": 30,
}
with ConnectHandler(**device) as conn:
config = conn.send_command("show running-config")
outfile.write_text(config)
size = outfile.stat().st_size
if size < 500:
return (host, False, f"config suspiciously small ({size} bytes)")
return (host, True, f"{size} bytes")
except Exception as e:
return (host, False, str(e))
def main():
targets = yaml.safe_load(Path(TARGETS_FILE).read_text())
hosts = [t["host"] for t in targets["devices"]]
print(f"Backing up {len(hosts)} devices, {MAX_WORKERS} concurrent...")
results = []
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
futures = {pool.submit(backup_one, h): h for h in hosts}
for future in as_completed(futures):
host, ok, msg = future.result()
results.append((host, ok, msg))
status = "OK" if ok else "FAIL"
print(f" [{status}] {host}: {msg}")
failed = [r for r in results if not r[1]]
print(f"\nDone. {len(results) - len(failed)}/{len(results)} succeeded.")
if failed:
print("Failed hosts:")
for host, _, msg in failed:
print(f" - {host}: {msg}")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
Targets file (/etc/cisco-backup/targets.yaml):
devices:
- host: 10.0.1.1
- host: 10.0.1.2
- host: 10.0.2.1
# ...
Why threading works here: Netmiko's bottleneck is SSH socket I/O, not CPU. Python's GIL releases during socket operations, so threads genuinely run in parallel. For 50 devices, MAX_WORKERS=20 is plenty — going higher doesn't help much and risks overwhelming your authentication server (TACACS+, RADIUS).
Step 4: alerting + retention (the parts nobody builds)
A backup that runs and a backup that runs and you know about its failures are different products. Add a wrapper script and a cron entry.
/opt/cisco-backup/run.sh:
#!/bin/bash
set -e
if ! /opt/cisco-backup/backup_fleet.py; then
echo "Cisco config backup failed — check /var/log/cisco-backup.log" \
| mail -s "[ALERT] Cisco backup failure" ops@yourcompany.com
exit 1
fi
Cron entry, every night at 02:00:
0 2 * * * /opt/cisco-backup/run.sh >> /var/log/cisco-backup.log 2>&1
For retention, add to the script: prune files older than 90 days (tune to your audit requirements).
# Add to backup_one() after successful write
RETENTION_DAYS = 90
cutoff = datetime.now().timestamp() - (RETENTION_DAYS * 86400)
for f in device_dir.glob("running-config-*.cfg"):
if f.stat().st_mtime < cutoff:
f.unlink()
When Netmiko isn't enough
Netmiko scripts work for fleets up to a few hundred devices, with one engineer maintaining them. Past that, the operational overhead starts compounding: where's the diff against yesterday? which devices haven't backed up in 24 hours? which user-account changed which device? audit wants the answer in 30 seconds, not after you grep four log files.
PacketPilot Config takes the script above and wraps it for multi-vendor fleets (Cisco, Juniper, Arista, HPE/Aruba): scheduled daily backups, diff-style compare between any two backups, restore any previous version, timestamped audit trail. Self-hosted Windows desktop app, credentials stored locally, never transmitted. 30-day machine-bound trial, no credit card.
Want to follow what we're building? dB-Electronics on LinkedIn — product updates and the occasional technical post, no spam.
— Ole
Other posts in this cluster and adjacent topics.
Cisco archive vs kron: which config backup mechanism wins
Practical comparison of Cisco's two on-device config backup mechanisms — archive and kron — and when each one is the right call.
Why your Cisco configs drift (and what to do about it)
Why Cisco device configs quietly diverge from the baseline, how to detect it, and the practical remediation playbook.
show running-config vs show startup-config: what actually matters
Quick primer on the difference between running-config and startup-config on Cisco IOS — and when you need each one in your backup.