I recently worked with a customer who had a web application running on a virtual machine in Azure.

Nothing particularly unusual there. The application needed to be available from the Internet over HTTP(S), and the requirement was to protect it with both intrusion prevention (IPS) and a web application firewall (WAF).

Microsoft has a very good starting point for this kind of conversation: Azure Firewall and Application Gateway for virtual networks. In that article, Microsoft provides a simplified decision tree for deciding where the firewall and Application Gateway should sit.

For the customer’s requirements, the Application Gateway in front of Azure Firewall design was a natural candidate: WAF first, firewall between Application Gateway and the application.

But I wanted to try something slightly different.

Why FortiGate-VM instead of Azure Firewall?

An excellent question, I hear you ask.

The customer already uses FortiGate on-premises and in their IaaS environment. Their technical team knows the product, their security policies and operational processes are built around it, and they already have experience troubleshooting it.

So rather than introduce a different firewall platform into Azure, I wanted to maintain a consistent approach: use FortiGate-VM as the network security control in front of the web service.

That brings us a familiar policy model and troubleshooting toolkit, along with capabilities such as FortiGuard IPS, source-based policies, logging and — where appropriate — Fortinet’s Internet Service Database (ISDB) to help identify and deny categories of known unwanted traffic. Those controls need to be explicitly configured, of course; installing a FortiGate doesn’t automatically make every bad source disappear.

This isn’t an argument that one vendor’s firewall is universally better than another. It’s an argument for a deployment that fits the customer’s existing skills and operating model.

Why not FortiWeb? Or FortiGate’s built-in WAF?

Also reasonable questions.

FortiWeb is a dedicated WAF platform and would absolutely warrant consideration in a Fortinet-centric design. FortiGate also provides WAF-related inspection features, including options in proxy-based policies. I’ve used the integrated FortiGate capabilities where they were appropriate for a customer’s needs and budget.

For this project, however, the team who would ultimately support the service was already familiar with Azure Application Gateway. The customer also specifically wanted bot protection, and the Azure WAF on Application Gateway can enable Microsoft’s managed Bot Manager rules.

Beyond bot protection, Application Gateway WAF gives us Microsoft-managed WAF rule sets, a dedicated HTTP reverse-proxy layer, host/path routing where needed, HTTPS termination and re-encryption options, backend health checks, integration with Azure Monitor and WAF policy management independently of the network firewall.

In short, Azure Application Gateway was a sensible choice for the application layer, while FortiGate was a sensible choice for the network security layer.

Which component goes first?

Microsoft also documents an Azure Firewall in front of Application Gateway variant, even though it isn’t one of the outcomes shown in the simplified decision tree.

The appeal for me was straightforward: let FortiGate have first refusal on inbound traffic, using the actual Internet client’s source IP, before forwarding acceptable requests to the Application Gateway/WAF.

In Microsoft’s Azure Firewall example, the added usefulness of HTTPS inspection can be constrained, depending on the firewall tier and mode of operation. With an appropriately configured and licensed FortiGate-VM, there is also the option to inspect the decrypted inbound HTTPS workload using a suitable Protecting SSL Server profile. That’s not achieved by certificate-only inspection; more on that later.

There was, however, a fairly significant routing complication to consider.

The source-IP problem

Application Gateway is a reverse proxy. It establishes a new connection to the backend web server, so the TCP connection arriving at IIS no longer has the Internet client’s IP as its source.

Instead, the gateway records client information in request headers. In particular, Application Gateway manages X-Forwarded-For, along with X-Forwarded-Proto and X-Forwarded-Port.

That’s all well and good when Application Gateway sees the actual Internet client connecting to it.

But if I placed a FortiGate before Application Gateway and performed source NAT on the FortiGate, AppGW would see the FortiGate’s inside interface address as the connection source. Its client information would then be based on the wrong IP unless I took additional measures.

I started investigating whether I would need to have the FortiGate explicitly insert an XFF header, using a proxy-based configuration along the lines of Fortinet’s guidance on adding X-Forwarded headers towards a protected web server.

There was another reason I had source NAT in mind. Historically, Application Gateway v2 has placed restrictions on user-defined routes (UDRs) on its dedicated subnet. In particular, forcing 0.0.0.0/0 through a virtual appliance could break its management-plane connectivity. With that default-route restriction, keeping the firewall path symmetric wasn’t quite as neat as I wanted.

