Config Management · python · 31 copies
Network Device Config Backup
Connects to every device in your inventory, downloads its running configuration, and saves each to a timestamped file. Rejects truncated output that would look like a valid backup but could not restore a device, strips volatile lines so unchanged configs produce identical files, and can skip writing when nothing changed. Devices come from a devices.yaml inventory; credentials from environment variables, never the file. Supports Cisco IOS/IOS-XE/IOS-XR/NX-OS, Juniper Junos, Arista EOS, Palo Alto PAN-OS, and Fortinet FortiOS.
- Arista EOS
- FortiOS
- Cisco IOS
- Cisco IOS-XE
- Cisco IOS-XR
- Junos
- Cisco NX-OS
- PAN-OS
pip install "netmiko>=4.3" "PyYAML>=6.0"#!/usr/bin/env python3
"""
Network Device Config Backup
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/config-backup
It covers what the script does, whether it is safe to run, the
inventory file and credentials it needs, how to read the output,
and what to do when something fails.
pip install "netmiko>=4.3" "PyYAML>=6.0"
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
try:
import yaml
except ImportError:
yaml = None
from netmiko import ConnectHandler
from netmiko.exceptions import NetmikoAuthenticationException, NetmikoTimeoutException
from datetime import datetime, timezone
# ---------------------------------------------------------------------------
# Inventory loading and connection
# ---------------------------------------------------------------------------
NETMIKO_DRIVER = {
"ios": "cisco_ios",
"iosxe": "cisco_ios",
"iosxr": "cisco_xr",
"nxos": "cisco_nxos",
"junos": "juniper_junos",
"eos": "arista_eos",
"panos": "paloalto_panos",
"fortios": "fortinet",
}
def load_inventory(path: str) -> list[dict]:
"""
Read devices.yaml (or .json) and return a list of device dicts.
Applies the `defaults:` block to every device, validates the `os` field,
and fails with a readable message rather than a stack trace.
"""
p = Path(path)
if not p.exists():
raise SystemExit(
f"Inventory file not found: {path}\n"
f"Create it with:\n\n"
f" devices:\n"
f" - host: 192.168.1.1\n"
f" os: ios\n"
)
raw = p.read_text(encoding="utf-8")
try:
if p.suffix.lower() in (".yaml", ".yml"):
if yaml is None:
raise SystemExit("PyYAML is not installed. Run: pip install PyYAML")
data = yaml.safe_load(raw)
else:
data = json.loads(raw)
except SystemExit:
raise
except Exception as exc:
raise SystemExit(f"Could not parse {path}: {exc}\n"
f"Check for tabs (use spaces) and missing spaces after colons.")
if isinstance(data, dict):
defaults = data.get("defaults", {}) or {}
devices = data.get("devices", []) or []
else:
defaults, devices = {}, data
if not devices:
raise SystemExit(f"No devices found in {path}. The file needs a `devices:` list.")
out = []
for i, d in enumerate(devices, 1):
if not isinstance(d, dict):
raise SystemExit(f"Device #{i} in {path} is not a mapping. "
f"Each entry needs at least `host:` and `os:`.")
merged = {**defaults, **d}
if "host" not in merged:
raise SystemExit(f"Device #{i} in {path} has no `host:` field.")
os_key = merged.get("os")
if not os_key:
raise SystemExit(f"Device '{merged['host']}' has no `os:` field. "
f"Valid values: {', '.join(sorted(NETMIKO_DRIVER))}")
if os_key not in NETMIKO_DRIVER:
raise SystemExit(
f"Device '{merged['host']}' has os '{os_key}', which is not supported.\n"
f"Valid values: {', '.join(sorted(NETMIKO_DRIVER))}\n"
f"Note: 'cisco' is a vendor, not an OS - use ios, iosxe, iosxr, or nxos."
)
merged.setdefault("vrfs", [])
out.append(merged)
return out
def credentials() -> tuple[str, str, str]:
"""Read credentials from the environment and fail clearly if missing."""
user = os.environ.get("NET_USER", "")
pwd = os.environ.get("NET_PASS", "")
enable = os.environ.get("NET_ENABLE", "")
if not user or not pwd:
raise SystemExit(
"Set NET_USER and NET_PASS in your environment before running.\n\n"
" Linux/macOS: export NET_USER='admin'\n"
" export NET_PASS='your-password'\n\n"
" PowerShell: $env:NET_USER = 'admin'\n"
" $env:NET_PASS = 'your-password'\n"
)
return user, pwd, enable
def connect(dev: dict):
"""Open an SSH session to one device using netmiko."""
user, pwd, enable = credentials()
params = {
"device_type": NETMIKO_DRIVER[dev["os"]],
"host": dev["host"],
"username": dev.get("username") or user,
"password": dev.get("password") or pwd,
"port": dev.get("port", 22),
"conn_timeout": 20,
"fast_cli": False,
}
secret = dev.get("secret") or enable
if secret:
params["secret"] = secret
conn = ConnectHandler(**params)
if secret and dev["os"] in ("ios", "iosxe", "nxos"):
conn.enable()
return conn
# ---------------------------------------------------------------------------
# Backup
# ---------------------------------------------------------------------------
# The command that prints the full running configuration, per OS.
COMMAND_MAP = {
"ios": "show running-config",
"iosxe": "show running-config",
"iosxr": "show running-config",
"nxos": "show running-config",
"junos": "show configuration | display set",
"eos": "show running-config",
"panos": "show config running",
"fortios": "show full-configuration",
}
# Lines that change on every read (timestamps, uptime counters). Stripping them
# means two backups of an unchanged device produce identical files, so the
# --only-changed check and any git diff show real change only.
VOLATILE = (
"Building configuration",
"Current configuration",
"! Last configuration change",
"! NVRAM config last updated",
"!Time:",
"## Last commit:",
"ntp clock-period",
)
def strip_volatile(text: str) -> str:
return "\n".join(
ln.rstrip() for ln in text.splitlines()
if ln.strip() and not any(ln.strip().startswith(v) for v in VOLATILE)
)
def latest_backup(d: Path) -> Path | None:
files = sorted(d.glob("*.cfg"), key=lambda p: p.stat().st_mtime, reverse=True)
return files[0] if files else None
def backup_device(dev: dict, args) -> dict:
host = dev["host"]
os_key = dev["os"]
command = COMMAND_MAP[os_key]
conn = None
try:
conn = connect(dev)
output = conn.send_command(command, read_timeout=args.read_timeout)
if not output.strip():
return {"host": host, "status": "error",
"error": f"'{command}' returned nothing - check account permissions"}
# A truncated config is worse than no config: it looks like a valid
# backup but cannot restore the device. Refuse anything implausibly short.
if len(output.splitlines()) < args.min_lines:
return {"host": host, "status": "error",
"error": f"only {len(output.splitlines())} lines returned "
f"(minimum {args.min_lines}) - output looks truncated"}
normalised = strip_volatile(output)
d = Path(args.backup_dir) / host
d.mkdir(parents=True, exist_ok=True)
if args.only_changed:
prev = latest_backup(d)
if prev and strip_volatile(prev.read_text(encoding="utf-8")) == normalised:
return {"host": host, "status": "unchanged", "file": str(prev),
"lines": len(output.splitlines())}
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H-%M-%S")
path = d / f"{ts}.cfg"
path.write_text(
f"! Backup of {host} ({os_key}) taken {ts} UTC\n"
f"! Command: {command}\n!\n{output}\n",
encoding="utf-8",
)
return {"host": host, "status": "ok", "file": str(path),
"lines": len(output.splitlines())}
except NetmikoAuthenticationException as exc:
return {"host": host, "status": "error",
"error": f"authentication failed - check NET_USER/NET_PASS ({exc})"}
except NetmikoTimeoutException as exc:
return {"host": host, "status": "error",
"error": f"unreachable or SSH timed out ({exc})"}
except Exception as exc: # noqa: BLE001 - one device must not abort the run
return {"host": host, "status": "error", "error": f"{type(exc).__name__}: {exc}"}
finally:
if conn:
try:
conn.disconnect()
except Exception: # noqa: BLE001
pass
def prune(backup_dir: str, host: str, keep: int) -> int:
"""Delete all but the `keep` most recent backups for one host."""
d = Path(backup_dir) / host
if not d.exists():
return 0
files = sorted(d.glob("*.cfg"), key=lambda p: p.stat().st_mtime, reverse=True)
removed = 0
for old in files[keep:]:
try:
old.unlink()
removed += 1
except OSError:
pass
return removed
def main() -> int:
ap = argparse.ArgumentParser(
description="Back up running configuration from network devices.",
epilog="Credentials come from NET_USER and NET_PASS. See the docstring at "
"the top of this file for the full inventory format.",
)
ap.add_argument("--inventory", required=True,
help="Path to devices.yaml listing which devices to back up")
ap.add_argument("--backup-dir", default="./backups",
help="Where to write the files. Default: ./backups")
ap.add_argument("--limit",
help="Only back up hosts whose name contains this text")
ap.add_argument("--only-changed", action="store_true",
help="Skip writing a new file when the config is identical to "
"the previous backup (ignoring timestamps)")
ap.add_argument("--keep", type=int, default=0,
help="Keep only the N most recent backups per device. 0 = keep all")
ap.add_argument("--min-lines", type=int, default=20,
help="Reject a backup shorter than this many lines, which almost "
"always means truncated output. Default: 20")
ap.add_argument("--read-timeout", type=int, default=180,
help="Seconds to wait for the config to download. Raise it for "
"very large configs. Default: 180")
ap.add_argument("--workers", type=int, default=10,
help="How many devices to contact at once. Default: 10")
ap.add_argument("--json-out", help="Write machine-readable results here")
args = ap.parse_args()
devices = load_inventory(args.inventory)
if args.limit:
devices = [d for d in devices if args.limit in d["host"]]
if not devices:
raise SystemExit(f"No device name contains '{args.limit}'.")
credentials() # fail fast before opening any connection
print(f"Backing up {len(devices)} device(s) to {args.backup_dir}/")
results = []
with ThreadPoolExecutor(max_workers=args.workers) as pool:
futures = {pool.submit(backup_device, d, args): d["host"] for d in devices}
for fut in as_completed(futures):
host = futures[fut]
try:
r = fut.result()
except Exception as exc: # noqa: BLE001
r = {"host": host, "status": "error", "error": f"worker crashed: {exc}"}
results.append(r)
if r["status"] == "ok":
print(f"[OK] {r['host']:<24} {r['lines']:>6} lines -> {r['file']}")
elif r["status"] == "unchanged":
print(f"[SAME] {r['host']:<24} {r['lines']:>6} lines no change since last backup")
else:
print(f"[FAIL] {r['host']:<24} {r['error']}")
if args.keep > 0:
total = sum(prune(args.backup_dir, r["host"], args.keep)
for r in results if r["status"] in ("ok", "unchanged"))
if total:
print(f"\nPruned {total} old backup file(s), keeping {args.keep} per device.")
if args.json_out:
Path(args.json_out).write_text(json.dumps(results, indent=2), encoding="utf-8")
ok = sum(1 for r in results if r["status"] == "ok")
same = sum(1 for r in results if r["status"] == "unchanged")
failed = sum(1 for r in results if r["status"] == "error")
print(f"\nDone. {ok} saved, {same} unchanged, {failed} failed "
f"(of {len(devices)} device(s)).")
return 1 if failed else 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
sys.exit(130)
--inventorytextrequiredPath to your devices.yaml file. Lists which devices to connect to, each with a host and an os field. See the output example below for the full format and every valid os value.
--backup-dirtextDirectory for the saved configs. Each device gets its own subfolder. Default: ./backups
--limittextOnly back up hosts whose name contains this text, e.g. --limit core
--only-changedboolSkip writing a new file when the configuration is identical to the previous backup, ignoring timestamps and other volatile lines. Keeps the backup directory meaningful.
--keepnumberKeep only the N most recent backups per device and delete older ones. 0 keeps everything. Default: 0
--min-linesnumberReject a backup shorter than this many lines. A truncated config looks like a valid backup but cannot restore a device. Default: 20
--read-timeoutnumberSeconds to wait for the configuration to download. Raise it for very large configs. Default: 180
--workersnumberHow many devices to contact at once. Default: 10. Keep it moderate — your TACACS server is usually the bottleneck, not your laptop.
--json-outtextWrite machine-readable results to this file, for monitoring or CI.
What This Solves
Everyone agrees configs should be backed up. Most places have something that does it, and most of those have never been tested by actually restoring a device. The failure mode is specific and quiet: the session hiccups, the download is cut short, and a 400-line file lands where a 3,400-line config should be. It has today's date, it is in the right folder, and it will not restore the device. You find out during the outage. This connects to every device in an inventory, downloads the running configuration, and saves it to a timestamped file per device - with two things most scripts skip: It rejects a truncated download. A backup shorter than --min-lines is refused and reported as a failure, rather than written as if it were fine. It strips volatile lines before comparing. Timestamps and counters change on every read, so two backups of an unchanged device would otherwise differ. Stripping them means "unchanged" is a real answer, and --only-changed can be trusted. Run it nightly. The exit code tells monitoring whether every device was actually captured.
Is It Safe to Run
Yes. It is read-only on the device. It sends one command per device and nothing else: ios, iosxe, iosxr, nxos, eos show running-config junos show configuration | display set panos show config running fortios show full-configuration It never enters configuration mode and never writes anything to a device. On your own machine it does write, and with --keep it also **deletes**: Writes one timestamped `.cfg` per device, under <backup-dir>/<host>/. Default ./backups. Deletes only with --keep N, and only files matching `*.cfg` inside that device's own folder, keeping the N most recent. The default is 0, which keeps everything. Point --backup-dir at a directory that belongs to this script and nothing else. One thing to know about `show running-config`: it needs privileged exec on most platforms. If the account cannot, you get a short file - which is exactly what --min-lines is there to catch.
Before You Start
1. Python 3.9 or newer: python --version 2. The libraries: pip install "netmiko>=4.3" "PyYAML>=6.0" 3. SSH access with enough privilege to read the running configuration. Prove it by hand on one device first: ssh admin@192.168.1.1 show running-config | include hostname
Your First Run
Create devices.yaml with one device: devices: - host: 192.168.1.1 os: ios Set your credentials and run it: export NET_USER='admin' export NET_PASS='your-password' python config_backup.py --inventory devices.yaml You get ./backups/192.168.1.1/<timestamp>.cfg. **Open it.** Check the line count looks like a whole configuration and that the end of the file is the end of the config, not a truncated line. That one check is the difference between having backups and believing you do. Then add the rest of your devices and put it on a schedule: 0 2 * * * cd /opt/netops && python config_backup.py \ --inventory devices.yaml --only-changed --keep 30 \ --json-out /var/log/config-backup.json
What the Output Means
$ export NET_USER='admin' $ export NET_PASS='********' $ python config_backup.py --inventory devices.yaml --only-changed --keep 30 Backing up 7 device(s) to ./backups/ [OK] rtr-core-01 3421 lines -> ./backups/rtr-core-01/2026-10-04_02-15-33.cfg [OK] sw-dist-02 1876 lines -> ./backups/sw-dist-02/2026-10-04_02-15-34.cfg [SAME] sw-access-11 942 lines no change since last backup [OK] fw-edge-01 5102 lines -> ./backups/fw-edge-01/2026-10-04_02-15-36.cfg [FAIL] rtr-branch-09 unreachable or SSH timed out [FAIL] sw-lab-03 authentication failed - check NET_USER/NET_PASS Pruned 4 old backup file(s), keeping 30 per device. Done. 3 saved, 2 unchanged, 2 failed Reading it: 3421 lines Worth a glance over time. A device that normally backs up at 3,400 lines and suddenly reports 800 has either been drastically reconfigured or the download was cut short. [SAME] no change since last backup Only with --only-changed. The config is byte-identical to the previous backup once volatile lines are stripped, so no new file was written. This is the normal state of a stable network - most devices should report SAME most nights. [FAIL] No file was written for that device. **A backup run with failures is not a backup run.** The device you could not reach is the one whose config you do not have, and it stays that way until someone looks. That is what the exit code is for. Pruned 4 old backup file(s) --keep did its job. Only that device's own `.cfg` files are considered.
Exit Codes for Cron and CI
0 every device backed up or unchanged 1 at least one device failed 130 you pressed Ctrl-C Wire the 1 to an alert. A nightly backup that silently skips three devices for six months is worse than no backup, because everyone believes it is working.
The Inventory File
devices: - host: 192.168.1.1 # REQUIRED: IP address or DNS name os: ios # REQUIRED: which OS it runs - host: sw-floor2.example.com os: eos port: 2222 # optional, default 22 device_name: Floor 2 IDF # optional label for the output This script uses the shared inventory loader, so a `defaults:` block applies to every device below it unless that device overrides it: defaults: os: ios port: 22 devices: - host: 192.168.1.1 - host: 192.168.1.2 - host: 192.168.1.3 os: nxos # except this one Credentials never go in this file. YAML rules that trip people up: * Indentation must be SPACES, never tabs. * The "- " before host marks a new list item. Keep it aligned. * A space after the colon is required. host: 192.168.1.1 correct host:192.168.1.1 WRONG - YAML parse error
All Valid OS Values
ios Cisco IOS older routers, older Catalyst switches iosxe Cisco IOS-XE Catalyst 9000, ISR/ASR, Catalyst 8000 iosxr Cisco IOS-XR ASR 9000, NCS, service provider gear nxos Cisco NX-OS Nexus data center switches junos Juniper Junos saved in `set` form, for scripted restore eos Arista EOS all Arista switches panos Palo Alto PAN-OS PA firewalls, VM-Series fortios Fortinet FortiOS FortiGate firewalls "cisco" is a vendor, not an OS. Use ios, iosxe, iosxr or nxos. Junos is deliberately captured with `| display set` rather than as hierarchy: the `set` form is what a script can push back, and it is what `config-validator` and `config-rollback` expect.
Where the Files Go
./backups/ rtr-core-01/ 2026-10-04_02-15-33.cfg 2026-10-03_02-15-30.cfg sw-dist-02/ 2026-10-04_02-15-34.cfg One folder per device, named from `host`. One file per run, named from the timestamp, so they sort chronologically. The files are plain configuration text. They are a good thing to commit to a git repository - with --only-changed, a nightly run produces a commit only on the days something actually changed, which turns the backup directory into a change history. Volatile lines are stripped before saving, so the diff between two days is the real change and not a timestamp. The stripped lines are the "Building configuration", "Current configuration", "Last configuration change", "NVRAM config last updated", "Time:", "Last commit:" and "ntp clock-period" headers.
Credentials
From the environment - never from a file, never from an argument. An argument is visible to every user on the machine in `ps auxww` while the script runs, and it lands in your shell history. NET_USER username required NET_PASS password required NET_ENABLE enable secret optional, falls back to NET_PASS Linux or macOS: export NET_USER='admin' export NET_PASS='your-password' export NET_ENABLE='your-enable-secret' Windows PowerShell: $env:NET_USER = 'admin' $env:NET_PASS = 'your-password' Windows Command Prompt: set NET_USER=admin set NET_PASS=your-password For a cron job, put them in the job's own environment file with restricted permissions - not in the crontab line, which other users can read.
Every Flag
--inventory PATH REQUIRED. The devices. --backup-dir PATH Where the files go. One subfolder per device. Default ./backups. --limit TEXT Only hosts whose name contains TEXT. --only-changed Do not write a new file when the config is identical to the previous backup. Reports [SAME] instead. --keep N Keep only the N most recent backups per device and DELETE the rest. Default 0 = keep everything. --min-lines N Reject a backup shorter than this and report it as a failure. This is the truncation guard - raise it above your smallest real config, not below it. --read-timeout N Seconds to wait for the download. Raise it for very large configurations. --workers N Devices at once. Default 10. Keep it moderate - TACACS is usually the bottleneck, not the devices. --json-out PATH Machine-readable results, for monitoring.
When Something Fails
"Set NET_USER and NET_PASS" Not set in this terminal, or not in the cron job's environment. See Credentials. "authentication failed - check NET_USER/NET_PASS" Wrong credentials, or the account lacks access. Verify by hand: ssh admin@192.168.1.1 "unreachable or SSH timed out" No answer on the SSH port. Check `ping` and `ssh` directly. If one device times out while the rest work, raise --read-timeout - a large configuration takes a while to print. "... (minimum N) - output looks truncated" The guard fired. Two real causes, and they need different fixes: the download was genuinely cut short - raise --read-timeout and lower --workers; or the account cannot read the full running config and got a short one - check the privilege level. Lowering --min-lines to make the message go away stores a broken backup. Everything reports [SAME] and you know something changed The change was in a line this script strips as volatile, or it was not in the running configuration at all. Compare the two most recent files directly to confirm. The backups directory is growing without limit --keep is 0 by default. Set it to the number of days you actually need - with --only-changed, 30 kept files is a long history, because files are only written on days something changed. You want these in git Run with --only-changed, point --backup-dir at a working tree, and commit after the run. The volatile stripping is what makes this produce clean diffs instead of a timestamp change every night.