Jinja2 Templates for IOS XE Configs with Ansible: Render, Review, Push

One Jinja2 template, six IOS XE devices, real output. The first render failed on five of six hosts, diff_against showed a wall of fake removals, and the useful review came from a different place.

Lab diagram: Inventory data (group_vars/all, site groups, edge role, host_vars; ntp 10.66.0.123 / 10.66.20.123, syslog 10.66.0.51 / 10.66.21.51, +10.66.0.52 for edge) is rendered by templates

This is part 4 of the Ansible for network engineers series. Part 2 built an inventory with group_vars and host_vars for six IOS XE devices (five routers and one L2 switch), and part 3 used resource modules to manage interface addressing. Here I take that same inventory data and turn it into a config file per device with a Jinja2 template, then push it.

If you want Jinja2 syntax on its own first (loops, filters, conditionals), I covered that in Jinja2 network config templates. This post is about the Ansible side: where the variables come from, what breaks, and how to check a rendered file before it reaches a router.

Built in my home lab with ansible-core 2.21.5, cisco.ios 11.6.0, ansible.netcommon 8.7.1, Jinja2 3.1.6 and Python 3.14, against six IOS XE 17.18.2 devices: five routers and one L2 switch.

Lab diagram: Inventory data (group_vars/all, site groups, edge role, host_vars; ntp 10.66.0.123 / 10.66.20.123, syslog 10.66.0.51 / 10.66.21.51, +10.66.0.52 for edge) is rendered by templates/ios-base.j2 into build/<host>.cfg on the control node, reviewed in check mode with push.updates (seven lines for hq-core1), pushed with ios_config src, and rerun with changed=0. A greyed side branch, diff_against: intended, shows every other line as removed against a partial file. ios_config only adds: the 10.66.199.1 secondary stays.
Render, review, push: one template for six devices

The template

The template lives at templates/ios-base.j2. It builds the parts of a config that every router shares: hostname, NTP, syslog, SNMP location and contact, a loopback, and descriptions on the core links.

hostname {{ inventory_hostname }}
!
ntp server {{ ntp_server }}
{% for host in [syslog_server] + role_syslog_servers %}
logging host {{ host }}
{% endfor %}
snmp-server location {{ site_location }}
snmp-server contact {{ snmp_contact }}
!
interface Loopback0
 description {{ site_code }} {{ device_role }} loopback
 ip address 10.66.255.{{ ansible_host.split(".")[-1] }} 255.255.255.255
{% for link in links %}
!
interface {{ link.port }}
 description {{ link.desc }}
{% endfor %}

Every variable here already exists from part 2, except links. ntp_server, syslog_server and snmp_contact come from group_vars/all. Branches override ntp_server, br1 overrides syslog_server, and the edge group adds a second syslog host through role_syslog_servers. The default syslog_server in group_vars/all is 10.66.0.51, so every device outside br1 logs there. site_location comes from the site group, with one host_vars override on hq-edge1. The loopback address is derived from the last octet of the management IP, so hq-core1 at 10.66.10.11 gets 10.66.255.11.

The link descriptions are per device, so they go in host_vars. I only wrote them for hq-core1 to start:

# site-inv/host_vars/hq-core1.yml
links:
  - port: Ethernet0/1
    desc: "CORE: hq-core2 Et0/1"
  - port: Ethernet0/2
    desc: "CORE: hq-edge1 Et0/1"

Rendering

Rendering runs on the control node, so the task delegates to localhost. Nothing touches a router yet.

---
- name: Render IOS base configs from inventory data
  hosts: all
  gather_facts: false
  tasks:
    - name: Render one config file per device
      ansible.builtin.template:
        src: ../templates/ios-base.j2
        dest: "../build/{{ inventory_hostname }}.cfg"
        mode: "0644"
      delegate_to: localhost

The first run failed on five of six hosts:

fatal: [hq-core2 -> localhost]: FAILED! => {"changed": false, "msg": "Task failed: 'links' is undefined"}
fatal: [br1-edge1 -> localhost]: FAILED! => {"changed": false, "msg": "Task failed: 'links' is undefined"}
fatal: [hq-edge1 -> localhost]: FAILED! => {"changed": false, "msg": "Task failed: 'links' is undefined"}
fatal: [br2-edge1 -> localhost]: FAILED! => {"changed": false, "msg": "Task failed: 'links' is undefined"}
fatal: [br1-acc1 -> localhost]: FAILED! => {"changed": false, "msg": "Task failed: 'links' is undefined"}
changed: [hq-core1 -> localhost]

Only hq-core1 had links defined. Ansible fails hard on an undefined variable in a template, which I'd rather have than a silently empty config. When a variable really is optional, say so in the template:

{% for link in links | default([]) %}

I could also set links: [] in group_vars/all. I prefer the default in the template here because it keeps the optional part visible where it's used. For values that must exist, like ntp_server, leave the default off so a missing value stops the run.

After the fix all six rendered:

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=0    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

hq-core1 shows changed=0 because its file from the failed run was already correct.

Check the whitespace before you push

Jinja2 tag lines can leave blank lines or stray spaces behind. I ran the files through cat -A, which marks each line end with $, so trailing spaces and empty lines are easy to spot. Here is br1-edge1:

