Fortinet SD-WAN Jinja Orchestrator — Part 2: Anatomy and Patterns

Recap and where we’re going

In Part 1 we drew a line: FortiManager runs two distinct template engines, and SD-WAN/ADVPN is the canonical use case for Jinja because its config has variable shape per device. Fortinet ship a reference implementation of that variable-shape config — the sdwan-advpn-reference repository, branded internally as the “Jinja Orchestrator”.

This post opens that repo and reads it. We’ll cover the directory layout, the two ways the same templates get consumed (FortiManager-native vs offline rendering), the supported overlay design on the release/7.6 branch, the four reference Project Templates that ship there, the inventory contract that drives them, and three Jinja patterns you’ll see repeated everywhere once you know how to look. The goal by the end is that you can open a rendered FortiGate config and point at every block on the page and say “that came from this template, driven by this inventory variable, conditional on that flag”.

What’s in the repo

The release/7.6 branch lays out like this:

sdwan-advpn-reference/
├── README.md
├── render_config.py           ← Python offline renderer
├── inventory_from_csv.py      ← CSV → JSON inventory converter
└── dynamic-bgp-on-lo/         ← The actual templates
    ├── 01-Edge-Underlay.j2
    ├── 01-Hub-Underlay.j2
    ├── 02-Edge-Overlay.j2
    ├── 02-Hub-Overlay.j2
    ├── 03-Edge-Routing.j2
    ├── 03-Hub-Routing.j2
    ├── 04-Edge-InterVRF.j2
    ├── 04-Hub-MultiRegion.j2
    ├── 05-Hub-IntraRegion.j2
    ├── 06-Hub-InterVRF.j2
    ├── 07-Hub-Services.j2
    ├── optional/
    │   ├── 11-Edge-SDWAN.j2
    │   ├── 11-Hub-SDWAN.j2
    │   ├── 12-Edge-Firewall.j2
    │   └── 12-Hub-Firewall.j2
    ├── projects/              ← Project Templates and inventory files
    │   ├── Project.singlehub.generic.nocert.j2
    │   ├── Project.singlehub.nocert.j2
    │   ├── Project.dualreg.cert.j2
    │   ├── Project.dualreg.mixed.nocert.j2
    │   ├── Project.dualreg.multivrf.nocert.j2
    │   ├── inventory.singlehub.generic.json
    │   ├── inventory.singlehub.json
    │   ├── inventory.dualreg.json
    │   └── …
    └── rendered/              ← Pre-rendered example outputs
        ├── single_hub/
        ├── deployment_guide/
        ├── mixed/
        └── multi_vrf/

A few things to note before we dig in. The directory name dynamic-bgp-on-lo is not a folder of one design — it’s the name of the overlay routing pattern that this whole tree implements. “Dynamic BGP on Loopback (unified)” is the only overlay design Fortinet’s reference materials track on 7.6, and the rest of the directory structure is layered customisation on top of that pattern. If you came here looking for “BGP per overlay” or the old “BGP on Loopback” non-unified design, those exist on older branches (release/7.2 and friends) and you should treat them as reference, not drop-in.

The 01- through 07- templates are the device-type templates, split by role (Edge or Hub) and by concern (Underlay, Overlay, Routing, plus a handful of cross-cutting templates for multi-region, inter-VRF, and hub-specific services). The Project Template (Project.*.j2) glues them together for a particular topology and is the one file you’re expected to edit.

The optional/ subdirectory contains SD-WAN and firewall-policy templates. Fortinet’s own README is explicit: those are recommended only when rendering offline (i.e., when you’re not using FortiManager at all). Inside FortiManager you’d use Policy Packages and FMG’s own SD-WAN templates for those concerns, and let the Jinja Orchestrator focus on the network plumbing.

Two ways to consume the same templates

The clever thing about the Orchestrator is that the same *.j2 files render in two completely different environments without modification.

FortiManager-native

FortiManager 7.0.1 and later ships a Jinja engine inside the product. You import a Project Template as a Jinja CLI template, supply meta variables (per-device or shared), and FMG renders against them at install time. The output stream goes straight into the device’s running config the way any other template install does. You see it in the Install Preview, you can stage it, you can roll back if you used a revision-controlled ADOM.

