Every network person has a story about a dead device and a config backup that turned out to be a year old. I decided my lab would never be that story, so I built the backups into the automation instead of leaving them as a chore.
The flow
An Ansible playbook logs into the switch and firewall, pulls the running config, encrypts it with ansible-vault, and commits the encrypted file to a private git repo. Semaphore runs the playbook on a schedule, pulling the playbook itself from git. Nothing is stored in cleartext anywhere, and every backup is a commit, so history is free. If I want to know what changed on the firewall last month, git diff answers it. The core of the playbook:
- name: Backup network configs
hosts: network_devices
gather_facts: no
connection: ansible.netcommon.network_cli
tasks:
- name: Pull running config
cisco.ios.ios_command:
commands: show running-config
register: runcfg
- name: Write it locally
delegate_to: localhost
copy:
content: "{{ runcfg.stdout[0] }}"
dest: "backups/{{ inventory_hostname }}.cfg"
- name: Encrypt before it touches the repo
delegate_to: localhost
command: ansible-vault encrypt backups/{{ inventory_hostname }}.cfg
- name: Commit
delegate_to: localhost
shell: |
cd backups && git add -A
git diff --cached --quiet || git commit -m "backup {{ inventory_hostname }} $(date -I)"
git push
Encrypting matters more for network gear than almost anything else, because a raw Cisco config is a credential goldmine: enable secrets, SNMP strings, user hashes. This repo has never held a plaintext config in any commit, which means I never have to think about who can see it.
NetBox as the source of truth
Inventory comes from NetBox instead of a hand-kept file. Devices, IPs, and roles live there, and Ansible’s inventory plugin reads it live:
# netbox_inv.yml
plugin: netbox.netbox.nb_inventory
api_endpoint: http://netbox.lab
token: "{{ lookup('env', 'NETBOX_TOKEN') }}"
group_by:
- device_roles
compose:
ansible_network_os: platform.slug
Add a device in NetBox and it shows up in the automation without touching an inventory file. Connection settings hang off device roles in group_vars, so a new access switch inherits everything the existing one uses. One trap: the NetBox inventory plugin reads its token from an environment variable or the file itself, never from Ansible extra vars, so don’t waste an afternoon trying to pass it that way.
The two Semaphore gotchas
Semaphore runs playbooks from the playbook’s own directory, which quietly breaks the usual group_vars resolution. The fix is a symlink from the playbooks directory back to group_vars, and it has to be a relative symlink or it dies inside the container:
cd playbooks && ln -s ../group_vars group_vars
Second, Semaphore hands its environment values to Ansible as extra vars with lowercased names. If your playbook expects Vault_Path you’re getting vault_path, and nothing warns you. Name your variables lowercase from the start and the problem never exists.
The whole thing is the same principle as keeping a torque spec sheet with the toolbox. Recovering a firewall at 2am is not the time to find out the backup was stale.
