Fortinet SD-WAN Jinja Orchestrator — Part 1: The Two Template Engines

Why this matters

The honest reason most FortiGate SD-WAN deployments end up as a sprawl of half-templated, half-hand-edited boxes is not that the engineers are sloppy. It’s that the config has two very different shapes of repetition in it, and FortiManager gives you two very different tools for them, and most teams reach for the wrong one and then stop because the right one looks intimidating from the outside.

There is a static-shape kind of repetition — every FortiGate in your estate needs the same SNMP block, the same syslog destinations, the same NTP servers, the same admin profiles, the same login banner. That’s a hundred copies of exactly the same lines, with a hostname swapped in. A classic CLI template handles it perfectly in twelve minutes of work.

And there is a variable-shape kind of repetition — every spoke needs an IPSec phase1 per WAN interface per hub, the count of WAN interfaces differs per site, the count of hubs differs per region, the count of regions differs per project. The same IPSec phase1 block appears thirty times in a busy spoke and fifty times in a hub, with addresses, names, and an occasional conditional changing each time. A classic CLI template cannot express that without literally writing thirty copies of the block. A Jinja CLI template handles it in one loop.

The Fortinet CSE team’s sdwan-advpn-reference repository — what they call the “Jinja Orchestrator” — is what variable-shape repetition for SD-WAN/ADVPN looks like when an experienced team has done it for you. It is a starter kit, not a finished product, and across this short series we’re going to read it, understand it, customise it, and integrate it into a FortiManager deployment without either fighting the tool or pretending it solves more than it does.

This first post is the foundation. The thesis is one line, and we’ll spend the rest of the post justifying it: Jinja for shape-varying network plumbing, classic CLI templates for shape-fixed system config, and a real deployment uses both.

The two engines, side by side

FortiManager has shipped two different template engines for some time now, and the distinction is not very prominent in the UI. Both live under Device Manager → Provisioning Templates → CLI Templates. Both produce a stream of FortiOS CLI when you push them to a device. Both can be assigned to a device or to a device group. They are not the same thing.

Classic CLI templates

A classic CLI template is, mechanically, a text file of FortiOS CLI with $(meta_variable_name) substitution. When FortiManager installs it to a device, it takes the per-device value of each meta variable and substitutes it into the text, line by line. There are no loops, no conditionals, no expression evaluation. What you write is what runs, modulo the variable substitution.

A worked example. You want every device to log to the same two syslog servers, with the device’s hostname as the source identifier:

config log syslogd setting
  set status enable
  set server "10.50.0.10"
  set mode reliable
end

config log syslogd2 setting
  set status enable
  set server "10.50.0.11"
  set mode reliable
end

config system global
  set hostname "$(branch_hostname)"
end

Assign this to a device group of 800 spokes. Each spoke has a branch_hostname meta variable (MIL-BR-014, MAD-BR-002, and so on). At install time, FortiManager substitutes the per-device value into the template and pushes the result. Every spoke ends up with the same syslog config, every spoke ends up with the right hostname. Twelve lines of CLI, one meta variable per device, zero engineering ceremony.

This is what a classic CLI template is for. The shape of the config does not vary between devices. Only a handful of values do. The mechanical model — substitute and push — fits the problem exactly.

There are two important things to know about classic CLI templates before we move on. First, the $(variable_name) syntax is the only form of templating they support. There is no condition, no iteration, no arithmetic. Second, meta variables in FortiManager are typeless strings. Whether you intended branch_hostname to be a string, branch_id to be an integer, or is_in_emea to be a boolean, FortiManager stores it as a string and substitutes it verbatim. If your template needs to do anything other than dump the string into a CLI position, you cannot do it in a classic CLI template.

Jinja CLI templates

A Jinja CLI template is the same idea — text in, FortiOS CLI out, push to device — with a real templating engine sitting between input and output. FortiManager 7.0.1 introduced Jinja support; 7.6 ships Jinja 2.11 plus the Ansible ipaddr filter set bolted onto the renderer. You write {% for %}, {% if %}, {% set %}, {{ variable | filter }} exactly as you would in Ansible.

You also get access to a small set of FortiManager-supplied predefined variables that pull from the Device Database. The most useful is DVMDB.name (the device’s name in FortiManager). There is also DVMDB.ip, DVMDB.serial_number, DVMDB.os_ver, and a handful of others depending on release. These let you reference the device’s identity inside the template without needing a per-device meta variable for it.

Same syslog example written as a Jinja CLI template:

config log syslogd setting
  set status enable
  set server "10.50.0.10"
  set mode reliable
end

config log syslogd2 setting
  set status enable
  set server "10.50.0.11"
  set mode reliable
end

config system global
  set hostname "{{ DVMDB.name }}"
end

