> ## Content Index
> Fetch the complete content index at: https://www.pinglabz.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Jinja2 Templates for IOS XE Configs with Ansible: Render, Review, Push
- URL: https://www.pinglabz.com/ansible-jinja2-templates-ios-config/
- Published: 2026-10-31T18:49:38.000Z
- Updated: 2026-10-31T19:08:14.000Z
- Description: 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.
- Author: Jaime
- Tags: Network Automation, Cisco IOS XE, Cisco

This is part 4 of the [Ansible for network engineers series](https://www.pinglabz.com/ansible-network-automation/). Part 2 built an [inventory with group\_vars and host\_vars](https://www.pinglabz.com/ansible-network-inventory-site-role/) for six IOS XE devices (five routers and one L2 switch), and [part 3](https://www.pinglabz.com/ansible-resource-module-states-merged-replaced-overridden-deleted/) 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](https://www.pinglabz.com/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.](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/10/ansible-jinja2-render-push.png)](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/10/ansible-jinja2-render-push.png?ref=pinglabz.com)

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.

```jinja
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:

```yaml
# 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.

```yaml
---
- 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:

```text
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:

```jinja
{% 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:

```text
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:

```text
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:

```text
--- 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:

```yaml
---
- 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

```

```text
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:

```text
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:

```text
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:

```text
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](https://www.pinglabz.com/ansible-resource-module-states-merged-replaced-overridden-deleted/)), 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.