Network Automation

Jinja2 Templates for Network Configs: From Variables to Rendered CLI

Jinja2 pipeline - YAML variables rendered to CLI and pushed to a live IOS XE router with Netmiko
In: Network Automation, Labs, CCIE

Copying a router config, pasting it, and changing the IP addresses by hand is how configuration errors are born. Jinja2 templating replaces that with a template plus a data file: write the structure once, describe each device in a few variables, and render perfect, consistent configuration every time. This is the single most useful automation skill for a network engineer, and unlike some of this cluster, it runs on any platform and needs nothing exotic.

This article builds the complete pipeline - YAML variables, a Jinja2 template, rendered CLI, and a Netmiko push to a live router - all captured for real from a CML lab. It extends the network automation guide.

The idea: separate structure from data

Every device of a given type shares the same structure - the same interfaces, the same protocols, the same command hierarchy. What differs is the data: the hostname, the IPs, the site ID. Templating separates the two:

  • The template is the config structure with placeholders. Written once, reviewed once.
  • The data (variables) describes each specific device. Small, readable, per-device.
  • The rendered output is the template with the data filled in - the actual config to push.

Get the template right once, and every device rendered from it is correct by construction. No copy-paste drift, no forgotten line, no fat-fingered subnet mask.

The data: YAML variables

YAML is the natural format for the variables - human-readable, structured, and what Ansible and most automation tooling expect. From the lab, describing two loopbacks and an OSPF process:

loopbacks:
  - {id: 100, ip: 172.20.100.1, mask: 255.255.255.0, desc: JINJA-AUTOMATION-100}
  - {id: 101, ip: 172.20.101.1, mask: 255.255.255.0, desc: JINJA-AUTOMATION-101}
ospf:
  process: 10
  networks:
    - {net: 172.20.100.0, wild: 0.0.0.255, area: 0}
    - {net: 172.20.101.0, wild: 0.0.0.255, area: 0}

That is the whole per-device description. Readable, reviewable, and trivial to keep in version control - a diff on this file shows exactly what changed about the device.

The template: Jinja2

Jinja2 is a templating language with loops, conditionals, and variable substitution. The template mirrors the config structure, iterating over the data:

{% for lo in loopbacks %}
interface Loopback{{ lo.id }}
 description {{ lo.desc }}
 ip address {{ lo.ip }} {{ lo.mask }}
{% endfor %}
router ospf {{ ospf.process }}
{% for n in ospf.networks %} network {{ n.net }} {{ n.wild }} area {{ n.area }}
{% endfor %}

{{ }} substitutes a variable; {% for %} loops. The template says "for each loopback in the data, emit an interface block" - so whether the data has two loopbacks or two hundred, the template is the same. That loop is the leverage: one template, any number of instances.

The rendered output

Feed the YAML data through the Jinja2 template and out comes real, pushable CLI - captured from the lab:

interface Loopback100
 description JINJA-AUTOMATION-100
 ip address 172.20.100.1 255.255.255.0

interface Loopback101
 description JINJA-AUTOMATION-101
 ip address 172.20.101.1 255.255.255.0

router ospf 10
 network 172.20.100.0 0.0.0.255 area 0
 network 172.20.101.0 0.0.0.255 area 0

Perfect, consistent, and generated - not typed. The Python that does the rendering is three lines:

import yaml, jinja2
data = yaml.safe_load(open('vars.yml'))
rendered = jinja2.Template(open('template.j2').read()).render(**data)

The push: Netmiko to a live device

Rendering is half the pipeline. The other half is getting it onto the router. Netmiko - a Python library that wraps SSH to network devices - takes the rendered config and pushes it, then reads back the result. From the lab, driving a real IOS XE router:

from netmiko import ConnectHandler
dev = {"device_type":"cisco_ios","host":"192.168.99.1",
       "username":"plauto","password":"..."}
conn = ConnectHandler(**dev)
out = conn.send_config_set(rendered.splitlines())
print(conn.send_command("show ip interface brief | include Loopback10"))

And the device, after the push, verified by the same script:

Loopback100  172.20.100.1  YES manual up  up
Loopback101  172.20.101.1  YES manual up  up

Interface  PID  Area  IP Address/Mask     Cost  State
Lo100      10   0     172.20.100.1/24     1     LOOP
Lo101      10   0     172.20.101.1/24     1     LOOP

That is the full pipeline, end to end, real: YAML data → Jinja2 render → Netmiko push → live config → verified back from the device. No hand-typing at any step. And critically, this runs on any platform - the router just needs SSH. No Guest Shell, no IOx, no DMI. It is the automation that works everywhere.

Why this pipeline is the foundation

Everything more advanced builds on this pattern:

  • Ansible is essentially this pipeline with structure, inventory, and idempotency added - it uses Jinja2 templates and pushes over SSH/NETCONF exactly like this.
  • CI/CD for networks is this pipeline in a git repository with a test stage - the YAML vars are reviewed in a pull request, rendered, tested, and pushed by a pipeline.
  • Infrastructure as code is the philosophy this embodies: the network's config is defined by data files in version control, and rendered/pushed by tooling, not typed by hand.

Master this small pipeline and you understand the mechanism behind all of it. The advanced tools are conveniences layered on this exact idea.

Practical Jinja2 patterns

Conditionals {% if intf.dhcp %}ip address dhcp{% else %}ip address {{ intf.ip }} {{ intf.mask }}{% endif %} - render different config based on the data.
Filters {{ hostname | upper }}, {{ vlans | join(',') }} - transform values inline.
Defaults {{ mtu | default(1500) }} - a fallback when the variable is absent.
Whitespace control {%- ... -%} trims whitespace - important for clean config output without stray blank lines.

Guardrails

  • Review the rendered output before pushing. Render to a file, eyeball it (or diff it against the current config), then push. A template bug rendered across a hundred devices is a hundred-device outage.
  • Version-control the data and templates. The whole point is that config changes are data changes, reviewable in a pull request.
  • Test with a lab device first. Push to one router, verify, then roll out - exactly as we did here.
  • Keep templates modular. One template per function (interfaces, routing, security) composed together, rather than one giant template - easier to review and reuse.

Key takeaways

  • Jinja2 templating separates config structure (the template, written once) from data (per-device variables), so every rendered config is correct by construction.
  • YAML holds the variables - readable and version-controllable. Jinja2 holds the template, with loops and conditionals. Netmiko pushes the rendered config over SSH.
  • Verified end to end in the lab: YAML → Jinja2 render → Netmiko push → live config → verified back from a real IOS XE router.
  • This pipeline runs on any platform - it just needs SSH. No Guest Shell, no IOx, no NETCONF required.
  • It is the foundation for Ansible, network CI/CD, and infrastructure-as-code - all of which are this exact pattern with more structure around it.
  • Always render to a file and review before pushing; version-control the data and templates; test on a lab device first.

Next: JSON vs XML vs YAML for network engineers - one payload, three ways. The full cluster index lives on the network automation pillar.

Written by
More from Ping Labz
Great! You’ve successfully signed up.
Welcome back! You've successfully signed in.
You've successfully subscribed to Ping Labz.
Your link has expired.
Success! Check your email for magic link to sign-in.
Success! Your billing info has been updated.
Your billing was not updated.