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 0Perfect, 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 LOOPThat 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
{% if intf.dhcp %}ip address dhcp{% else %}ip address {{ intf.ip }} {{ intf.mask }}{% endif %} - render different config based on the data.
{{ hostname | upper }}, {{ vlans | join(',') }} - transform values inline.
{{ mtu | default(1500) }} - a fallback when the variable is absent.
{%- ... -%} 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.