Zero Touch Provisioning (ZTP) is a wonderful thing that can turn hours into minutes, reduce errors, and help with standardization, among other things. In this post, dear reader, I want to go through the entire process, starting with a factory-reset FortiGate, to one that is fully integrated into an Auto-Discover VPN (ADVPN) environment with SD-WAN. We’ll see FortiZTP, FortiManager, Jinja, and so much more along the way. Read on, and I hope you find this entire process just as cool as me.
Acknowledgement
Before I get into the topic proper, there is one person I need to thank. Dennis Holzmann has given me some valuable information and tips in the past on this topic, and some of them are reflected in this post, as well as my work. This post would look different, and worse, without his input, so thank you, Dennis!
The setup
For this post, I have the following components:
- A FortiGate VM running 7.6.6, serving as a hub
- A FortiGate 70G running at 7.6.6, serving as a branch
- FortiManager running 7.6.6
- FortiZTP, a free cloud service
The GitHub repository
The purpose of this post isn’t the actual configuration that is being done, but the process. I will highlight parts of the configuration where I think it’s useful, but for the most part, the configuration of the deployment is up to you. If you are still interested in the configuration I am using, you can check out my Fortinet resources GitHub repository. The finished FortiGate configurations, exports of FortiManager templates, the ZTP pre-run script, and more are all there.
A high-level look at ZTP
Breaking down ZTP provisioning of FortiGates in the Fortinet ecosystem in its most basic steps is as follows:
- A factory default branch FortiGate gets an IP, and thus a WAN connection, via DHCP
- The branch FortiGate contacts FortiZTP for partial provisioning
- A VPN tunnel, as well as a BGP neighborship, gets established between the branch and the hub
- The branch FortiGate connects to FortiManager via the VPN tunnel
- FortiManager fully provisions the FortiGate

The process itself is simple, but there are a decent amount of pieces we first have to prepare so our ZTP workflow runs smoothly.
Let’s first look at the general FortiManager pieces.
Preparing FortiManager for ZTP
On the hub, we need to prepare four things before we dive into templates:
- Device groups
- Metadata variables
- Policy packages
- Blueprints
Let’s take it from the top.
Device groups
To make sure that our FortiGates get the correct configuration, we will target groups as much as possible. How many groups you want to create depends on your needs, but I usually see the need for four:
- Hubs
- Branches
- Branches with single WAN
- Branches with dual WAN

I think the groups are self-explanatory.
A FortiGate might start in a single WAN group, and if it ever gets a second WAN connection, you can simply move the device to the dual WAN group and all the necessary things, the second IPsec tunnel and second SD-WAN member, for example, get automatically provisioned and installed on the next configuration push.
The branches group is useful if you ever need to run a script against all branches and don’t want to pick the WAN-related groups, and it is also used for the template group.
Metadata variables
A magical aspect of FortiManager are metadata variables. You can create these variables, assign mappings for devices and/or groups, with an optional default value, and use them in a whole host of configurations in FortiManager. You see the possibility of using metadata variables in fields by the looking glass symbol of a dollar sign
, or you know that you can use them in CLI templates and scripts when using both CLI and Jinja.
Again, how many and what metadata variables you need is up to your needs. I need the following for this post:
- branch_id
- cluster
- hostname
- wan1_port
- wan2_port
branch_id is a variable that will be used for the site subnet and loopback IP for the most part. The cluster variable signalizes if this is an HA cluster, because some things will be different for these installations.
hostname, wan1_port, and wan2_port are self-explanatory.
Policy packages
Without policies, not a whole lot will work, so we need policy packages for the hubs and our branches. It is recommended to create one policy package for all branches and try to make it as homogenous as possible, because branches should be treated like cattle, not pets.
The branches only need one policy, which is a policy that uses the SD-WAN zone with the IPsec overlays as members, which will be created later, because otherwise the IPsec tunnel to the hub won’t ever come up. Everything else is up to you.
The hub needs the standard policies for an SD-WAN/ADVPN deployment with BGP.
- Branch IPsec SD-WAN zone to the BGP loopback to allow BGP traffic
- Branch IPsec SD-WAN zone to the health check loopback to allow ping traffic for our SLA
- Branch IPsec SD-WAN zone to Branch IPsec SD-WAN zone for shortcut tunnels
Again, everything else, including policies that allow traffic to LAN resources, is up to you. My policies are visible in the GitHub repository and here:


