Using Bicep to deploy Azure Front Door using Private Link to Web Servers.

Hello! Welcome back to my blog. Today, I want to explain how to set up a secure method for accessing a website or web application hosted on an IIS/Web Server.

Yes! Sometimes you have scenarios where the website or application cannot be migrated to an App Service, so you need a secure way to access it. We don’t want to assign a public IP to the server because of the risks involved in exposing it directly to the internet.

Azure Front Door is a global service that can distribute requests across regions,. It ensures high performance, reliability, and security while optimizing web traffic.

A little summary:

💡 Azure Front Door handles public access, and IIS/Webserver will not be exposed to the internet directly.
💡 Private Link ensures secure, direct connectivity without exposing the IIS/Webserver Virtual Machine.
💡 Performance and security are optimized using Front Door’s global reach and WAF.


What do we need.

I have created this in Bicep using the Azure Verified Modules where the values are stored in a biceparam file and we are going to deploy the Bicep with an deployment script, the files are also located in my repository. We need several resources when we set-up this solution, keep in mind that some little things need to be set-up in the Azure Portal, but this will be only the domain validation step. I will describe it later in this blog.

I assume you have already a Virtual Machine that you want to protect, i will use existing resources to connect to the backend trough the load balancer.

Because we are using AFD managed certificates and the backend server has an certificate on the server itself, we don't deploy a key-vault to store the certificates in. You can if you want add this to the bicep file. As last step we want to add a IIS/Web server as a backend so we are calling a existing resources and add this to the backendpool.

To conclude, it's good to mention you need a premium sku for the Front Door to implement this solution.

First we list the needed resources:

  • Resource Group
  • Azure Front Door (premium sku)
  • Web Application Firewall
  • Private Link Service
  • Load Balancer
  • Log Analytics Workspace

We have the existing resources:

  • Resource Group (Virtual Network)
  • Virtual Network
  • Virtual Machine/NIC

Let's take a look at the files.

For the deployment script we created a simple structure that in the end uses the AZ CLI to execute the deployment with an WHAT-IF statement.

 1
 2param(
 3    [Parameter(Mandatory = $true)][string] $subscriptionID = "",
 4    [Parameter(Mandatory = $true)][ValidateSet("northeurope", "westeurope")][string] $location = "", 
 5    [ValidateSet("frontdoor")][string][Parameter(Mandatory = $true, ParameterSetName = 'Default')] $productType = "",
 6    [Parameter(Mandatory = $true, Position = 3)] [validateSet("prod", "acc", "dev", "test")] [string] $environmentType = "",
 7    [switch] $deploy
 8)
 9
