All posts
C automationhow-toTOFU

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.

Ole OlorentzenJune 30, 20267 min read

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 but cisco_xe is what Netmiko 4.x is optimized around.
  • The connection keyword is host, not ip as 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. .env files or systemd unit files keep them out of the code.
  • Hostname sanity check. find_prompt() returns the device's prompt. If your CSV says core-sw-01 but you SSH'd into core-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