Blueprints
We don’t want to spend lots of time in a ZTP deployment creating model devices, or waiting for the actual device to come online, and assigning things. We use blueprints to speed up the process.
Pick your device model, enable auto-linking, enforce a firmware version and the device configuration, assign groups, templates, a policy package and configure HA all in one fell swoop and reuse it hundreds of times. In the blueprint, we also define that we use the SD-WAN Manager for all branches, and the hub(s) will be added manually.
The blueprint you create will then be used to create model devices that define how your FortiGate will look once FortiManager has provisioned it.
I got one blueprint for single WAN, one for dual WAN and a third one for HA (more on HA later).
With all our non-template pieces in place, we can now turn to templating, and we will start with the branches.
Overlay Orchestration
Just joking, one more point. I want to point out that the SD-WAN Manager offers a feature called Overlay Orchestration. This feature creates an SD-WAN Overlay Template, which builds the necessary templates for you, and you only need to supply some values regarding the configuration (topology, loopback IPs, AS number, groups, etc.). This is a quick and easy way to get started, but I wouldn’t recommend using it except to get the initial templates. The reason for that is that if you ever edit the overlay template, it will overwrite all custom settings you made in the templates.
Once the Overlay Orchestration feature gets the capability to only edit its own settings in templates, it will be a great way to handle an entire rollout.
Now onto the branches.
Templating the branches
I configured the following templates for branches:
- A system template
- CLI templates that are in a CLI template group
- A BGP template
- An IPsec tunnel template
- An SD-WAN template
Let’s look at each in turn. I will not spend much time on the configuration of the templates; see the GitHub repository for that, but only point out noteworthy points.
System template
The system template houses options like DNS and NTP servers, the hostname, SNMP, and logging. These options are, of course, specific to your deployment, but one you should keep in mind is the Log Settings option, where you can tell devices to log to FortiAnalyzer, and if FortiAnalyzer is managed by FortiManager, it’s even easier to do.
CLI templates
CLI templates, and the pre-run CLI templates, is where lots of magic happens, not just because you can use metadata variables in them, but because you can use Jinja to make configurations that have some logic in them, like if-else and for loops and functions, like ipmath. I want to highlight three things here:
1. You can import CLI templates in other templates. At first reading, this doesn’t make much sense, but since you can use Jinja, a CLI template can be used to house variables that, after an import, can be used in other CLI templates.
Consider the classic case of creating VLAN interfaces on branches. It’s not rare for branches to have 5+ VLAN interfaces, maybe one is for management and needs different management access, and some with DHCP relay enabled. You can create a CLI template and write out the configuration as is, but this definitely isn’t cool, and the cool-factor has a certain charm.
Instead, create a CLI template that holds a variable with your VLAN interface information, import it in another CLI template, loop through your interfaces, and make the individual changes as you need them. If you ever need to add another VLAN interface, just copy the line and replace the values.
In my configuration, I have done just that, so take a look:
{# PROJECT VARIABLES #}
{# INTERFACES #}
{% set interfaces = {
'vlan_interfaces' :[
{ "id": 100, "alias": "TEST100", "suboctet": 100, "dhcp_relay": true},
{ "id": 101, "alias": "TEST101", "suboctet": 101, "dhcp_relay": false}
]
}
%}
{# CREATE INTERFACES #}
{%- import 'CLI-000-PROJECT-VARIABLES' as project_variables with context -%}
config system interface
edit "LAG_INTERNAL"
set vdom "root"
set type aggregate
set member "port1" "port2"
next
{%- for vlan_interface in project_variables.interfaces.vlan_interfaces %}
edit "VL_{{vlan_interface.id}}"
set vdom "root"
set type vlan
set vlanid {{vlan_interface.id}}
set interface LAG_INTERNAL
set alias {{vlan_interface.alias}}
set ip 192.168.{{vlan_interface.suboctet}}.1/24
{%- if vlan_interface.id == 100 %}
set allowaccess ping https ssh snmp fgfm
{%- else %}
set allowaccess ping
{%- endif %}
{%- if vlan_interface.dhcp_relay %}
set dhcp-relay-service enable
set dhcp-relay-ip "192.0.2.1"
{%- endif %}
next
{%- endfor %}
end
2. FortiManager comes equipped with some predefined variables you can use in Jinja. You can use these to target only specific models, using {{DVMDB.platform}}, loop through interfaces using {{DEVDB_system_interface}}, and much more. I am using just such a variable in the interface CLI template from point one to check if a second IPsec interface exists to set the management access on the interface.
3. The Pre-Run CLI Template is referenced in the blueprint and runs only once. You can use this for whatever you want, but I like to use it to initially define a password for administrators, because a factory-reset FortiGate has an empty password for the admin user, which is a security risk.
BGP template
There isn’t much to say about BGP templates. It’s a standard configuration for BGP on loopback, which is the recommended deployment. The only thing of note is that I use a route map, referencing a prefix list, to make sure only the local summary subnet can get announced, e.g. branch with ID 101 announces only 10.101.0.0/16. This saves you from some misconfiguration, but how you do this is up to you. You can announce all connected routes if you want, but even then, I’d restrict it to the private IP address space using a prefix list, because otherwise, you might also announce the public IP space that is on the branch’s WAN port.
IPsec tunnel template
IPsec is, again, pretty standard with a BGP on loopback configuration using network IDs. I have two tunnels in the template. One for overlay 1, using the wan1 port, and overlay 2, using wan2, and the tunnels target different device groups. Single and dual WAN for both overlays, and only the dual WAN group gets overlay 2.

One part that might be interesting is that I have enabled mode config on both tunnels despite using BGP on loopback. The reason is that when the hub gives out IP addresses to the branch IPsec interface, we can do two things:
- Enable management access on the IPsec interface, and the hub can then connect to the branch via the tunnel, which can be helpful in a pinch. Just make sure the hub is allowed according to trusted hosts and local-in policies.
- It saves you from having to define the source IP for all your possible local-out traffic (FortiAnalyzer, DNS, NTP, RADIUS, etc.), because FortiOS, unless defined otherwise, will use the IP of the tunnel interface as the first choice to source this traffic.
SD-WAN template
In the SD-WAN template, we configure ADVPN 2.0, without transport groups, and use embedded SLA information in ICMP probes.
There are two SD-WAN zones:
- SDWAN-HUBS, with both overlays
- SDWAN-OUTSIDE, with both WAN ports
Both these zones have both members defined, but the installation target narrows down who gets what.

In the SD-WAN Rules, both members are defined, and we install these rules on the branch group, not the single/dual WAN groups. FortiManager is smart enough to only push the SD-WAN members that actually exist on the device in these rules.

Templating the hub
The hub, already registered and synchronized in FortiManager, performs the following functions:
- Terminates dial-up IPsec connections of branch devices
- Forms a BGP neighborship with all branch devices
- Acts as a BGP route reflector (optional)
- If you use dynamic BGP for your spokes, you don’t need the hub to be a route reflector
- Serves as the ADVPN hub
The templates for the hub are very standard, so I can keep the noteworthy things short:
- CLI templates: As necessary
- BGP templates: Very standard, except here I’m using a route map with a prefix list to allow only private IP addresses from the RFC1918 space
- IPsec tunnel templates: It’s a dial-up hub with two dial-up tunnels and mode config enabled to hand out IPs, as mentioned in the branch section and it also gets an IP address just outside of that range (the following picture is edited, and the Tunnel Interface Setup section is further down)

- SD-WAN: Uses a remote performance SLA with priority IN-SLA and priority OUT-SLA values to dynamically steer branch traffic.

Now that we have all our templates, we can create the template groups and assign them to our groups.

FortiZTP
This is the thing that starts the entire process in earnest. FortiZTP is a free service Fortinet offers to facilitate Zero Touch Provisioning of FortiGates, FortiAPs, FortiSwitches, and FortiExtenders. It is available in the Services section of support.fortinet.com (click on “Show More”) or at https://fortiztp.forticloud.com. As soon as a supported device is an asset of your FortiCloud account, it will show up in FortiZTP ready to be provisioned.

If a FortiGate starts from a factory default/reset state, it will:
- Try to pull an IP address using DHCP on ports that have the DHCP client enabled. The ports vary by model, but for the smaller devices, the WAN ports have it enabled.
- Try to connect to FortiCloud and look if it is provisioned for ZTP
- Connect to a selected target:
- FortiGate Cloud
- FortiManager Cloud
- An on-premise FortiManager
- Execute an optional pre-run CLI script
[Update 2026-04-08]: I wrote factory default/reset state, and this is important because if the configuration has been touched, the ZTP process will never be started by the FortiGate. You can check the status using thediagnose fdsm fmg-auto-discovery-status command. If the output shows config-touched=1, no ZTP will happen. Thanks to Chris Eddisford from Fortinet for reminding me about this command.

The pre-run CLI script is the first thing we define. It has all the information the FortiGate needs to connect to FortiManager. At its most basic, and keeping the BGP on loopback configuration in mind, we need six things:
- The loopback interface for BGP
- An IPsec configuration
- A BGP configuration
- An SD-WAN configuration for our member and zone
- A policy that uses the SD-WAN zone so that the tunnel establishes
- The FortiManager configuration
An optional seventh step is the breaking of the default switch interface, as well as the deletion of the fortilink interface. This is included in my pre-run CLI script. Note that the name of the switch might be different for your model.
My somewhat overblown, pre-run CLI script is here:
ZTP pre-run CLI script
config system interface
edit "LO_BGP"
set vdom "root"
set ip 172.25.1.252 255.255.255.255
set allowaccess ping
set type loopback
next
end
config vpn ipsec phase1-interface
edit "HUB1-O1"
set interface "wan1"
set ike-version 2
set dhgrp 32
set proposal aes256gcm-prfsha512
set keylife 28800
set peertype any
set mode-cfg enable
set remote-gw 192.0.2.1
set idle-timeout enable
set idle-timeoutinterval 10
set net-device enable
set add-route disable
set exchange-interface-ip enable
set exchange-ip-addr4 172.25.1.252
set auto-discovery-receiver enable
set psksecret ENC JVojDRO7n0Wkft6+Ts9vwzs0m891XrhCH5/N/fU5xATS+bTAflRfv/tS/vFpjOyZ2+1FZcg+aniAcpQ0uUsUCrBIF6jPfVEfPHSXsZW5KE+DiDoOBWIFp7mgkgdZppgPZWZbaErBlQ0R6koV1N0bCgByN4otCT+nBSU1DorQBa5Z5jifcYIiTtdUk5RulqIjBRcnbFlmMjY3dkVA
set network-overlay enable
set network-id 10
set auto-discovery-shortcuts dependent
set exchange-fgt-device-id enable
set transport auto
set shared-idle-timeout enable
next
end
config vpn ipsec phase2-interface
edit "HUB1-O1"
set phase1name "HUB1-O1"
set proposal aes256gcm
set auto-negotiate enable
set dhgrp 32
set keylifeseconds 3600
next
end
config router prefix-list
edit "PL_RFC1918"
config rule
edit 1
set prefix 192.168.0.0 255.255.0.0
unset ge
set le 32
next
edit 2
set prefix 172.16.0.0 255.240.0.0
unset ge
set le 32
next
edit 3
set prefix 10.0.0.0 255.0.0.0
unset ge
set le 32
next
end
next
end
config router route-map
edit "RM_SDWAN-OUT"
config rule
edit 1
set match-ip-address "PL_RFC1918"
next
end
next
end
config router bgp
set as "65123"
set router-id 172.25.1.252
set keepalive-timer 5
set holdtime-timer 15
set ibgp-multipath enable
set additional-path enable
set scan-time 15
set graceful-restart enable
set recursive-next-hop enable
set tag-resolve-mode merge
set recursive-inherit-priority enable
config neighbor
edit "172.25.1.254"
set advertisement-interval 1
set activate6 disable
set capability-graceful-restart enable
set link-down-failover enable
set soft-reconfiguration enable
set interface "LO_BGP"
set remote-as "65123"
set route-map-out "RM_SDWAN-OUT"
set keep-alive-timer 5
set holdtime-timer 3
set connect-timer 15
set update-source "LO_BGP"
set additional-path both
next
end
end
config system sdwan
set status enable
config zone
edit "SDWAN-HUBS"
next
end
config members
edit 1
set interface "HUB1-O1"
set zone "SDWAN-HUBS"
next
end
end
config system dhcp server
purge
end
config system ntp
set server-mode disable
end
config firewall address
del lan
del internal
end
config firewall policy
purge
end
config system virtual-switch
del lan
del internal
end
config system interface
del fortilink
end
config firewall policy
edit 0
set name "DUMMY VPN"
set srcintf "LO_BGP"
set dstintf "SDWAN-HUBS"
set action deny
set srcaddr "all"
set dstaddr "all"
set schedule "always"
set service "ALL"
set logtraffic all
next
end
config system central-management
set type fortimanager
set serial-number {{fmg-sn}}
set fmg {{fmg-ip}}
end
Note: The interface binding for the pre-run script varies model by model. A 50G only has wan, for example, instead of wan1 and wan2 for the 70G. Keep this in mind when making your scripts and create several scripts that map to your devices.
I have purposefully removed all leading tabs and whitespaces from the script to reduce possible errors when the script runs.
With the pre-run script, we can define our FortiManager and attach the script.

Now we can go to our Assets, select our FortiGate(s), and provision them to target our FortiManager. Here you can also select a different script if the default one attached to your FortiManager isn’t the one you need.

Now our FortiGate is ready to be provisioned by ZTP.

The CSV file
The last piece of, technically optional, preparation we need for ZTP is a CSV file to create our model devices. The CSV file, at a minimum, needs the serial number, the name of a blueprint, and the name the device will have in FortiManager.
You can, and probably will, add several metadata variables in addition to that to the CSV. With the CSV file in hand, you can use it to create your model devices, look over the assigned values, and continue with your import.
If you ever need to give the creation of the CSV off to someone else who might not be as technically inclined as you, consider creating an Excel sheet to easily fill out and select values. After that, it can be exported to a CSV.
An example CSV file is provided in the GitHub repository.
Auto-linking
The process of connecting an incoming FortiGate, using its serial number, to a model device is called auto-linking. After a successful link, the real FortiGate will get handled according to the blueprint, meaning the firmware might get upgraded, it gets added to groups, a policy package gets pushed, etc.
You can see the status of auto-link in the corresponding column. It will go from Enabled to Auto-linking and disappear once it is done.


Auto-linking creates two tasks on FortiManager:
- Push config to device: This signalizes the pushing of the entire configuration to the FortiGate
- Install Package ‘<PACKAGE-NAME’>: Installs the policy package

If either of these tasks fails, look at FortiManager’s Task Monitor and the installation reports in the tasks to find out where your errors are. In the following picture, I forgot to first install the device configuration to the model device, so the SDWAN-OUTSIDE zone was missing, and that is needed in the policy package.

Now we finally got everything, so let’s look at the process from start to finish.
The end-to-end ZTP process
Let’s now go through the entire process of how to ZTP a FortiGate.
- Provision the FortiGate on FortiZTP
- Create a model device on FortiManager using the CSV
- Do a Quick Install on the model device
- This is necessary if you are enforcing the device configuration in the blueprint, because the installed configuration is needed for everything else to correctly install, like firewall policies
- Connect your FortiGate, which is factory reset or booting for the first time, on ports that have DHCP client enabled, to a modem/router for internet access
- The FortiGate will try to contact FortiZTP to check for any provisioning
- The pre-run CLI script will be downloaded and run
- The FortiGate will connect to FortiManager
- FortiManager will auto-link the FortiGate to the model device
- If enforced in the blueprint, the firmware of the FortiGate will be upgraded
- If the firmware was upgraded, signatures and databases like the ISDB, IPS, and application control might get upgraded as well
- The full configuration, including the policy package, gets pushed to the FortiGate
- On FortiManager, the config status becomes Synchronized, the policy package status gets a green check mark, and on FortiZTP, we see Provisioned
- ZTP is done



I have made a short video about the entire process if you want to see it in action:
Note: In some cases, I’ve seen an “Out of Sync” message shortly after the ZTP process is done, which can be related to things like FortiTokens that get provisioned after the device comes online. These things should get corrected with the next install.
The process is now explained, and hopefully clear, but we’re not done.
ZTP with FortiGate clusters/HA
Most ZTP installations I’ve seen are single devices, but there is a need to create clusters during ZTP as well. This has some caveats, but it’s nothing insurmountable.
It all starts with the blueprint, where, after enabling the HA option, you can fill out information relating to the monitor and heartbeat interfaces and assign a password for the cluster.
Next, you need a CSV that handles the cluster configuration. This CSV has some more necessary values in addition to the serial number, blueprint, and the name. The new values are:
- cluster id: Defines the group ID of the cluster, which is important if you ever have two FortiGate clusters in the same layer 2 network
- cluster name: Self-explanatory
- Priority: Sets the cluster priority of each FortiGate in the cluster, and needs to be different between the two units
- Mode: Either AP for Active-Passive or AA for Active-Active
Note: The name value needs to be different for each device in the CSV; otherwise, the import will not work correctly.
An example CSV for a cluster is available in the GitHub repository.
Once the model HA device cluster has been imported, the process is the same as for a single device; you just need to make sure that your FortiGates are correctly cabled up.
If you ever import multiple clusters, the cluster ID is responsible for differentiating between them, and this is color-coded in the import.

Important point: I have not found a way to supply FortiManager metadata variables using the CSV for an HA model device, so this is something you have to do manually after the import. This is easily done by right-clicking on the model device and selecting “Edit Variable Mapping”. If someone knows how to do this, please tell me.
The hostnames of the HA devices will look a bit strange after the import, and in my configuration, the hostname also won’t get changed by a CLI or System template, because HA hostnames are individual to each cluster member.

When it comes to FortiZTP, it is important to have a second pre-run CLI script for the second member in a cluster, because of the overlapping IPs and router IDs, which create a problem during the ZTP process on FortiManager. So you need to adjust the following in the second CLI script:
- The IP on the loopback interface
- The BGP router ID
- The exchange IP address in the IPsec phase 1 configuration
In FortiZTP, you then provision the first member with the regular pre-run script and the second member with the second script.
Scaling beyond 253 branches
This is a niche case, but if you ever have more than 253 branches and only one hub, the regular way to use the branch_id metadata variable for your loopback IP and other things breaks down. If your loopback IP with the variable looks like 10.10.0.$(branch_id) and branch_id is 300, this obviously cannot work.
If this case applies to you, you will have to use Jinja CLI templates to set these things instead of regular CLI templates and set these values in IPsec, BGP and SD-WAN templates. FortiManager luckily comes with the Jinja ipmath function that helps us here.
By using {{'10.10.0.0'|ipmath(branch_id|int)}}, you can scale to much more than 253 branches, for example. With the above branch_id of 300, this would lead to the IP 10.10.1.44.
How you handle the addressing for your networks, and thus the BGP announcements, in such a case is up to you. At that point, you might have to create a metadata variable that defines the local network as a summary and map it to each device. In my CLI template, I have created an if condition that only creates a network entry in BGP when the branch ID is lower than 256, because everything else would result in an error, and it also uses a route map to filter to RFC1918 space instead of only the local network summary.
In the GitHub repository, you can find a CLI template called “CLI-004-IPMATH” that has all the settings where you, at a minimum, need to use the ipmath function if this case applies to you.
Additional resources
I am, of course, not the first person to create content about this topic, and this post isn’t about the configuration itself, but the process. If you would like some more information on how configurations can work, please check out these two resources by Fortinet themselves:
- 4-D SD-WAN resources: Includes documents detailing the topologies, deployment guides, explanations, and GitHub repositories with working example configurations. Thanks to Cameron Keighley and kli who are the GitHub contributors!
- The Jinja Orchestrator: A repository containing best practice SD-WAN/ADVPN configurations and templates, you can deploy with or without FortiManager. It’s an extensive and fantastic resource. Thanks to Dmitry Perets and Boris Henicker, who are the GitHub contributors!
Enough ZTP
I think I have touched on everything I wanted to cover regarding the current state of Zero Touch Provisioning with FortiGates and FortiManager. It might seem like it’s a lot, but the process is quite simple once you’ve seen it.
With all this information, I wish you, dear reader, happy zero touch provisioning.



Leave a Reply