That works — but notice we just stopped needing the branch_hostname meta variable. FortiManager already knows the device’s name. We pulled it directly from the Device Database via DVMDB.name. One less meta variable to maintain.

That’s a small win. Now look at the kind of template Jinja is actually built for:

config router static
  {% for wan in wan_interfaces %}
  edit {{ loop.index }}
    set device "{{ wan.name }}"
    set gateway {{ wan.subnet | ipaddr('network') | ipaddr('1') | ipaddr('address') }}
    set priority {{ wan.priority }}
  next
  {% endfor %}
end

This generates a static default route per WAN interface, derives the gateway from the WAN’s subnet (first usable host, .1), and assigns a priority. If wan_interfaces is a list of three entries, you get three routes. If it’s six, you get six. The template doesn’t change. The shape of the config does.

In a classic CLI template, you would have to write three copies of the edit … next block, one per WAN, with the gateway hard-coded or pulled from three separate meta variables, and either tolerate empty blocks on devices that don’t have a third WAN or maintain a different template for each shape of device. Jinja makes the count a property of the data, not the template.

The mechanical difference

Classic CLI templates do string substitution. Jinja CLI templates do rendering — they execute a small program whose output is the CLI stream. That distinction is the entire point.

Substitution treats the template as text with named holes. Rendering treats the template as code that, when run against a context, produces text. Substitution is fast, simple, and inflexible. Rendering is more expensive at install time, slightly more error-prone to debug, and roughly infinitely more expressive.

A second consequence, less often discussed: variables you create inside a Jinja template are not the same as FortiManager meta variables. If you write {% set my_thing = "hello" %}, my_thing is a Jinja variable, scoped to that template’s render. It does not show up as a meta variable in the FortiManager UI and it is not visible to other templates. To share state between Jinja templates you use Jinja’s import and include mechanisms, not meta variables.

This matters when you start factoring big templates. We will lean on it in Part 2.

Where Jinja earns its keep — and where it doesn’t

A short, opinionated table. The thesis from the top of this post in concrete form:

Config blockShape across the estatePick this engine
SNMP, syslog, NTP, admin profilesIdenticalClassic CLI
Banner / MOTDIdenticalClassic CLI
Service-account passwords, API usersIdentical (one secret)Classic CLI
Compliance hot-fix (single set)IdenticalClassic CLI
Static routes per WANN-varyingJinja
IPSec phase1/phase2 per overlayN-varying, derived addrJinja
BGP neighbour per hubN-varyingJinja
SD-WAN zones / membersN-varyingJinja
Cert vs PSK selectionConditionalJinja
Multi-VRF transpositionConditional, derivedJinja
Loopback addressing from a baseDerived per deviceJinja (ipaddr)

The rule of thumb is simple. Ask whether the number of CLI blocks in the output depends on something about the device — its interface count, its region, its overlay design. If yes, the template is shape-varying and Jinja is the right engine. If the answer is no — the block always looks the same, only a handful of literal strings inside it change — a classic CLI template is the right engine, and reaching for Jinja is overhead with no benefit.

There is a corollary that gets people in trouble. Don’t re-implement static system config in Jinja just because Jinja is already in your deployment. A twelve-line SNMP template does not become better by being wrapped in Jinja; it becomes harder to read for the next engineer. Use the simpler tool when it fits.

The same task two ways

To make the difference concrete, let’s pick one realistic SD-WAN task and write it both ways. The task is straightforward: every spoke has between one and three WAN underlays (call them ISP1, ISP2, MPLS); each WAN gets an IPSec tunnel to the hub on a fixed UDP port; every tunnel needs the same phase1 shape with phase1 differing only in the local interface, the local-id suffix, and the remote-gw.

Classic CLI

You cannot loop, so you write the worst case — three WAN tunnels — and accept that devices with fewer WANs need a different template, or you write a separate template per shape:

config vpn ipsec phase1-interface
  edit "H1_ISP1"
    set interface "$(isp1_intf)"
    set ike-version 2
    set authmethod psk
    set psksecret "$(spoke_psk)"
    set localid "$(branch_hostname)"
    set remote-gw $(hub_ip_isp1)
  next
  edit "H1_ISP2"
    set interface "$(isp2_intf)"
    set ike-version 2
    set authmethod psk
    set psksecret "$(spoke_psk)"
    set localid "$(branch_hostname)"
    set remote-gw $(hub_ip_isp2)
  next
  edit "H1_MPLS"
    set interface "$(mpls_intf)"
    set ike-version 2
    set authmethod psk
    set psksecret "$(spoke_psk)"
    set localid "$(branch_hostname)"
    set remote-gw $(hub_ip_mpls)
  next
end

