This is part 5 of the Ansible for network engineers series. In part 4 I pushed rendered configs to six IOS XE devices. Before pushing more changes, I want a copy of every running config and a history of what changed and when. Git gives you the history for free, as long as the files you commit only change when the config does.
Built in my home lab with ansible-core 2.21.5, cisco.ios 11.6.0, ansible.netcommon 8.7.1, Python 3.14 and git 2.53.0, against six IOS XE 17.18.2 devices: five routers and one L2 switch. The inventory is the one from part 2.

One task to save the config
ios_config has a backup option. With backup_options you choose the folder and file name. Without them you get a timestamped file per run in a backup/ folder next to the playbook, which is useless for Git because every run creates a new file.
---
- name: Back up running configs
hosts: all
gather_facts: false
vars:
backup_root: "{{ playbook_dir }}/../backups"
tasks:
- name: Save running config
cisco.ios.ios_config:
backup: true
backup_options:
dir_path: "{{ backup_root }}"
filename: "{{ inventory_hostname }}.cfg"
First run:
br1-acc1 : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
br1-edge1 : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
br2-edge1 : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
hq-core1 : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
hq-core2 : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
hq-edge1 : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Each file starts like this:
Building configuration...
Current configuration : 2076 bytes
!
! Last configuration change at 18:47:46 UTC Sat Oct 31 2026 by netauto
!
version 17.18
service timestamps debug datetime msec
service timestamps log datetime msec localtime
!
hostname hq-core1
I turned the folder into a Git repo and committed it as a baseline. Running the backup again right away gave changed=0 on all six, because the module compares the new content with the existing file and leaves it alone when nothing differs.
The diff after a real change
To test it I changed one description on hq-core1 outside the normal workflow, the way someone might during a maintenance window:
interface Ethernet0/1
description CORE hq-core2 Et0/1 10G planned
After another backup, git diff showed the change, plus two lines I didn't care about:
-Current configuration : 2076 bytes
+Current configuration : 2087 bytes
!
-! Last configuration change at 18:47:46 UTC Sat Oct 31 2026 by netauto
+! Last configuration change at 18:50:45 UTC Sat Oct 31 2026 by netauto
...
interface Ethernet0/1
- description CORE: hq-core2 Et0/1
+ description CORE hq-core2 Et0/1 10G planned
ip address 10.66.101.1 255.255.255.252
With one change that's fine. Across a fleet, every changed router adds the same two lines of noise to every commit, and the byte count makes diffs harder to scan. After a write memory you can also get a ! NVRAM config last updated line. I strip all of these before committing.
Strip, stage, commit
The full playbook has two plays. The first saves and cleans each file. The second runs once on the control node and commits only if something is staged.
---
- name: Back up running configs
hosts: all
gather_facts: false
vars:
backup_root: "{{ playbook_dir }}/../backups"
tasks:
- name: Save running config
cisco.ios.ios_config:
backup: true
backup_options:
dir_path: "{{ backup_root }}"
filename: "{{ inventory_hostname }}.cfg"
- name: Strip lines that change on every save
ansible.builtin.lineinfile:
path: "{{ backup_root }}/{{ inventory_hostname }}.cfg"
regexp: "{{ item }}"
state: absent
loop:
- "^Building configuration"
- "^Current configuration :"
- "^! Last configuration change"
- "^! NVRAM config last updated"
delegate_to: localhost
- name: Commit to Git
hosts: localhost
gather_facts: false
vars:
backup_root: "{{ playbook_dir }}/../backups"
tasks:
- name: Stage backups
ansible.builtin.command: git add -A
args:
chdir: "{{ backup_root }}"
changed_when: false
- name: Check for staged changes
ansible.builtin.command: git diff --cached --quiet
args:
chdir: "{{ backup_root }}"
register: staged
failed_when: staged.rc > 1
changed_when: false
- name: Commit
ansible.builtin.command: >-
git commit -m "Config backup {{ now(utc=true).strftime("%Y-%m-%d %H:%M") }} UTC"
args:
chdir: "{{ backup_root }}"
when: staged.rc == 1
git diff --cached --quiet exits 0 when nothing is staged and 1 when something is. Running git commit with nothing staged exits non-zero and fails the play, so the check keeps scheduled runs from going red on quiet days. Set user.name and user.email in the repo's own config so the commit works under cron or CI.
The first run committed the header cleanup and the description change together:
br1-acc1.cfg | 3 ---
br1-edge1.cfg | 3 ---
br2-edge1.cfg | 3 ---
hq-core1.cfg | 5 +----
hq-core2.cfg | 3 ---
hq-edge1.cfg | 3 ---
6 files changed, 1 insertion(+), 19 deletions(-)
Is it idempotent?
Partly. With no config change, the commit was skipped:
TASK [Commit] ******************************************************************
skipping: [localhost]
But every device reported changed=2:
br1-acc1 : ok=2 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
hq-core1 : ok=2 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
The backup writes the header lines back, the module sees a file that differs from the stripped one, and lineinfile removes them again. The Git history stays clean, which is what matters, but the recap won't tell you whether anything changed. Git will. If you want a clean recap as well, write the backup to a temporary file, strip it there, and copy it into the repo with ansible.builtin.copy, which only reports a change when the content differs.
Using the history to catch drift
Now the out-of-band description is in the history, and I know exactly which router and line changed. Part 4's template says Ethernet0/1 should be CORE: hq-core2 Et0/1, so rerunning that push puts it back. Its check output showed one line to fix:
"push.updates": [
"interface Ethernet0/1",
"description CORE: hq-core2 Et0/1"
]
After the push and another backup, git log -p for hq-core1 tells the whole story:
136e653 Config backup 2026-10-31 18:52 UTC
aec7f70 Config backup 2026-10-31 18:51 UTC
946ad02 Baseline backup
commit 136e6533c6e46b30f8f8b538a202917f3ceb79ea
Author: netauto <noc@example.net>
Date: Sat Oct 31 18:52:22 2026 +0000
Config backup 2026-10-31 18:52 UTC
diff --git a/hq-core1.cfg b/hq-core1.cfg
@@ -93,7 +93,7 @@ interface Ethernet0/0
ip address 10.66.10.11 255.255.255.0
!
interface Ethernet0/1
- description CORE hq-core2 Et0/1 10G planned
+ description CORE: hq-core2 Et0/1
ip address 10.66.101.1 255.255.255.252
git diff 946ad02 HEAD now shows only the header cleanup, which confirms the router is back where it started. For a router you can't remember the state of, git log -p -- hq-core1.cfg is a faster answer than opening a ticket history.
Keep the repo private
These files are full running configs. Mine include enable secret 9 and username ... secret 9 lines, SNMP settings and every interface address. Type 9 hashes are much harder to crack than type 7, but they shouldn't end up on a public GitHub repo either. Keep the backup repo private, separate from your playbook repo, and if you share configs, scrub the secret lines first. If your configs contain type 7 passwords or SNMP community strings, treat the repo as a secrets store.
Key takeaways
Set backup_options with a fixed filename so each device has one file Git can track. Strip Building configuration, Current configuration, Last configuration change and NVRAM config last updated before committing. Guard the commit with git diff --cached --quiet so quiet runs don't fail. Expect the recap to say changed even when Git has nothing new, and trust the Git history instead. Keep the repo private.
Next in the series: a troubleshooting casebook of nine real Ansible failures on IOS XE.