This is the mode I recommend. The reasons:

  • Per-device state already lives in FMG (serial, hostname, address). Pulling it via DVMDB.* predefined variables removes inventory drift.
  • Pushes are auditable. Every install creates a record. Every rendered config is comparable to the previous one.
  • The Jinja Orchestrator slots in alongside the other things FMG does well — Policy Packages for firewall policy, header/footer templates for system config, classic CLI templates for compliance hot-fixes. You don’t have to choose one tool to do everything.
  • Recovery is sane. If a render breaks a spoke, you re-render and re-install from the same source of truth, you don’t go hunting for the last good text file.

Offline rendering

The repo ships a Python renderer (render_config.py) and a CSV→JSON inventory tool (inventory_from_csv.py). The prerequisites are minimal:

pip3 install jinja2 ansible netaddr

And the invocation is direct:

./render_config.py -p dynamic-bgp-on-lo/projects/Project.dualreg.cert.j2 \
                   -i dynamic-bgp-on-lo/projects/inventory.dualreg.json

The renderer walks the inventory, picks each Hub and each Edge in turn, and writes a plain-text FortiOS configuration file per device under out/ (or wherever you point -o). You then paste that into the FortiGate, or stage it via System → Configuration → Scripts, or — if you’re so inclined — push it over SSH with a small wrapper.

Two cases where offline rendering earns its keep even if you primarily use FMG:

  1. Lab validation. You’re changing the Project Template. You want to see what the diff looks like across three representative devices before you touch FMG. Render offline against a tiny inventory, diff the output. Five minutes, no risk to production.
  2. Greenfield bootstrap. You’re standing up a brand-new FMG and you don’t have model devices yet. You can render the initial configs offline, paste them, then bring those devices under FMG control and switch to native rendering from there.

The offline renderer differs from the FMG one in one important way: it must cover the entire config because there’s no FortiManager to layer SNMP/admin/policy on top. That’s why the optional/ SD-WAN and firewall templates exist — they’re the offline-only complement. In a FortiManager deployment you don’t render them.

The 7.6 design — Dynamic BGP on Loopback (unified)

The release/7.6 branch supports exactly one overlay design as the recommended path: Dynamic BGP on Loopback (unified). The word “unified” is doing work. Earlier 7.2-era designs split into “BGP on Loopback” and “BGP per Overlay” and “Multi-VRF BGP on Loopback” — different code paths, different routing patterns, different operational gotchas. The 7.4+ unified design folds them into one Project Template structure that supports:

  • RR-based and RR-less ADVPN, in the same topology if you need to.
  • ADVPN 2.0 (the dynamic-tunnel idle-timeout improvements; default-on in the current templates).
  • Multi-VRF (segmentation over a single overlay, optionally with Internet access).
  • Mixed deployments — some sites RR, some not, some single-VRF, some multi-VRF.
  • 7.4+ Non-Root VDOM support and CRL distribution on Hubs.

The “Dynamic BGP” half of the name refers to the BGP-neighbour-discovery pattern: spokes peer with hubs over the loopback rather than over individual overlay tunnels, and set neighbor-range on the hubs accepts spokes from a defined loopback summary instead of having one neighbour configured per spoke. That single decision is what makes the design scale to thousands of spokes without rewriting hub config on every onboard.

All of which means: if you start from this design, you’re starting from where Fortinet’s CSEs converged after several years of trying the alternatives.

The four reference Project Templates

The projects/ directory ships four end-to-end examples. Pick the one closest to your topology and customise. The README maps them like this:

SubfolderProject TemplateInventoryWhat it demonstrates
single_hubProject.singlehub.generic.nocert.j2inventory.singlehub.generic.jsonOne Hub, two Spokes — the minimum viable example
deployment_guideProject.dualreg.cert.j2inventory.dualreg.jsonDual-region from the MSSP Deployment Guide, cert auth
mixedProject.dualreg.mixed.nocert.j2inventory.dualreg.mixed.jsonDual-region, mixed RR/RR-less, heterogeneous spokes
multi_vrfProject.dualreg.multivrf.nocert.j2inventory.dualreg.multivrf.jsonDual-region multi-VRF segmentation

Fortinet themselves are emphatic that these are examples, not products. Start from the nearest one, then customise. We’ll do exactly that in Part 3.

Inside a Project Template

The Project Template is the only file you’re expected to edit. It declares your regions, your hubs, your device profiles, and a handful of project-wide settings. Everything else — interface config, IPSec, BGP, routing — comes out of the device-type templates which pull from the Project Template via Jinja’s import.