Then I found something rather useful.

Enter Private Application Gateway

Microsoft’s private Application Gateway deployment capabilities are now generally available and supported for production. The subscription feature is called EnableApplicationGatewayNetworkIsolation.

Among other things, it supports:

  • Private-only frontend — Application Gateway no longer needs a public IP frontend just for this deployment model.
  • Enhanced subnet network controls — including the ability to override the default 0.0.0.0/0 route through a network virtual appliance.
  • Greater control over NSGs — without the historic need to permit the same GatewayManager path as traditional deployments.

Microsoft also now requires the Application Gateway subnet to be delegated to Microsoft.Network/applicationGateways for these private deployment capabilities.

My favourite part is the route control.

Because the Application Gateway subnet can send its Internet-bound traffic back through FortiGate, I can put the firewall first and publish the gateway through a DNAT-only VIP, with source NAT disabled.

The public client’s IP survives as the TCP source all the way to Application Gateway. AppGW then does its usual reverse-proxy work and forwards client information to the backend in XFF. I don’t need to try to fabricate that identity in a header at the FortiGate.

Hurrah.

The resulting architecture

                         INTERNET CLIENT
                          Public client IP
                                 |
                                 | HTTP/HTTPS
                                 v
                      FortiGate-VM public IP
                       Azure port1 / external
                                 |
                           VIP: DNAT only
                           NO SOURCE NAT
                         Network policy + IPS
                                 |
                                 v
                   PRIVATE APPLICATION GATEWAY
                         WAF_v2 / Bot Manager
                          10.252.0.250
                                 |
                           HTTP reverse proxy
                         X-Forwarded-For set
                                 |
                                 v
                       IIS VM in spoke VNet
                          10.254.0.10

          AppGW subnet: 0.0.0.0/0 --> FortiGate port2
             (symmetric Internet-facing return path)

This also makes the division of responsibilities fairly clear:

FortiGate-VM handles the external network security decision first; Application Gateway handles HTTP reverse proxying and WAF enforcement; and IIS serves the application, without a persistent public IP address of its own.

The Application Gateway-to-IIS connection across the peered VNets is not forced through FortiGate by this design. That is intentional. The AppGW subnet’s default route matters for the Internet-facing return path; more-specific peering routes still take care of the backend connection.

One important caveat: this is an architecture applying some Zero Trust principles, not a claim that a firewall and WAF alone give an application a complete Zero Trust security model. Application authentication, authorisation, identity, least privilege and application security are separate design responsibilities.

Build it yourself: a disposable PowerShell lab

To make the concept practical, I’ve put together a small deployment example that creates a Windows Server VM running IIS, two peered VNets, a private-only WAF_v2 Application Gateway, and the required route tables. FortiGate is deployed separately so you can follow the same Azure Marketplace workflow you would ordinarily use.

Important: this lab is intentionally HTTP-only on TCP/80, including the backend connection. It demonstrates routing, source-IP preservation and WAF operation. It does not demonstrate a suitably encrypted production service. Use a dedicated test subscription/resource group; do not put real credentials, sensitive data or customer workloads behind it.

The example uses the following resource names and addressing:

Resource Value
Resource group rg-uks-01
Region UKSouth
Hub VNet vnet-hub-uks-01 — 10.252.0.0/15
Application Gateway subnet snet-appgw — 10.252.0.0/24
External FortiGate subnet snet-external — 10.253.0.192/26
Internal FortiGate subnet snet-internal — 10.253.0.160/27
Web spoke vnet-web-uks-01 — 10.254.0.0/24
Web subnet snet-web — 10.254.0.0/26
Backend Windows VM vm-web-uks-01 — 10.254.0.10
Private Application Gateway agw-uks-01 — 10.252.0.250
Route tables rt-agw-uks-01, rt-web-uks-01

Note that the hub address range is 10.252.0.0/15, rather than /23. The /23 would not include my FortiGate subnets in 10.253.0.0.

Step 0 — Register the feature

In PowerShell 7, install the Az module if necessary and connect to your test subscription:

Install-Module -Name Az -Scope CurrentUser -Repository PSGallery -Force
Connect-AzAccount
$subscription = '<YOUR-SUBSCRIPTION-ID>'
Set-AzContext -SubscriptionId $subscription

