Route Leaking Between VRFs on FortiGate: What Happens When You Actually Build It

In an earlier post I laid out the theory of route leaking between VRFs on FortiGate — the BGP leak-target mechanism, why it’s the only path since FortiOS has no static-route-leak equivalent to Cisco’s ip route vrf X ... vrf Y, and why it’s easy to miss because it lives buried in config router bgp. That post was written from the documentation. This one is what happened when I actually built it, on a real FortiGate 70G, in my own SD-WAN homelab.

Short version: the documented approach didn’t work. Not “worked with caveats” — didn’t work, for three separate, undocumented reasons, on a device explicitly named as supported for every command I tried. The actual working answer turned out to live in a completely different part of the admin guide, didn’t need BGP at all, and was simpler than what I started with. This is the story of getting there, because I think the process is more useful than the punchline.

The problem I was actually solving

I’m running a homelab SD-WAN build with a VRF scheme: VRF 0 for transport/WAN, and a separate VRF for management infrastructure that’s meant to be completely isolated — no route in, no route out, by design. The catch: a handful of management services (DNS-over-TLS upstream, license validation checks, that kind of thing) need internet reachability, and that breakout currently rides on a flat, unsegmented interface arrangement that predates the VRF retrofit. Once management traffic actually moves into its own VRF, that breakout has to cross a real VRF boundary for the first time.

The requirement was narrow and specific: leak only the default route from the transport VRF into the management VRF, one-way, with nothing else able to cross by accident. Not a general-purpose multi-VRF mesh. Not bidirectional. Just: one route, one direction, tightly filtered.

This is exactly the scenario my earlier post’s BGP leak-target example was built for. So that’s where I started.

Reality check #1: matching the docs to the device

First mistake, and it’s an embarrassing one to admit: I pulled Fortinet’s 7.6.6 CLI reference and admin guide, because that’s the baseline version this lab has used for most of its FortiGate VM builds. I didn’t check what the actual physical hub was running. Turned out to be FortiOS 7.6.7 — an unplanned firmware bump that happened months earlier during an unrelated maintenance window and never got reconciled against the docs I was working from.

Lesson one, and it’s not a subtle one: verify the exact firmware build before trusting a CLI reference against it. I pulled the correct 7.6.7 CLI reference and admin guide once I caught this. It didn’t fix everything — see below — but it eliminated one whole category of “is this a version mismatch or a real bug” uncertainty for the rest of the session.

Reality check #2: a documented command that doesn’t exist

The BGP leak-target mechanism needs a link between the two VRFs to act as the next-hop — on FortiGates with NPU hardware, that’s normally an NPU-accelerated inter-VDOM link. Fortinet’s own KB material points at config system npu-vlink for this. Both the 7.6.6 and 7.6.7 CLI references list this command, and both explicitly name the 70G in the supported-model list — not a generic “check your platform,” an actual named entry.

config system npu-vlink
command parse error before 'npu-vlink'

Flat rejection. Not a permissions issue, not a licensing nag — the parser doesn’t recognize the token. I tried it with single-vdom-npuvlink enabled (the prerequisite the docs describe for exposing NPU vlinks on a single-VDOM system, which this device is). Same result. I tried it after a full reboot, in case the flag needed a restart to propagate through the NPU subsystem. Same result again.

I never found a documented explanation for this. My working theory is that the 70G’s NP7Lite/SOC5 architecture — a lighter, integrated chip rather than a standalone NP7 — implements the same conceptual feature through a different, more generic command path. Which turned out to be true, sort of, though not in the way I expected:

config system vdom-link
    edit "vl-test"
    next
end

This worked cleanly. config system vdom-link is a separate, older, more generic command that predates the dedicated npu-vlink table. Its type field takes ppp, ethernet, or npupair — that last one being the NPU-accelerated option, functionally the same idea as npu-vlink, just reached through a different door. On this device, that door works and the other one doesn’t, regardless of what the model-support table says.

Takeaway: a documented command being listed as supported for your exact model is necessary, not sufficient. Confirm it live with a bare attempt or ? before you build anything on top of it.

Reality check #3: “Only 1 PE is allowed”

With the link in place, I moved on to the actual leak-target BGP config — register both VRFs, set roles, define the leak. The pattern in the docs (and in my earlier post) sets role pe on the VRF doing the leaking. On my scratch pair (VRF 21 and VRF 22, disposable IDs picked specifically so nothing live was at risk), setting role pe on VRF 21 committed cleanly. Setting it on VRF 22 as well — because the fuller MP-BGP-style examples set it symmetrically — did not:

