Project: Back Up Running-Configs from a Device List into Git

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 git commands 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 site field 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

  1. Warm-up. Adapt fetch_config to also accept a read_timeout and pass it to send_command for slow devices.
  2. Filtering. Extend the volatile‑line filter to also drop any line containing "! Last configuration change", another common source of diff noise.
  3. Dry run. Add a --dry-run behavior (a boolean argument) that fetches and saves configs but skips the Git commit, logging what would be committed.
  4. Per-site folders. Given an inventory where each device has a site key, modify save_configs to write to configs/<site>/<host>.cfg.
  5. Challenge. Add a function changed_devices(repo_dir) that runs git diff --name-only HEAD~1 HEAD and 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.

Leave a Reply