Register-AzProviderFeature -FeatureName 'EnableApplicationGatewayNetworkIsolation' -ProviderNamespace 'Microsoft.Network'
Get-AzProviderFeature -FeatureName 'EnableApplicationGatewayNetworkIsolation' -ProviderNamespace 'Microsoft.Network'

Proceed when the state reads Registered, and refresh Microsoft.Network registration:

Register-AzResourceProvider -ProviderNamespace 'Microsoft.Network'

Despite the feature still appearing under a Preview features heading in parts of the portal, Microsoft documents these controls as generally available.

Step 1 — Deploy Azure networking, IIS and private Application Gateway

The full Azure PowerShell scripts live alongside this article in the companion GitHub lab. I’m keeping the longer PowerShell out of the article body so it’s easier to copy, run and maintain independently.

Run:

./scripts/01-Deploy-Lab.ps1 -SubscriptionId $subscription -ResourceGroupName 'rg-uks-01' -Location 'UKSouth'

The script creates the VNets, hub/spoke peering and web NSG; creates a Windows Server VM; enables IIS with an ASP.NET test page that displays REMOTE_ADDR and XFF; creates a WAF policy with Microsoft DRS and Bot Manager; and creates a private-only WAF_v2 Application Gateway with an HTTP listener and backend HTTP settings.

It also creates the two UDR resources but deliberately does not add the 0.0.0.0/0 routes yet. Sending AppGW traffic to a non-existent firewall while it’s being deployed would make troubleshooting unnecessarily entertaining.

The VM is private in the completed deployment. As new Azure subnets can’t rely on implicit outbound access, the bootstrap briefly uses a temporary, NSG-protected public IP for outbound provisioning, then removes it. This is lab convenience, not how I would provision production workloads.

Step 2 — Deploy and license FortiGate-VM

You can deploy FortiGate-VM through PowerShell, but for this demonstration I’ll use Fortinet’s Marketplace deployment method.

Use the external subnet for port1 and internal subnet for port2. Give port1 a public IP and ensure Azure NIC IP forwarding is enabled. Check the actual private IPs assigned by Azure: my illustrative configuration assumes port1 is 10.253.0.196 and port2 is 10.253.0.164, but don’t blindly copy these. FortiGate also needs static routes for 10.252.0.0/24 and 10.254.0.0/24 via the Azure subnet gateway 10.253.0.161 on port2; those are not directly attached networks from the firewall’s point of view.

Before deploying, check Fortinet’s current recommended FortiOS release. The appropriate release is a choice for the time of deployment, not necessarily the newest version available.

What about the permanent FortiGate-VM trial?

The permanent VM trial is handy for exploring FortiOS, but it has strict platform and security-service limitations, including no FortiGuard subscription support. I would not use it as proof that production IPS is working.

For my lab, I can use a BYOL FortiGate-VM with a suitable FortiFlex VM02 entitlement that includes the necessary FortiGuard IPS services. PAYG is another Azure Marketplace route where the subscription allows it. For a real customer, I’d work with a Fortinet partner to size the appliance and decide between commercial licensing models, including FortiFlex where appropriate.

Step 3 — Configure the FortiGate VIP and security policy

In this HTTP-only lab, the FortiGate needs:

  1. An external TCP/80 VIP, mapping its external private NIC IP to 10.252.0.250:80. Azure handles the mapping from its public IP to port1’s private IP.
  2. A port1-to-port2 policy for that VIP, with source NAT disabled.
  3. Logging and, with appropriate licensing, a web-server IPS sensor such as protect_http_server if available on your release.

There is an illustrative FortiOS CLI example in the companion repository. Adapt it to your interface names, policies, FortiOS release and actual IP addresses.

For firewall administration, limit HTTPS/SSH access to an approved management route or known management addresses. Don’t leave an Internet-facing administrator GUI wide open just because it’s a lab.

Step 4 — Enable the UDRs

With the FortiGate deployed, its private addresses confirmed, and Azure IP forwarding enabled, run:

./scripts/02-Enable-FortiGate-Routing.ps1 -SubscriptionId $subscription -FortiGateInsideIp '10.253.0.164' -WhatIf
./scripts/02-Enable-FortiGate-Routing.ps1 -SubscriptionId $subscription -FortiGateInsideIp '10.253.0.164'

