Every network team has the same story: a switch dies, the replacement arrives, and then someone asks “where’s the latest config?” Usually the answer is a .txt on somebody’s laptop from eight months ago.

I wanted backups that happen without anyone thinking about them, for a network of office switches spread over a lot of sites. No extra VM to babysit, no cron box under a desk. This is what I ended up with.

The big picture

EventBridge (cron) ──► Lambda (VPC) ──SSH──► switches (on-prem)
                          │
                          ├──► S3: configs by date / site
                          └──► SNS: email summary
  • EventBridge fires on a schedule.
  • A Lambda function (Python + Paramiko) reads the list of switches, logs in over SSH and grabs show running-config from each one, in parallel.
  • Configs land in S3, sorted by date and site.
  • SNS emails a summary: which devices succeeded, which failed and why.
  • Everything is Terraform, deployed from a GitLab pipeline.

The inventory is just CSV files in git

Each site has a small semicolon-separated file: hostname and management IP.

SITE1-SW01;192.0.2.11
SITE1-SW02;192.0.2.12

Terraform uploads those files to an S3 bucket with aws_s3_object resources. That sounds like a detail, but it’s my favourite part: adding a switch is a merge request, there’s history for free, and the bucket never drifts from what’s in git.

The site name is parsed from the hostname, so backups end up neatly organised:

s3://<backup-bucket>/cisco-backups/2026-10-13/SITE1/SITE1-SW01_running_config.txt

Getting a Lambda onto the management network

This is where most of the actual work went. The switches live on-prem, so the Lambda runs inside a VPC that has a route back to the on-prem network (in my case through a Transit Gateway).

A Lambda in a VPC has no internet access by default, but it still needs to talk to S3, Secrets Manager and SNS. Instead of a NAT gateway, I added VPC endpoints:

  • S3: a gateway endpoint (free, just a route table entry)
  • Secrets Manager and SNS: interface endpoints, each with its own security group

That keeps AWS API traffic inside AWS instead of hairpinning over the on-prem link.

Talking to the switches

The Lambda uses Paramiko to open an interactive shell, because it needs to handle enable mode:

  1. connect, wait for the login banner to finish, and read the prompt (SITE1-SW01> or SITE1-SW01#)
  2. if it ends in >, send enable and the enable password
  3. terminal length 0 so the output isn’t paginated
  4. show running-config, and read until that exact prompt comes back

Reading until the device’s own prompt matters: a # can show up anywhere in a config (banners, description Uplink #2), so “stop at the first #” isn’t good enough. And a config is only saved if it ends with the end line; anything else counts as a failure, never as a half backup.

Switches are processed with a ThreadPoolExecutor (up to 50 at a time), so a full run finishes well within Lambda’s 15-minute limit. 512 MB of memory is plenty: a run over the lab switches takes about 3 seconds and uses ~110 MB.

Credentials live in Secrets Manager, not in the code or the Terraform: a primary account (the normal AAA login), plus a fallback with local credentials for when AAA is unreachable. If the primary login fails, the fallback is tried. If the switch can’t be reached at all, it isn’t: other credentials won’t make a dead switch answer.

One function, several jobs

The Lambda takes its parameters from the event, so the same code covers several schedules with a different input each time:

resource "aws_cloudwatch_event_target" "switch_backup" {
  rule = aws_cloudwatch_event_rule.daily_2am.name
  arn  = aws_lambda_function.device_backup.arn

  input = jsonencode({
    csv_bucket    = var.csv_bucket_name
    csv_prefix    = "switches/"
    output_prefix = "switch-backup"
  })
}
  • Switches: every night at 02:00 UTC
  • Wireless LAN controllers: weekly, same function, csv_prefix = "WLC/"
  • “Tech dump”: a second Lambda that runs a list of show commands (show version, show interface status, show cdp neighbors, …) every Sunday. Handy as a weekly snapshot of what the network looks like, not just how it’s configured.

Storage that costs next to nothing

Config files are tiny, but they pile up. The backup bucket uses:

  • Intelligent-Tiering archive: Archive Access after 90 days, Deep Archive after 180
  • a lifecycle rule that deletes objects after 365 days

Knowing it actually ran

A backup job that fails silently is worse than none, because everyone thinks there are backups. So:

  • every run emails a per-device summary through SNS, and the subject says how many devices failed (Device Backup Summary: 2 failed)
  • CloudWatch alarms fire on Lambda errors, throttles and runs that get close to the timeout
  • an alarm fires when any device fails

That last one needed a trick. A run where three switches fail is still a successful Lambda invocation, so the built-in Errors metric never sees it. The Lambda logs its counts in Embedded Metric Format, a JSON log line that CloudWatch turns into a metric by itself:

{"_aws": {"CloudWatchMetrics": [{"Namespace": "NetworkBackup",
  "Dimensions": [["FunctionName"]],
  "Metrics": [{"Name": "FailedDevices", "Unit": "Count"}]}]},
 "FunctionName": "device-backup", "FailedDevices": 0}

No PutMetricData call, no extra IAM permission, and no extra VPC endpoint for a Lambda that has no internet access.

Deploying it: GitLab CI with OIDC

StageWhat happens
testunit tests against a fake Cisco switch
validateterraform validate
planterraform plan, which also builds the Lambda ZIPs
deployterraform apply, only on main

GitLab authenticates to AWS with OIDC: the pipeline assumes an IAM role through a short-lived token. No long-lived AWS access keys stored anywhere. State lives in GitLab’s managed Terraform state, with locking.

The tests run against a tiny fake switch: an SSH server written with Paramiko that mimics IOS (> and # prompts, enable, a banner full of # characters, and output that trickles in slowly like over a WAN link). It’s about 180 lines and makes it easy to test the SSH logic without touching a real network.

A few CI/CD details worth copying

  • Build the Lambda ZIPs from source in Terraform (archive_file) instead of committing them, so what’s deployed is always what’s in git. The ZIP is created during plan and the separate apply job needs that file, so pass it along as a CI artifact.

  • Only deploy from main. Merge requests get tests and a plan they can review, nothing more.

  • Pin the AWS account. One line in the provider makes Terraform refuse to run with credentials for any other account:

    provider "aws" {
      region              = var.aws_region
      allowed_account_ids = [var.aws_account_id]
    }
    
  • Know where your CI variables come from. GitLab variables can be set on the project and inherited from parent groups, and protected variables only exist on protected branches. Worth checking which AWS_ROLE_ARN a branch pipeline actually gets.

What’s next

  • Netmiko instead of raw Paramiko. The prompt handling works well, but Netmiko already knows the quirks of every Cisco platform.
  • Diff the configs. Every night is a full copy. Committing them to a git repo (the way Oxidized or RANCID do) would give you a “what changed last night?” email for free.

All in all it’s a handful of Terraform files and two Python functions, it costs pennies a month, and nobody has had to think about switch backups since.