This works for the three-WAN site. For a two-WAN site (no MPLS), you either point $(mpls_intf) at a non-existent interface and accept the noise, or you maintain template_2wan alongside template_3wan. For a four-hub topology, the template doubles in length. For two hubs and three WANs, it sextuples. You start cloning templates, and the moment you have two templates that need the same edit, you’ve lost.

Jinja CLI

{% for hub in hubs %}
  {% set hubloop = loop %}
  {% for wan in wan_interfaces if wan.ol_type in hub.overlays %}
config vpn ipsec phase1-interface
  edit "H{{ hubloop.index }}_{{ wan.ol_type }}"
    set interface "{{ wan.intf }}"
    set ike-version 2
    set authmethod psk
    set psksecret "{{ project.psk }}"
    set localid "{{ DVMDB.name }}"
    set remote-gw {{ hub.overlays[wan.ol_type].wan_ip }}
  next
end
  {% endfor %}
{% endfor %}

One template. Any number of WAN interfaces. Any number of hubs. Per-device shape comes from inventory (wan_interfaces), per-hub structure comes from the project definition (hubs). A two-WAN spoke generates two tunnels per hub, a three-WAN spoke generates three; a one-hub region generates one set, a three-hub region generates three. The template doesn’t grow.

This is the exact pattern the Jinja Orchestrator uses in its 02-Edge-Overlay.j2 template — we’ll read the real version in Part 2.

What CLI templates still earn their keep for

I want to be very clear about something: the existence of Jinja does not retire classic CLI templates. They keep working. They are easier to write, easier to debug, easier to hand off, and they are exactly right for a non-trivial fraction of every real-world deployment. A reasonable FortiManager estate uses both engines simultaneously.

What I’d put in classic CLI templates, and would not let a junior engineer talk me out of:

System config that’s identical across every device. SNMP, syslog, NTP, DNS, admin profiles, lockout policy. None of these vary in shape. A handful of strings change per estate, not per device. Put them in a classic CLI template, assign to a global device group, and forget about them. The cognitive overhead of Jinja here is pure cost.

Compliance hot-fixes. “Auditor says we need set login-banner-message on every box by Friday.” Six lines of CLI. Push to all-devices group via a classic CLI template. Do not build a Jinja module for it.

Pre-existing CLI templates that already work. If you inherit a deployment with thirty classic CLI templates that have been tested for two years, the right move is to leave them alone and layer Jinja on top of the SD-WAN/ADVPN parts only. Migrating working things for tidiness costs an outage budget you don’t get back.

One-off execute commands. execute log filter blocks for diagnostics, execute backup runs — these aren’t part of the device’s running config, and they’re shape-invariant. Classic CLI handles them fine.

The mental model I encourage: Jinja is the scaffold for the SD-WAN/ADVPN config — overlay, routing, the lot — and classic CLI templates fill the gaps that scaffold cannot, or shouldn’t, reach. They aren’t in competition. They sit next to each other on the device, assembled by FortiManager at install time, and the device doesn’t care which engine produced which lines.

A note on the split philosophy

There’s a temptation, especially in a greenfield deployment, to push everything into Jinja “for consistency”. Resist it. The split is not arbitrary. Shape-varying config benefits from a code-like engine because its shape is itself data. Shape-fixed config doesn’t, because there is no shape to express. Treating both the same way doesn’t reduce complexity; it just hides one kind of cost under another kind.

The Jinja Orchestrator codifies this split by scope — its templates are scoped entirely to underlay, overlay, and routing on a device that’s playing a defined role in an SD-WAN topology. It deliberately stops there. SNMP, admin profiles, login banners — none of those are in the Orchestrator. Fortinet’s documentation specifically notes that the Orchestrator’s optional firewall-policy and SD-WAN templates are recommended only when running offline without FortiManager; in a FortiManager deployment, those parts belong elsewhere (policy packages, header/footer templates, or classic CLI templates as appropriate). The boundary the repo draws is the right boundary. Imitate it.

What’s next

Part 2 of this series walks through the Jinja Orchestrator itself, end to end. We’ll open dynamic-bgp-on-lo/, read the four reference Project Templates that ship in release/7.6, understand the inventory contract that feeds them, and dissect the three Jinja patterns the templates lean on heaviest. By the end of Part 2 you’ll be able to look at a rendered config and trace every block back to the template that produced it.

Part 3 takes the most common production customisation — switching from PSK to certificate-based IPSec authentication, with FortiManager itself acting as the certificate authority — and walks it end to end. By the end of Part 3 you should have an honest answer to when the reference fits as-is, when to customise inventory only, when to fork the templates, and when a small classic CLI template is the right tool to fill the remaining gap.

If anything in this post landed wrong — especially the bit about not re-implementing system config in Jinja — push back. The split is the load-bearing claim of the rest of the series.