Nineteen days of building blocks come together now. This is a complete, real tool: read a device inventory from YAML, connect to each device over SSH, pull the running‑config, and save it into a Git repository so every backup is version‑controlled and every change is a diff. Config backup to Git is one of the most common first automation projects in production networks — and every piece needed for it has already appeared in this series.
This is Day 20 of the 21‑post Python for Network Engineers series: the first of two mini‑projects.
Why Git for Config Backups?
Plain files in a folder answer “what is the config now?” Git answers far more: what changed, when, and compared to what. Each backup run becomes a commit; git diff between commits shows exactly which lines changed on which device; git log is an audit trail. That turns a backup script into a change‑detection system for free.
The Pieces, Already Covered
- YAML inventory (Day 15) — the device list, separate from code.
- Netmiko (Day 13) — SSH connect and
show running-config. - pathlib + files (Day 7) — write each config to disk.
- Exceptions + logging (Day 7) — isolate per‑device failures, leave a paper trail.
- subprocess (Day 10) — drive
gitcommands to commit.
The Inventory
# inventory.yaml
- device_type: cisco_ios
host: 192.168.1.1
username: admin
password: cisco123
- device_type: cisco_ios
host: 192.168.1.2
username: admin
password: cisco123
Step 1: Fetch and Save Each Config
The core loop reuses the Day 13 fleet pattern — per‑device error isolation so one unreachable box does not abort the run:
import logging
from pathlib import Path
import yaml
from netmiko import ConnectHandler
from netmiko.exceptions import NetmikoTimeoutException, NetmikoAuthenticationException
logging.basicConfig(level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S")
log = logging.getLogger("backup")
BACKUP_DIR = Path("configs")
def fetch_config(device):
"""Connect and return the running-config as text, or None on failure."""
host = device["host"]
try:
with ConnectHandler(**device) as conn:
return conn.send_command("show running-config")
except NetmikoAuthenticationException:
log.error("%s: authentication failed", host)
except NetmikoTimeoutException:
log.error("%s: unreachable", host)
return None
def save_configs(inventory_path):
with open(inventory_path) as f:
inventory = yaml.safe_load(f)
BACKUP_DIR.mkdir(exist_ok=True)
saved = 0
for device in inventory:
cfg = fetch_config(device)
if cfg is None:
continue
# Strip the volatile first lines (timestamps) so diffs stay meaningful
cfg = "\n".join(line for line in cfg.splitlines()
if not line.startswith("Current configuration"))
path = BACKUP_DIR / f"{device['host']}.cfg"
path.write_text(cfg + "\n")
log.info("%s: saved %d bytes", device["host"], len(cfg))
saved += 1
return saved
One detail that matters for Git: many devices print a timestamp or byte count at the top of show running-config (Current configuration : 1234 bytes). Left in, that line changes every run and creates noise in every diff. Filtering volatile lines keeps a commit’s diff limited to real config changes — the whole point of using Git.
Step 2: Commit to Git
Driving git through subprocess (Day 10, list form for safety) turns each run into a commit:
import subprocess
from datetime import datetime
def git_commit(repo_dir=".", message=None):
message = message or f"Config backup {datetime.now():%Y-%m-%d %H:%M}"
# Initialize the repo on first run (idempotent)
if not (Path(repo_dir) / ".git").exists():
subprocess.run(["git", "init"], cwd=repo_dir, check=True,
capture_output=True)
subprocess.run(["git", "add", "-A"], cwd=repo_dir, check=True,
capture_output=True)
# Only commit if something actually changed
status = subprocess.run(["git", "status", "--porcelain"], cwd=repo_dir,
capture_output=True, text=True).stdout
if not status.strip():
log.info("no config changes since last run")
return False
subprocess.run(["git", "commit", "-m", message], cwd=repo_dir,
check=True, capture_output=True)
log.info("committed: %s", message)
return True
The git status --porcelain check is what makes this pleasant to run on a schedule: it produces empty output when nothing changed, so the script commits only on real differences instead of creating an empty commit every hour.
Step 3: Tie It Together
def main():
saved = save_configs("inventory.yaml")
log.info("fetched %d configs", saved)
if saved:
git_commit(message=f"Backup of {saved} devices "
f"{datetime.now():%Y-%m-%d %H:%M}")
if __name__ == "__main__":
main()
Run it once and it creates the repo, saves every config, and makes the first commit. Run it again after someone changes a VLAN, and the output narrows to exactly that device — git show displays the added and removed lines. Schedule it with cron (or a systemd timer) and it becomes a continuous config‑change record with zero further effort.
Seeing a Change
# After a second run where one device changed:
git log --oneline
# a1b2c3d Backup of 2 devices 2026-07-13 06:00
# e4f5g6h Backup of 2 devices 2026-07-12 06:00
git diff HEAD~1 HEAD
# --- a/configs/192.168.1.1.cfg
# +++ b/configs/192.168.1.1.cfg
# @@ ... @@
# +vlan 50
# + name GUEST
That diff is the deliverable. It answers “what changed on the network last night?” — instantly, per device, forever.
Extending It
Production‑hardening ideas, each building on an earlier day:
- Read credentials from environment variables or a secrets manager instead of plaintext YAML (never commit passwords).
- Add a
sitefield to the inventory and organize backups into per‑site subdirectories. - Push the repo to a remote (
git push) so backups survive the backup host. - Parse each config with TextFSM (Day 19) to also emit a structured JSON snapshot alongside the raw text.
Exercises
- Warm-up. Adapt
fetch_configto also accept aread_timeoutand pass it tosend_commandfor slow devices. - Filtering. Extend the volatile‑line filter to also drop any line containing
"! Last configuration change", another common source of diff noise. - Dry run. Add a
--dry-runbehavior (a boolean argument) that fetches and saves configs but skips the Git commit, logging what would be committed. - Per-site folders. Given an inventory where each device has a
sitekey, modifysave_configsto write toconfigs/<site>/<host>.cfg. - Challenge. Add a function
changed_devices(repo_dir)that runsgit diff --name-only HEAD~1 HEADand returns the list of device config files that changed in the most recent commit — the basis for an email/Slack “these devices changed overnight” alert.
Answers
Show answers
1. Warm-up
def fetch_config(device, read_timeout=30):
host = device["host"]
try:
with ConnectHandler(**device) as conn:
return conn.send_command("show running-config",
read_timeout=read_timeout)
except (NetmikoAuthenticationException, NetmikoTimeoutException) as e:
log.error("%s: %s", host, e)
return None
2. Filtering
SKIP = ("Current configuration", "! Last configuration change")
cfg = "\n".join(l for l in cfg.splitlines()
if not any(l.startswith(s) or s in l for s in SKIP))
3. Dry run
def main(dry_run=False):
saved = save_configs("inventory.yaml")
if saved and not dry_run:
git_commit()
elif dry_run:
log.info("dry run: would commit %d configs", saved)
4. Per-site folders
site = device.get("site", "unknown")
site_dir = BACKUP_DIR / site
site_dir.mkdir(parents=True, exist_ok=True)
path = site_dir / f"{device['host']}.cfg"
5. Challenge
import subprocess
def changed_devices(repo_dir="."):
out = subprocess.run(
["git", "diff", "--name-only", "HEAD~1", "HEAD"],
cwd=repo_dir, capture_output=True, text=True, check=True,
).stdout
return [line for line in out.splitlines() if line.endswith(".cfg")]
print(changed_devices())
# ['configs/192.168.1.1.cfg']
git diff --name-only lists just the changed file paths — feed that list into a notification and the tool graduates from “backup” to “change alerting.”
Previously: TextFSM and ntc-templates. Coming tomorrow — the finale: Project 2, a bulk config‑push tool with a per‑device diff report, plus where to take Python from here.