hostname br1-edge1$
!$
ntp server 10.66.20.123$
logging host 10.66.21.51$
logging host 10.66.0.52$
snmp-server location Branch 1 comms closet$
snmp-server contact noc@example.net$
!$
interface Loopback0$
 description BR1 edge loopback$
 ip address 10.66.255.21 255.255.255.255$

No blank lines where the {% for %} tags were. Ansible's template module sets trim_blocks on by default, which removes the newline after a block tag. Plain Jinja2 outside Ansible doesn't, so the same template rendered in a Python script will look different. You can also see the inheritance working: the branch NTP server, the br1 syslog override, and the second syslog host from the edge role.

The interface lines keep their single leading space. That matters for the push, as you'll see next.

Reviewing the change

This is where I expected one tool to work and a different one did.

My first idea was ios_config with diff_against: intended, pointing intended_config at the rendered file and running with --diff. The diff came back as roughly fifty lines of removals: the version line, AAA, users, the VTY lines, the banner, and every interface the template doesn't mention. A short sample:

--- before
+++ after
@@ -1,64 +1,12 @@
-version 17.18
-service timestamps debug datetime msec
-service timestamps log datetime msec localtime
 hostname hq-core1
-boot-start-marker
-boot-end-marker
...
-ntp server 10.66.0.123
-end
+interface Loopback0
+ description HQ core loopback
+ ip address 10.66.255.11 255.255.255.255

None of those removals would happen. ios_config adds lines; it doesn't delete what you didn't mention. The diff compares the whole running config against a partial file, so it reports everything missing from the file as a removal. It's useful when the rendered file is a full config. For a partial template it's noise, and it also prints the full running config, password hashes included, into your terminal or CI log.

The view that actually answered "what will this push" was the module's own updates return value in check mode:

---
- name: Push the rendered configs
  hosts: all
  gather_facts: false
  tasks:
    - name: Apply build/<host>.cfg
      cisco.ios.ios_config:
        src: "../build/{{ inventory_hostname }}.cfg"
      register: push
      notify: Save config

    - name: Lines that differ from the running config
      ansible.builtin.debug:
        var: push.updates

  handlers:
    - name: Save config
      cisco.ios.ios_config:
        save_when: modified
ok: [hq-core1] => {
    "push.updates": [
        "interface Loopback0",
        "description HQ core loopback",
        "ip address 10.66.255.11 255.255.255.255",
        "interface Ethernet0/1",
        "description CORE: hq-core2 Et0/1",
        "interface Ethernet0/2",
        "description CORE: hq-edge1 Et0/1"
    ]
}
ok: [br1-edge1] => {
    "push.updates": [
        "interface Loopback0",
        "description BR1 edge loopback",
        "ip address 10.66.255.21 255.255.255.255"
    ]
}

That's the real change set. hostname, NTP, logging and SNMP were already on the routers from part 2, so ios_config skipped them. On hq-core1 it would add the loopback and replace two old descriptions ("to hq-core2" and "spare - old WAN").

Two warnings showed up on every run. One says the input lines should match the running config's indentation, which is why the leading space on interface sub-commands matters. The other is a deprecation notice: in ansible.netcommon, passing a template through src is going away (after 2028-01-01). Here the file is already rendered, so nothing is lost, but it's another reason to keep rendering in ansible.builtin.template and treat src as a plain file.

Push and rerun

The real run changed all six devices and fired the save handler:

br1-acc1                   : ok=3    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
br1-edge1                  : ok=3    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
br2-edge1                  : ok=3    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
hq-core1                   : ok=3    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
hq-core2                   : ok=3    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
hq-edge1                   : ok=3    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

The second run is the proof that the rendered text matches how IOS XE stores it:

br1-acc1                   : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
br1-edge1                  : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
br2-edge1                  : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
hq-core1                   : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
hq-core2                   : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
hq-edge1                   : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

If a template writes a line differently from how the device stores it (an abbreviated keyword, a mask in another format), the rerun keeps reporting changed forever. Running twice is the quickest way to catch that.

What the template didn't fix

Here's hq-core1 afterwards:

interface Loopback0
 description HQ core loopback
 ip address 10.66.255.11 255.255.255.255
interface Ethernet0/1
 description CORE: hq-core2 Et0/1
 ip address 10.66.101.1 255.255.255.252
interface Ethernet0/2
 description CORE: hq-edge1 Et0/1
 ip address 10.66.199.1 255.255.255.0 secondary
 ip address 10.66.102.1 255.255.255.252

The descriptions are right. The leftover 10.66.199.1 secondary from the brownfield seed in part 3 is still on Ethernet0/2, because a template pushed through ios_config only adds. If you want the device to match the data exactly, that's a job for a resource module with replaced or overridden (with the cautions from part 3), or a full-config replace. I keep templates for the parts of a config that are easy to state as text and resource modules for the parts where removal matters.

Key takeaways

Render on the control node with ansible.builtin.template and look at the files before pushing. Use default() only on variables that are truly optional, so a missing required value still stops the run. Review a partial template with ios_config's updates in check mode instead of diff_against: intended, which only makes sense against a full config. Run the push twice and expect changed=0 the second time. And remember that ios_config never removes lines you left out.

Next in the series: backing up these configs to Git so every change has a readable history.

Read next