This adds the default routes to the FortiGate’s inside NIC and associates the route tables with the AppGW and web subnets.

Again, the AppGW subnet route is crucial for the return traffic to Internet clients. The web subnet default route is for web-server egress and can be omitted in the lab if you don’t want to test outbound access. Any outbound traffic you want to permit from IIS will additionally need a FortiGate outbound policy and appropriate source NAT.

Step 5 — Test, test, test

Once the Application Gateway reports a healthy IIS backend and you’ve published the VIP, browse to:

http://<FORTIGATE-PUBLIC-IP>/

You should see the Network Stalwart IIS test page, including the observed X-Forwarded-For value and the backend’s REMOTE_ADDR. The latter is typically an Application Gateway instance address, because the gateway is a reverse proxy. The former should include the originating Internet client address as seen by Application Gateway.

There’s a simple way to demonstrate the WAF separately from IPS. The lab policy includes an intentionally artificial custom WAF rule that blocks requests containing a particular header:

curl.exe -i -H 'x-stalwart-lab-test: block-me' 'http://<FORTIGATE-PUBLIC-IP>/'

A 403 response should indicate that the Application Gateway WAF blocked the request. Confirm the event in the WAF logs if you’ve enabled Azure Monitor diagnostics. This custom rule is for lab verification only — it is not a realistic attack signature, and should be removed outside this example.

For IPS, validate the FortiGuard entitlement and sensor first, then use a documented, non-destructive test for a specific enabled signature and check FortiGate’s Security Events > Intrusion. A simple SQL-injection-looking URL does not necessarily trigger a given IPS profile, and a WAF rejection doesn’t prove that IPS worked. I prefer to verify each security control independently rather than assume the final HTTP response proves everything happened as intended.

The repository includes a small PowerShell test script covering the healthy request, the predictable WAF block and backend health checks.

What would change for a production deployment?

Quite a lot, deliberately.

My demo uses HTTP/80 end to end because I want readers to follow the network routing and observe the headers without having to build PKI at the same time. A real Internet-facing customer application should normally use TLS on every leg:

Client -- HTTPS/443 --> FortiGate VIP
       -- HTTPS/443 --> Private Application Gateway WAF
       -- HTTPS/443 --> Backend web application

The AppGW listener would use an appropriate TLS certificate. The backend would also use a trusted certificate for end-to-end TLS; depending on the design, that can be a public CA or a correctly trusted private CA.

If I wanted IPS inspection of the encrypted HTTP payload at the FortiGate, I’d use a properly configured server-protection SSL inspection profile with the required certificate/private-key material, on a FortiOS release that supports the chosen inspection mode. Certificate-inspection alone won’t give the IPS engine decrypted HTTP requests. I’d also ensure the FortiGate’s administrative HTTPS listener doesn’t conflict with the public service VIP, preferably by keeping management on a private/restricted path.

There are many other production considerations: FortiGate HA and scaling, Azure DDoS controls, outbound restrictions, certificate lifecycle and secure key handling, accurate client-IP trust boundaries, WAF/IPS false-positive tuning, log retention, patching, application identity/security, resource grouping, and suitable IaC/CI/CD processes.

And, very importantly, the lab only uses a single IIS VM and single FortiGate. Neither is a production high-availability design.

If you take inspiration from this article for a real environment, tailor the whole deployment to your security, availability and operational requirements — ideally with someone who knows both Azure networking and the relevant security appliances. Don’t copy an HTTP-only tutorial and call it done.

Closing thoughts

Private Application Gateway’s new network controls removed the constraint that was making this design awkward for me.

The end result is a clean arrangement: FortiGate receives the real Internet source IP, performs the first security decision, DNATs the connection without SNAT, and lets private Application Gateway do what it does best — proxy HTTP requests and enforce the WAF policy.

It gives the customer a familiar FortiGate operational model alongside an Azure-native WAF managed by a team who already knows Application Gateway. That’s a combination I’m happy to work with.

If you’d like to discuss this design, the more traditional Application Gateway in front of firewall approach, a legacy Application Gateway v1 deployment, or how to make any of these production-ready, get in touch.


Further reading: Microsoft’s Azure Firewall/Application Gateway decision tree, Private Application Gateway deployment, FortiGate-VM on Azure, and the companion GitHub lab.