Here is the full Project.singlehub.generic.nocert.j2, lightly trimmed to the structural bits:

{# Optional Settings #}
{% set cert_auth = false %}
{% set dynamic_bgp = true %}

{# Mandatory Global Definitions #}
{% set lo_summary = '10.200.0.0/14' %}

{# Regions #}
{% set regions = {
    'SuperWAN': {
      'as': '65001',
      'hubs': [ 'site1-H1' ]
    }
  }
%}

{# Device Profiles #}
{% set profiles = {

    'JustBranch': {
      'interfaces': [
        { 'name': isp1_intf, 'role': 'wan', 'ol_type': 'ISP1', 'ip': 'dhcp', 'dia': true },
        { 'name': isp2_intf, 'role': 'wan', 'ol_type': 'ISP2', 'ip': 'dhcp', 'dia': true },
        { 'name': mpls_intf, 'role': 'wan', 'ol_type': 'MPLS', 'ip': 'dhcp' },
        { 'name': lan_intf,  'role': 'lan', 'ip': lan_ip }
      ]
    },

    'Hub': {
      'interfaces': [
        { 'name': isp1_intf, 'role': 'wan', 'ol_type': 'ISP1', 'ip': 'dhcp', 'dia': true },
        { 'name': isp2_intf, 'role': 'wan', 'ol_type': 'ISP2', 'ip': 'dhcp', 'dia': true },
        { 'name': mpls_intf, 'role': 'wan', 'ol_type': 'MPLS', 'ip': 'dhcp' },
        { 'name': lan_intf,  'role': 'lan', 'ip': lan_ip }
      ]
    }
  }
%}

{# Hubs #}
{% set hubs = {
    'site1-H1': {
      'lo_bgp': '10.200.1.253',
      'overlays': {
        'ISP1': { 'wan_ip': h1_isp1 },
        'ISP2': { 'wan_ip': h1_isp2 },
        'MPLS': { 'wan_ip': h1_mpls }
      }
    }
  }
%}

Read this carefully because everything else makes sense once you have it. Four top-level pieces:

Global toggles. cert_auth, dynamic_bgp, lo_summary. The first two are conditionals that ripple through 02-Edge-Overlay.j2 and 03-Edge-Routing.j2. The third is a project-wide invariant — every spoke’s loopback lives inside 10.200.0.0/14, which means hub configuration can summarise it in one neighbor-range line.

Regions. A dictionary keyed by region name. Each region has an AS number and a list of hubs. In a single-region deployment there’s one entry; in a dual-region deployment (Project.dualreg.cert.j2) there are two, and each has its own lo_summary to subdivide the project-wide loopback pool. We will look at that pattern in a minute.

Profiles. A dictionary keyed by profile name. Each profile defines an interface set — a list of interfaces with role (wan, lan, lan_member, sd_branch, etc.) and per-interface settings. Profiles are how you say “this kind of spoke has two ISPs and an MPLS and a LAN” once, and then assign it to many devices.

Crucially the interface entries reference Jinja variablesisp1_intf, lan_ip — that aren’t defined in the Project Template at all. They come from inventory. Each device in inventory declares its own values, and Jinja resolves them at render time.

Hubs. A dictionary keyed by hub name. Each hub declares its loopback BGP address and an overlays dict mapping overlay type (ISP1, ISP2, MPLS) to the hub’s public IP on that underlay. This is the per-hub structure that drives Edge phase1 generation in 02-Edge-Overlay.j2.

The Project.dualreg.cert.j2 template extends this pattern in three places: cert_auth flips to true, the regions dict has two entries with their own lo_summary and lan_summary keys, and three hubs are declared (two in West, one in East) with network_id keys per overlay (used for ADVPN 2.0’s network-overlay feature).

The inventory contract

The Project Template references variables it doesn’t define. Inventory defines them. The contract is implicit but firm: a Project Template requires certain per-device variables to exist, optionally with defaults-level fallbacks. If you miss one, the renderer will fail noisily.

Here’s the matching inventory.singlehub.generic.json:

{
  "defaults": {
    "h1_isp1": "100.64.1.1",
    "h1_isp2": "100.64.1.9",
    "h1_mpls": "172.16.1.5",
    "region": "SuperWAN"
  },

  "Hub": {
    "site1-H1": {
      "hostname": "site1-H1",
      "loopback": "10.200.1.253",
      "profile": "Hub",
      "isp1_intf": "port1",
      "isp2_intf": "port2",
      "mpls_intf": "port4",
      "lan_intf":  "port5",
      "lan_ip":    "10.1.0.1/24"
    }
  },

  "Edge": {
    "site1-1": {
      "hostname": "site1-1",
      "loopback": "10.200.1.1",
      "profile":  "JustBranch",
      "isp1_intf": "port1",
      "lan_intf":  "port5",
      "lan_ip":    "10.0.1.1/24"
    },
    "site1-2": {
      "hostname": "site1-2",
      "loopback": "10.200.1.2",
      "profile":  "JustBranch",
      "isp1_intf": "port1",
      "isp2_intf": "port2",
      "mpls_intf": "port4",
      "lan_intf":  "port5",
      "lan_ip":    "10.0.2.1/24"
    }
  }
}

Three things to notice.

First, Hub and Edge are top-level dictionaries. The renderer treats them differently — Hubs get a different set of device-type templates rendered (01-Hub-Underlay.j2, etc.), and Edges get the Edge variants. A device’s role is determined by which dictionary it’s in, not by anything inside the device’s record.

Second, defaults applies to every device. The hub IPs h1_isp1 / h1_isp2 / h1_mpls are project-wide constants (every spoke needs to know them), so they live in defaults rather than being repeated on every Edge.

Third, site1-1 only defines isp1_intf and lan_intf — no ISP2, no MPLS. When 02-Edge-Overlay.j2 iterates over the profile’s interface list, the inventory variable isp2_intf is undefined for this device. The template handles that with if i.name is defined guards (we’ll read the relevant chunk in a moment). The net effect is that site1-1 gets one tunnel per hub instead of three. Shape-varying via inventory, exactly as advertised.

The CSV path (inventory_from_csv.py) accepts the same kind of CSV that FortiManager 7.2+‘s Import Model Devices feature uses, so you can keep one canonical CSV of your estate, feed it to FMG to create the device records, and feed it (separately, for Hubs and Edges) to the Jinja Orchestrator to generate the configs. Two consumers, one source of truth.

Three Jinja patterns the Orchestrator leans on

If you skim the device-type templates, three patterns appear over and over. Knowing them lets you read any of the templates fluently.

Pattern 1 — Loops with predicate filters

The 02-Edge-Overlay.j2 template renders one IPSec phase1 per hub per applicable overlay per Edge:

{% for h in project.regions[region].hubs %}
{% set hubloop = loop %}
{% set ol_tunnels = [] %}
{% set backup_tunnel = {} %}
{% for i in project.profiles[profile].interfaces 
      if i.role == 'wan' and 
      i.name is defined and
      i.ol_type in project.hubs[h].overlays %}
  ...
{% endfor %}
{% endfor %}

This is the central loop, and it’s worth breaking down. The outer loop walks every hub in the device’s region. The inner loop walks every WAN interface in the device’s profile that has a defined name (so the inventory can omit interfaces the device doesn’t have) and whose overlay type the hub actually offers. That last predicate matters: if hub site2-H1 only exposes ISP1 and MPLS, a spoke’s ISP2 interface produces no tunnel to it, automatically, with no extra config.

{% set hubloop = loop %} is a Jinja idiom: when you nest loops, the inner loop shadows loop, so you save the outer one before entering the inner. The Orchestrator uses hubloop.index to name tunnels (H1_, H2_) and inner loop.index for index suffixes when WAN combinations would otherwise collide.

Pattern 2 — ipaddr derivation

The Orchestrator never hard-codes addresses it can derive. The loopback is the canonical example:

config router bgp
  set as {{ project.regions[region].as }}
  set router-id {{ loopback|ipaddr('address') }}
  ...

loopback is supplied as 10.200.1.1/32 in inventory. The ipaddr('address') filter strips the mask to produce 10.200.1.1 for the router-id, which expects a bare address. Elsewhere in the same template:

set exchange-ip-addr4 {{ loopback|ipaddr('address') }}

— same trick for the IKEv2 IPv4 exchange. The Ansible ipaddr family covers most of what you need: ('address'), ('network'), ('netmask'), ('host'), ('size'), plus the indexed forms ipaddr('1') (first host) and ipaddr('-1') (last host). When the Project Template needs to derive a tunnel network from a base prefix, or pick the first usable host as a gateway, ipaddr is the tool.

A second example, this time from 03-Edge-Routing.j2’s route-map block — it programmatically builds one route-map per potential hub index:

config router route-map
  {% for i in range(1,3) %}
  edit "H{{loop.index}}_TAG"
    config rule
      edit 1
        set set-tag {{ loop.index }}
      next
    end
  next
  {% endfor %}
  ...
end

This is a tight little use of range() — the design supports up to two hubs per region in this template family, so the route-map block generates H1_TAG and H2_TAG deterministically.

Pattern 3 — Imports and shared context

The device-type templates don’t redefine the project structure; they import it. Every template starts with:

{% import '00-Project' as project with context %}

'00-Project' is the Project Template aliased — the renderer (and FortiManager’s Jinja runtime) maps the file you supplied via -p to the name 00-Project so the device-type templates can reference it portably. with context is critical: it makes per-device inventory variables (hostname, loopback, region, etc.) visible inside the imported context too.

The result is that you can write project.regions[region].hubs inside 02-Edge-Overlay.j2 and have it resolve to the right thing without passing variables through long chains. Imports are how big Jinja projects stay legible.

If you find yourself wanting to share computed state between templates — for example, a list of tunnel names computed in Overlay that Routing also needs — Jinja import/include is the right tool, not a meta variable. Meta variables in FortiManager are device-scoped strings; Jinja imports are template-scoped objects. They look superficially similar; they aren’t the same.

Reserved indexes and names — the gotcha

The Orchestrator quietly uses certain numeric indexes for objects it creates: route-map names like H1_TAG, BGP neighbour group names, certain VPN tunnel indexes, particular DHCP server indexes, and others. The wiki page 08 Reserved Indexes and Names keeps an up-to-date list — read it once, then keep it in mind whenever you customise.

The way this bites: you fork the templates, you keep your existing legacy IPSec tunnels (perhaps on indexes 1–5), and the Orchestrator renders new objects on top of those indexes. FortiOS doesn’t generally complain; it just merges, and now you have two tunnels arguing over the same identity. The fix is to either renumber your legacy objects out of the Orchestrator’s range, or fork the templates to shift the Orchestrator’s range upward.

Don’t try to solve this by guessing. Read the wiki page, design your numbering, and write it down.

Idempotence — offline wipe-and-replace vs FortiManager merge

One last structural point because it changes how you write customisations.

The offline renderer produces a full FortiOS config for a device. That config typically starts with execute batch start and proceeds top-down. If you paste it onto a device, you’re effectively replacing the affected blocks. This is fine in a lab; in production it would obliterate anything else on the box.

FortiManager, by contrast, merges. It computes a diff between the rendered desired state and the device’s current state, and pushes the delta. If your Jinja template renders the same IPSec phase1 block twice with different content, FMG renders both, and the second one wins. If your template stops rendering an object that previously existed, FMG generally doesn’t remove it unless you’ve explicitly told it to (via purge).

That distinction shapes how you write templates. In an FMG-driven flow, you sometimes need an explicit purge to make a block deterministic — you’ll see this in the BGP neighbour block in 03-Edge-Routing.j2:

config router bgp
  ...
  config neighbor
    {{ 'purge' if options.strict_peering or "" }}
    ...

strict_peering is on by default, and the purge ensures the neighbour list is exactly what the template produces, not the template’s list plus anything that happened to exist. If you’re customising and your template stops producing a neighbour that used to exist, you want this; if you’re sharing the device with another template that also writes neighbours, you don’t. Decide consciously.

What’s next

We’ve now read the Orchestrator end to end. We know its directory layout, its two consumption modes, its single supported overlay design, its four reference Project Templates, its inventory contract, and the three patterns that show up everywhere inside it.

In Part 3 we use that understanding to drive a real customisation. The starting point is the single-hub PSK example we read above; the destination is the same topology with certificate-based IPSec, where FortiManager itself acts as the CA, every spoke gets its own device certificate signed by FMG, and the Project Template’s cert_auth flag and inventory both change to suit. We’ll see exactly what changes in the rendered config, what stays the same, and which gap is best filled by a small classic CLI template alongside the Jinja scaffold.