set role pe
next
Only 1 PE is allowed.object set operator error, -7 discard the setting
Command fail. Return code -7

FortiOS permits exactly one role pe VRF, system-wide, per BGP process. This is a real architectural constraint — closer to genuine MPLS L3VPN PE/CE terminology than I’d appreciated, where there’s one provider edge and everything else is customer edge — but it appears nowhere in either the CLI reference or the admin guide’s worked examples. You just have to hit it.

The fix is straightforward once you know it: one VRF gets role pe, the other gets role ce. But there’s a sharper gotcha hiding in the failure mode itself — the failed next didn’t just reject the role pe setting, it discarded the entire pending VRF entry. I confirmed this with show router bgp afterward: only VRF 21 existed. VRF 22 had to be recreated from scratch with the correct role. If you’re not in the habit of re-verifying state after every failed commit on this platform, this one will bite you quietly.

Reality check #4: the leak that leaked nothing

With both VRFs correctly registered — role pe on 21, role ce on 22 — and the leak-target itself configured with a route-map and prefix-list scoped to a single test prefix, I expected this to just work. It didn’t. The target VRF’s routing table stayed empty of the leaked route, showing only the directly-connected transit subnet.

The prefix I was trying to leak was a test loopback — a directly-connected route in VRF 21’s own table, confirmed present with get router info routing-table all vrf 21. And that’s the actual problem: leak-target leaks routes that are already present in BGP’s own RIB for the source VRF. A directly-connected interface route sitting in the kernel’s routing table isn’t automatically visible to BGP just because it’s tagged with the same VRF. Fortinet’s own worked example for this feature learns its example routes from real BGP neighbors — actual branch routers peering in — which is a completely different path into BGP’s RIB than “I have a locally connected route I’d like to advertise.”

I tried bridging that gap with config router bgp > config redistribute "connected" > set status enable — the standard FortiOS mechanism for injecting connected routes into BGP. No change. My reading now is that this redistribution statement, sitting outside any config vrf sub-block, only applies to the default/global BGP context (effectively VRF 0), not to a registered PE-role VRF like 21. There’s no per-VRF redistribute or network-statement mechanism documented anywhere I could find — which means, as far as I can tell, leak-target genuinely requires a real BGP neighbor relationship to have anything to leak in the first place. For a homelab scratch test with no real peers on either side, that’s a dead end.

At this point I’d spent real effort proving that the textbook mechanism, configured exactly as documented, doesn’t do what the textbook scenario needs when your only source of the route is something already sitting locally on the box.

The pivot: a different worked example entirely

Fortinet’s admin guide has three separate worked examples under VRF routing. I’d been building against the first — the two-VRF BGP leak-target example. There’s a third: a star-topology example for leaking routes between three or more VRFs at once, aimed at avoiding a full mesh of pairwise BGP relationships. I’d skimmed past it assuming it was a scaling variant of the same BGP mechanism, just for more VRFs.

It isn’t. It doesn’t use BGP at all. It uses plain static routes:

config router static
    edit 4
         set dst 172.16.11.0 255.255.255.0
         set gateway 10.1.1.2
         set device "vlink0_Vlan_10"
         set comment "VRF10_Route_Leaking"
    next
end

The mechanism: point a static route’s device at the local end of a VDOM-link, and its gateway at the far end’s IP address, which physically sits in the other VRF. No BGP process, no leak-target, no prefix-list, no route-map, no role assignment, no “only 1 PE” constraint to navigate. Fortinet’s own admin guide describes “no static route leaking on FortiOS” only in the narrow sense that you can’t write a single Cisco-style ip route vrf X ... vrf Y line — but the functional outcome is fully achievable with a static route and a link between the VRFs. This example is also, notably, the most thoroughly worked-through one in the whole chapter — it includes firewall policy configuration, zone setup, and live verification with actual ping tests and packet captures across the link. That should have been my first signal that it was the more production-grade approach, not a niche variant.

Proving it, on scratch VRFs first

Given how much the documented BGP path had already diverged from live behavior, I wasn’t about to build the static-route version straight onto the live device either. Same scratch VRF pair, same disposable transit link, adjusted for the new mechanism.

Interfaces, VRF-tagged, transit /30 between them:

config system interface
    edit "vl-test0"
        set vrf 21
        set ip 169.254.253.1 255.255.255.252
    next
    edit "vl-test1"
        set vrf 22
        set ip 169.254.253.2 255.255.255.252
    next
end

A test loopback in the source VRF, using the TEST-NET-1 documentation range (RFC 5737) so it’s obviously not a real address:

edit "lo-test"
    set type loopback
    set vdom "root"
    set vrf 21
    set ip 192.0.2.1 255.255.255.255
next

And the static route in the target VRF, pointing at the far end of the link:

config router static
edit 10
set dst 192.0.2.1 255.255.255.255
set gateway 169.254.253.1
set device "vl-test1"
next
end
get router info routing-table all vrf 22

Routing table for VRF=22
C       169.254.253.0/30 is directly connected, vl-test1
S       192.0.2.1/32 [10/0] via 169.254.253.1, vl-test1

There it is. A route that only exists locally on the box, in a completely different VRF, with no BGP process involved anywhere in the chain.

The last twist: a documented field that isn’t there, and doesn’t matter

Before I got to that clean result, I hit one more wall worth recording, because the resolution taught me something about how FortiOS actually models VRF for static routes. The CLI reference documents an explicit vrf {integer} field on config router static, for this exact command, on this exact firmware version. On this device, it’s not there:

set vrf
command parse error before 'vrf'

Not an error on a specific value — the field is absent from the live set ? completion list entirely, alongside a dozen other fields that are present (dst, device, gateway, distance, sdwan-zone, and so on). I ruled out the obvious candidates: it wasn’t SD-WAN mode interfering (configured but with no active zone members, functionally inert), and a full reboot changed nothing.

The resolution turned out to be that the field was never necessary in the first place. FortiOS infers a static route’s VRF from the VRF already tagged on its device interface. Since vl-test1 was already tagged VRF 22, a route with device "vl-test1" lands in VRF 22’s table automatically — no explicit vrf field required, which is presumably why an explicit override field exists at all: for routes with no device to infer from, like ECMP or internet-service-based routes, where FortiOS has nothing to infer the VRF from. For a plain device-based static route, it’s redundant. Whether it’s redundant-but-normally-present-anyway on other platforms and genuinely absent on the 70G specifically, or whether it’s actually a non-functional field everywhere that the CLI reference documents out of general-model habit, I don’t know — but it doesn’t matter for this use case either way.

The mechanism that actually works

Stripped of the scratch-test scaffolding, this is the real, proven design for a controlled, one-way default-route leak between two VRFs on a single FortiGate:

  1. Build a VDOM-link between the two VRFs (config system vdom-link, NPU-accelerated via type npupair if your platform actually supports it — confirm this live, don’t trust the model list).
  2. Tag each end with its VRF and a small transit subnet.
  3. Add a single static route in the target VRF: destination 0.0.0.0/0 (or a specific prefix), gateway pointing at the source VRF’s transit IP, device pointing at the local link interface.
  4. Add firewall policy permitting the actual traffic across the link interfaces — a route existing doesn’t mean traffic is permitted, that’s still a completely separate layer.

No BGP. No prefix-lists. No route-maps. No role assignment, no RT import/export, no “only one PE” landmine. One auditable static route doing exactly one job, which is a better fit for “I want to leak exactly this and nothing else” than a filter-based BGP construct ever was — and it sidesteps every platform quirk this session turned up.

What I’d tell someone starting this from scratch

Confirm your firmware build before you open a CLI reference. A version mismatch will waste your time chasing bugs that are really just documentation for a different build.

A model being explicitly named as supported for a command doesn’t guarantee that command works. Test it live, bare, before designing anything around it.

Failed commits on FortiOS can discard more than the field that failed. Re-verify state with show/get after any rejected next or end — don’t assume only the offending line was rolled back.

BGP leak-target wants routes that are already in BGP. If your source route is only ever going to be a local static or connected route with no real neighbor behind it, look at the static-route-over-VDOM-link pattern first. It’s simpler, better documented in practice (if not in the chapter you’d naturally reach for), and has fewer moving parts to get wrong.

When a documented field silently vanishes from a live completion list, ask whether it’s actually still necessary before you go looking for a toggle to bring it back. In this case it wasn’t needed at all — FortiOS was already inferring the answer from context.

Closing thought

None of this makes FortiOS’s VRF handling badly designed — the static-route-over-VDOM-link mechanism, once found, is clean and does exactly what it says. But it’s a reminder that “the documentation says this model supports this command” and “this command works, as documented, on this specific unit” are two different claims, and homelab work is exactly the right place to find out which one you’re actually dealing with before it matters somewhere it can’t be a scratch VRF.

If you want the theory-first version of this — why FortiOS has no static route leaking, the BGP RT import/export model, and how it compares to Cisco and Junos — that’s the earlier post this one follows on from.