Back Up IOS XE Configs to Git with Ansible (and Get Diffs Worth Reading)

ios_config can save a running config in one task. Getting a Git history you can actually read takes three more: strip the header lines, stage, and commit only when there is a real change. Real runs against six IOS XE devices, including an out-of-band change caught and rolled back.

Lab diagram: Five routers and one switch (br1-acc1) on 10.66.10.0/24 (hq-core1, hq-core2, hq-edge1, br1-edge1, br1-acc1, br2-edge1) feed ios_config backup, then strip header lines (Building c

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.

Lab diagram: Five routers and one switch (br1-acc1) on 10.66.10.0/24 (hq-core1, hq-core2, hq-edge1, br1-edge1, br1-acc1, br2-edge1) feed ios_config backup, then strip header lines (Building configuration, Current configuration, Last configuration change, NVRAM config last updated), then git diff --cached --quiet: rc 0 skip, rc 1 commit. The history shows 946ad02 Baseline backup, aec7f70 drift (description CORE: hq-core2 Et0/1 changed to CORE hq-core2 Et0/1 10G planned), and 136e653 restored by the part 4 push, with a dashed return to hq-core1. Keep the repo private: secret 9 hashes are in every file.
Backup flow into Git and the drift commit it caught

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.

Read next