Using Bicep to deploy Azure Front Door using Private Link to Web Servers.
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
LoadbalancerexistingVNet.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!