10# Ensure parameters are captured
11Write-Host "Subscription ID: $subscriptionID"
12Write-Host "Location: $location"
13Write-Host "Product Type: $productType"
14Write-Host "Environment Type: $environmentType"
15
16$deploymentID = (New-Guid).Guid
17
18<# Set Variables #>
19az account set --subscription $subscriptionID --output none
20if (!$?) {
21    Write-Host "Something went wrong while setting the correct subscription. Please check and try again." -ForegroundColor Red
22}
23
24
25$updatedBy = (az account show | ConvertFrom-Json).user.name 
26$location = $location.ToLower() -replace " ", ""
27
28$LocationShortCodeMap = @{
29    "westeurope"  = "weu";
30    "northeurope" = "neu";
31}
32
33$locationShortCode = $LocationShortCodeMap.$location
34
35if ($deploy) {
36    Write-Host "Running a Bicep deployment with ID: '$deploymentID' for Environment: '$environmentType' with a 'WhatIf' check." -ForegroundColor Green
37    az deployment sub create `
38    --name $deploymentID `
39    --location $location `
40    --template-file ./azure-frontdoor.bicep `
41    --parameters ./azure-frontdoor.bicepparam `
42    --parameters updatedBy=$updatedBy location=$location locationShortCode=$LocationShortCode productType=$productType environmentType=$environmentType `
43    --confirm-with-what-if `
44}

Good to mention is that we are using an azure-frontdoor.bicepparam file and we declare it using the --parameters /.azure-frontdoor.bicep keep in mind that the files are located in the same folder. We want to use the WHAT-IF statement to do a lest check because you have a great overview what will be created or modified.

For the main file azure-frontdoor.bicep I have created several modules using the Azure Verified Modules that create the resources, but also call existing modules for existing resources. In this situation I think you want to use an existing virtual network, because the virtual machine needs to be in the same Virtual Network where the Loadbalancer resides. If not you need to deploy a new Virtual Network and migrate the machine to the this Virtual Network. Some things that is good to mention.

Based on your Virtual Network configuration you will need to set-up the subnets for the Frontend confirguration and the Private Link configuration In my example i use this values:

  • existingVNet.properties.subnets[3].id Loadbalancer
  • existingVNet.properties.subnets[4].id PrivateLink

So if you have a different subnet order just change the numbers.

  • For the loadbalancer it will create en inbound loadbalancing rule that connects over port 443.
  • For the WAF policy it will be placed in detection mode to first check the traffic.
  • I have set the policy auto approve when creating the private link.
  1targetScope = 'subscription'
  2
  3param updatedBy string
  4
  5@description('Environment Type: example prod.')
  6@allowed([
  7  'test'
  8  'dev'
  9  'prod'
 10  'acc'
 11  'poc'
 12])
 13param environmentType string
 14
 15param subscriptionId string
 16
 17@description('Unique identifier for the deployment')
 18param deploymentGuid string = newGuid()
 19
 20@description('Product Type: example listed below')
 21@allowed([
 22  'frontdoor'
 23  'firewall'
 24  'network'
 25  'avd'
 26])
 27param productType string
 28
 29@description('Azure Region to deploy the resources in.')
 30@allowed([
 31  'westeurope'
 32  'northeurope'
 33])
 34param location string = 'westeurope'
 35
 36@description('Location shortcode')
 37param locationShortCode string 
 38
 39@description('Add tags as required as Name:Value')
 40param tags object = {
 41  Environment: environmentType
 42  LastUpdatedOn: utcNow('d')
 43  LastDeployedBy: updatedBy
 44}
 45
 46//resource group parameters
 47param resourceGroupName string 
 48
 49//azure front door parameters
 50param azureFrontDoorName string
 51param azureFrontDoorSKU string
 52param azureFrontDoorLocation string
 53param azureFrontDoorAFDEndpointName string
 54param hostDomainName string
 55param customDomainName string
 56param originGroupName string
 57param originName string
 58param routeName string
 59
 60//private link parameters
 61param privateLinkServiceName string
 62
 63//load balancer parameters
 64param loadBalancerName string
 65param frontendIPConfigurationName string
 66param backendAddressPoolName string
 67
 68// param backendAddressPoolName2 string
 69param loadBalancerRuleNameHTTPS string
 70param probeHTTPSName string
 71param loadBalancerSkuName string
 72
 73//waf policy parameters
 74param wafPolicyName string
 75
 76//log analytics workspace parameters
 77param logWorkspaceName string
 78param logWorkspaceSkuName string
 79
 80param existingSubscriptionId string 
 81param existingResourceGroupName string 
 82param existingVnetName string 
 83
 84module createResourceGroup 'br/public:avm/res/resources/resource-group:0.4.0' = {
 85  scope: subscription(subscriptionId)
 86  name: 'rg-${deploymentGuid}'
 87  params: {
 88    name: resourceGroupName
 89    location: location
 90    tags: tags
 91  }
 92}
 93
 94
 95resource existingResourceGroup 'Microsoft.Resources/resourceGroups@2024-11-01' = {
 96  name: existingResourceGroupName
 97  location: location
 98  tags: tags
 99}
100
101resource existingVNet 'Microsoft.Network/virtualNetworks@2022-11-01' existing = {
102  name: existingVnetName
103  scope: resourceGroup(existingSubscriptionId, existingResourceGroupName)
104}
105
106module createLoadBalancer 'br/public:avm/res/network/load-balancer:0.4.1' = {
107  scope: resourceGroup(resourceGroupName)
108  name: 'lb-${deploymentGuid}'
109  params: {
110    name: loadBalancerName 
111    location: location
112    frontendIPConfigurations: [
113      {
114        name: frontendIPConfigurationName
115        subnetId: existingVNet.properties.subnets[3].id
116      }
117    ]
118    backendAddressPools: [
119      {
120        name: backendAddressPoolName
121      }
122    ]
123    loadBalancingRules: [
124      {
125        backendAddressPoolName: backendAddressPoolName
126        backendPort: 0
127        disableOutboundSnat: true
128        enableFloatingIP: true
129        enableTcpReset: false
130        frontendIPConfigurationName: frontendIPConfigurationName
131        frontendPort: 0
132        idleTimeoutInMinutes: 4
133        loadDistribution: 'Default'
134        name: loadBalancerRuleNameHTTPS
135        probeName: probeHTTPSName
136        protocol: 'All'
137      }
138    ]   
139    skuName: loadBalancerSkuName
140    probes: [
141      {
142        intervalInSeconds: 5
143        name: probeHTTPSName
144        numberOfProbes: 2
145        port: '443'
146        protocol: 'Tcp'
147      }
148    ]
149    tags: tags
150  }
151}
152
153module createWAFPolicy 'br/public:avm/res/network/front-door-web-application-firewall-policy:0.3.1'= {
154  scope: resourceGroup(resourceGroupName)
155  name: 'waf-${deploymentGuid}'
156  params: {
157    name: wafPolicyName
158    sku: azureFrontDoorSKU
159    policySettings: {
160      enabledState: 'Enabled'
161      mode: 'Detection'
162      }
163    
164
165  
166  }
167}
168
169module createAzureFrontdoor 'br/public:avm/res/cdn/profile:0.11.1' = {
170  scope: resourceGroup(resourceGroupName)
171  name: 'afd-${deploymentGuid}'
172  params: {
173    name: azureFrontDoorName
174    sku: azureFrontDoorSKU
175    diagnosticSettings: [
176      {
177        name: 'customSetting'
178        logCategoriesAndGroups: [
179          {
180            categoryGroup: 'allLogs'
181            enabled: true
182          }
183        ]
184        metricCategories: [
185          {
186            category: 'AllMetrics'
187            enabled: true
188          }
189        ]
190        workspaceResourceId: createLogAnalyticsWorkspace.outputs.resourceId
191      }
192    ]
193    tags: tags
194    location: azureFrontDoorLocation
195    originResponseTimeoutSeconds: 60
196    endpointName: 'afd-endpoint'
197    customDomains: [
198      {
199        name: customDomainName
200        hostName: hostDomainName
201        certificateType: 'ManagedCertificate'
202      }
203    ]
204    originGroups: [
205      {
206        name: originGroupName
207      
208
209
210        loadBalancingSettings: {
211          additionalLatencyInMilliseconds: 50
212          sampleSize: 4
213          successfulSamplesRequired: 3
214        }
215        origins: [
216          {
217            name: originName
218            hostName: hostDomainName
219            sharedPrivateLinkResource: {
220              privatelink: {
221              id: createPrivateLinkServices.outputs.resourceId
222              }
223              privateLinkLocation: location
224              requestMessage: 'allow'
225             }
226            
227          }
228        ]
229      }
230    ]
231    afdEndpoints: [
232      {
233        name: azureFrontDoorAFDEndpointName
234        routes: [
235          {
236            name: routeName
237            originGroupName: originGroupName
238            customDomainNames: [customDomainName]
239
240          }
241        ]
242      }
243    ]
244    securityPolicies: [
245        {
246          name: 'sec-policy'
247          
248          associations: [
249            {
250              domains: [
251                {
252                  id:'/subscriptions/${subscription().subscriptionId}/resourcegroups/${resourceGroupName}/providers/Microsoft.Cdn/profiles/${azureFrontDoorName}/afdendpoints/${azureFrontDoorAFDEndpointName}'
253                }
254                ]
255              patternsToMatch: [
256                '/*'
257              ]
258            }
259
260          ]
261          wafPolicyResourceId: createWAFPolicy.outputs.resourceId
262        }
263      ]
264  }
265
266  dependsOn: [createResourceGroup, createLoadBalancer]
267}
268
269module createPrivateLinkServices 'br/public:avm/res/network/private-link-service:0.2.0' = {
270  scope: resourceGroup(resourceGroupName)
271  name: 'pls-${deploymentGuid}'
272  params: {
273    name: privateLinkServiceName
274    location: location
275
276    ipConfigurations: [
277      {
278        name: 'ipconfig'
279        properties: {
280          primary: true
281          privateIPAllocationMethod: 'Dynamic'
282          subnet: {
283            id: existingVNet.properties.subnets[4].id
284          }
285        }
286      }
287    ]
288    loadBalancerFrontendIpConfigurations: [
289      {
290         id: '/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.Network/loadBalancers/${loadBalancerName}/frontendIPConfigurations/${frontendIPConfigurationName}'
291      }
292    ]
293
294    autoApproval: {
295
296      subscriptions: [
297        '*'
298      ]
299    }
300
301    visibility: {
302      subscriptions: [
303        subscription().subscriptionId
304      ]
305    }
306    
307    enableProxyProtocol: true
308    tags: tags
309  }
310  dependsOn: [createResourceGroup, createLoadBalancer]
311}
312
313module createLogAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.10.0' = {
314  scope: resourceGroup(resourceGroupName)
315  name: 'law-${deploymentGuid}'
316  params: {
317    name: logWorkspaceName
318    skuName: logWorkspaceSkuName
319    location: location
320    tags: tags
321  }
322  dependsOn: [createResourceGroup]
323}

And as a last part I have created an azure-frontdoor.bicepparam file where i store my values and of course you need to set-up your own values when calling an existing vnet.

 1using 'azure-frontdoor.bicep'
 2
 3
 4//parameters for the deployment.
 5param updatedBy = 'demo'
 6param subscriptionId = ''
 7param environmentType = 'prod' 
 8param location = 'westeurope' 
 9param locationShortCode = 'weu' 
10param productType = 'frontdoor'
11
12//parameters for the existing resources
13param existingSubscriptionId = ''
14param existingVnetName = ''
15param existingResourceGroupName = ''
16
17//parameters for the resource group
18param resourceGroupName = 'rg-${productType}-${environmentType}-${locationShortCode}'
19
20//parameters for the Azure Front Door
21param azureFrontDoorName = 'afd-${productType}-${environmentType}-${locationShortCode}'
22param azureFrontDoorSKU = 'Premium_AzureFrontDoor'
23param azureFrontDoorLocation = 'global'
24param azureFrontDoorAFDEndpointName = 'afdendpoint${environmentType}${locationShortCode}'
25param hostDomainName = 'demo.cloud'
26param customDomainName = 'demo-cloud'
27param originGroupName = 'og-group-demo-cloud'
28param originName = 'og-demo-cloud'
29param routeName = 'route-demo-cloud'
30
31//parameters for the Front Door web application firewall
32param wafPolicyName = 'waf${productType}${environmentType}${locationShortCode}'
33
34//parameters for the Log Analytics Workspace
35param logWorkspaceName = 'law-${productType}-${environmentType}-${locationShortCode}'
36param logWorkspaceSkuName = 'PerGB2018'
37
38//parameters for the Private Link Services
39param privateLinkServiceName = 'pls-${productType}-${environmentType}-${locationShortCode}'
40
41//parameters for the Load Balancer
42param loadBalancerName = 'lb-${productType}-${environmentType}-${locationShortCode}'
43param loadBalancerSkuName = 'Standard'
44param frontendIPConfigurationName = 'frontendconfig${environmentType}'
45param backendAddressPoolName = 'backend${environmentType}'
46param loadBalancerRuleNameHTTPS = 'lb-rule-https-${environmentType}'
47param probeHTTPSName = 'probe-https-${environmentType}'

You can run the files using the command .\deploy-frontdoor.ps1 -subscriptionID "" -location "westeurope" -productType "frontdoor" -environmentType "acc" -deploy. Keep in mind that you run the command on the location you have stored the files. You need to fill in your own subscriptionID and you can use your own values for the environtmentType.

Hard parts to configure.

In my experience there are a couple of hard parts to configure but with the right configuration you can work around it, so let's name them.

  • When using the Azure Verified Modules you have several public templates you can use but in my case i needed the CDN one: br/public:avm/res/cdn/profile:0.11.1'

  • Naming of some values within the PrivateLink module, for example: loadBalancerFrontendIpConfigurations you can not use and copy the frontendconfiguration.id so you need to use the whole path '/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.Network/loadBalancers/${loadBalancerName}/frontendIPConfigurations/${frontendIPConfigurationName}' but it has a structure that you can parameterize the values as much as possible.

  • Naming of some values within the Azure Front Door module, for example: securityPolicies you will need to use the exact values (naming) when you create the endpoints else the deployment will fail. That is the reason I have hardcode and parameterize it as much as possible. '/subscriptions/${subscription().subscriptionId}/resourcegroups/${resourceGroupName}/providers/Microsoft.Cdn/profiles/${azureFrontDoorName}/afdendpoints/${azureFrontDoorAFDEndpointName}'

  • If you are adding a new domain you need to do the validate it with your hosting partner or in Azure DNS.

  • You cannot add the NIC or IP-address directly in the backend pool of the loadbalancer, you can of course do it in the Azure portal but you can also use an Powershell script, please use your own values:

 1# Define variables
 2$resourceGroup = ""   
 3$lbName = ""        
 4$backendPoolName = ""  
 5$nicName = ""               
 6
 7# Get the Load Balancer
 8$lb = Get-AzLoadBalancer -ResourceGroupName $resourceGroup -Name $lbName
 9
10# Get the Backend Pool
11$backendPool = $lb.BackendAddressPools | Where-Object { $_.Name -eq $backendPoolName }
12
13# Ensure the backend pool exists
14if (-not $backendPool) {
15    Write-Host "Backend pool '$backendPoolName' not found in Load Balancer '$lbName'. Exiting..." -ForegroundColor Red
16    exit
17}
18
19# Get the Network Interface (NIC)
20$nic = Get-AzNetworkInterface -ResourceGroupName $resourceGroup -Name $nicName
21
22# Associate the NIC with the Load Balancer Backend Pool
23$nic.IpConfigurations[0].LoadBalancerBackendAddressPools += $backendPool
24
25# Apply the changes
26Set-AzNetworkInterface -NetworkInterface $nic
27
28# Verify the association
29$nicUpdated = Get-AzNetworkInterface -ResourceGroupName $resourceGroup -Name $nicName
30$backendPools = $nicUpdated.IpConfigurations[0].LoadBalancerBackendAddressPools
31
32# Output the result
33if ($backendPools -contains $backendPool) {
34    Write-Host "Successfully added NIC '$nicName' to backend pool '$backendPoolName' in Load Balancer '$lbName'." -ForegroundColor Green
35} else {
36    Write-Host "Failed to add NIC '$nicName' to backend pool '$backendPoolName'." -ForegroundColor Red
37}

What's next?

You have set-up a solution that provide security towards your backend webserver, of course you will need to also check on your server if everything is set in place and the ports are being accessible and that IIS is rightly configured but if the website or webapplication was running before the front door i assume every thing is being set-up correctly.

In the WAF policy connected to your Azure Front Door you can check if it is getting some hits, it should not prevent or block anything because we set this in detection mode, if you don't see anything strange after a week you can set it to prevention mode. It will be also good that after the validation of the domain you associate it with the right security policy.


Conclusion

You will now have an fully functional Azure Front Door connected with an Private Link Service to your backend so your Virtual Machine doesn't need to be exposed publicly to the internet when hosting a Website or Web Application on your Virtual Machine.

In this blog, we've walked through how to deploy Azure Front Door using Private Link to securely access a Web Server, like IIS, hosted on Azure. By combining Azure's powerful features such as Private Link, Azure Front Door, and Web Application Firewall (WAF), we've built a highly secure and scalable solution for managing web traffic.

Sometimes i was struggling a little bit with the code and the naming conventions, but with some determination, reading and with a little help of my good colleague pixelrobots it went very well.

I hope this blog can help you with deploying Azure Front Door connecting backend servers using Private Link Service for your future projects, see you next time!