We have been talking about ‘public’ application scenarios for a while, it is time to get into ‘internal’ application within a private network. This scenario is commonly used in Line of Business (LOB) application, which includes a web application accessing data in SQL, and sometime has a batch job. Here are the characteristics or matching criteria:
- Application utilizes web protocol, https, to communicate with end user or consuming application, such as MVC Web App, REST API, etc.
- Application has an optional batch job, which runs on background in a preset interval
- Application and batch job is only accessible within private network
- Data is served from one or more Azure SQL Database within private network
- Data sensitivity is medium, which has some impact to the organization
Here are the areas I would like to demonstrate in this recipe:
- Utilize Private Endpoint to provide accessible route from private network
- Block access from public internet for Azure PaaS
- Use VNET Integration to access private network from Azure App Service
- Deploy web application into App Service that is only accessible from private network
- Demonstrate CI/CD automation with Azure DevOps using Bicep and YAML
I am reusing most of the Bicep modules and techniques from previous posts, and you can find them on the Azure Recipes List (link). In addition, we need a private network and an Azure DevOps self-hosted agent, please check out Automation: Provision Self-Hosted Agent for Azure DevOps (link). Of course, you can find all the code in GitHub (link).
Application Example
I am using ‘Invest’ as an example web app, which is implemented as a Razor Web App with SQL database as backend plus a batch job using Azure Functions. This web app manages investment portfolio with nightly batch to consolidate transactions. Of course, this is a ‘shell’ application so that I can demonstrate CI/CD for an internal LOB application. Below diagram shows the deployment model into Azure Cloud:

Walkthrough
Private Endpoint
Conceptually, private endpoint (MS Doc link) enables access to the associated Azure service (PaaS) from your private network, so you could consider it as an inbound traffic control mechanism. On a high-level, Private Endpoint requires an IP address and a DNS entry for name resolution, which translate to the following Azure Services:
- A Network Interface (NIC) for an IP address from a subnet
- A Private DNS Zone, and link it to the virtual network
- You can use your organization DNS server to serve the same purpose
- for reference, Azure Private Endpoint DNS configuration (MS Doc link)
- A Private Link Service (MS Doc link), that is created behind the scenes when provisioning the Private Endpoint
- A Private Endpoint itself
The bicep module, private-endpoint.bicep, encapsulates all the above to create the private endpoint and link it to the given Azure Service, below is an example of using the module for Key Vault:
// Provision Private Endpoint to Key Vault
module peKeyVault '../../bicep-modules/private-endpoint.bicep' = {
name: 'pe-${keyVaultName}-${unqiueUtc}'
params: {
peLocation: location
resourceNameforPe: keyVaultName
resourceIdforPe: keyVault.outputs.id
vnetId: vnet.id
subnetId: snetApp.id
peGroupId: 'vault'
}
}
Since the private DNS zone name must follow the exact naming provided by Azure, the module infers the name based on the group id provided:
// Zone name lookup
var zoneNameLookup = {
vault: {
zone: 'privatelink.vaultcore.azure.net'
}
sites: {
zone: 'privatelink.azurewebsites.net'
}
blob: {
zone: 'privatelink.blob.${environment().suffixes.storage}'
}
...
sqlServer: {
zone: 'privatelink${environment().suffixes.sqlServerHostname}'
}
}
var privateDnsZoneName = zoneNameLookup[peGroupId].zone
IMPORTANT: Storage Account requires private endpoint for each sub-resource: blob, file, queue, table, dfs, and web (not shown in the above example). If the sub-resource is used in the solution, make sure the private endpoint is created accordingly.
Note: It is common that Virtual Network is under a different resource group, so the Azure DevOps service principal needs to have the necessary permission to create the link between the virtual network and the private DNS zone. See Microsoft documentation (link)
Disable Public Access
As a recommended practice, we should disable public access explicitly by setting property publicNetworkAccess to Disabled in the bicep code. For Storage Account, the property allowBlobPublicAccess also needs to set to false:
@description('Indicate if Storage Account has public access or not')
param publicNetworkAccess bool = false
resource storageAccount 'Microsoft.Storage/storageAccounts@2021-09-01' = {
name: storageName
location: storageLocation
sku: {
name: storageSku
}
kind: 'StorageV2'
properties: {
supportsHttpsTrafficOnly: true
publicNetworkAccess: publicNetworkAccess ? 'Enabled' : 'Disabled'
allowBlobPublicAccess: publicNetworkAccess
accessTier: 'Hot'
minimumTlsVersion: 'TLS1_2'
}
}
Once the service and the private endpoint are provisioned properly, you should see the public access is disable and a Private Endpoint is provisioned in Approved state, below is the screen capture for Key Vault:

Virtual Network Integration
For Web App and Functions App, Virtual Network (VNet) Integration is required to allow the outbound traffic goes into the virtual network so that they can access other resources (e.g., Key Vault, SQL) within the VNet. We first need a subnet that is delegated to Microsoft.Web/serverFarms, and of course, has the required network route to the target resources. Then, we provide the subnet id to the property virtualNeworkSubnetId. In addition, most organizations set the vnetRouteAllEnabled to true so that causes all outbound traffic to go through the virtual network, which means the Network Security Groups and User Defined Routes applied.
resource webApp 'Microsoft.Web/sites@2022-03-01' = {
name: webAppName
location: webAppLocation
identity:{
type: 'SystemAssigned'
}
properties: {
serverFarmId: webAppServicePlan.id
siteConfig: {
linuxFxVersion: fxConfigure[langEngine].fxVersion
appSettings: appSettings
}
virtualNetworkSubnetId: (subnetIdforIntegration == '') ? null : subnetIdforIntegration
vnetRouteAllEnabled: (subnetIdforIntegration == '') ? false : true
httpsOnly: true
publicNetworkAccess: publicNetworkAccess ? 'Enabled' : 'Disabled'
}
}
You can find a nice diagram showing how the Networking looks like for the Web App / Functions App:

What about Private Endpoint for Application Insights?
Unfortunately, there is no ‘Private Endpoint’ just for Application Insights, instead we need to use Azure Monitor Private Link Scope. From Microsoft documentation (MS Doc link):
Azure Monitor is a constellation of different interconnected services that work together to monitor your workloads. An Azure Monitor Private Link connects a private endpoint to a set of Azure Monitor resources, defining the boundaries of your monitoring network. That set is called an Azure Monitor Private Link Scope (AMPLS).
It is not that difficult to create and test bicep modules for AMPLS for each Application Insights, but it is not a good practice. Instead, you should plan out a monitoring strategy based on your organization/department needs, then create one or more AMPLS accordingly. Since I don’t want to ‘scope creep’ for this recipe, I am leaving it out.
Monitoring using Application Insights
Application Insights auto-instrumentation is built into Azure App Service runtime image, you can enable telemetry collection by adding a few Application settings:
appSettings: [
{
name: 'APPLICATIONINSIGHTS_CONNECTION_STRING'
value: '@Microsoft.KeyVault(VaultName=${keyVaultName};SecretName=${appInsightsConnStrSecretName})'
}
{
name: 'ApplicationInsightsAgent_EXTENSION_VERSION'
value: '~3' // use ~3 for Linux, ~2 for Windows
}
{
name: 'XDT_MicrosoftApplicationInsights_Mode'
value: 'recommended' // used for .NET Core, ignored for other runtimes
}
]
I am creating a one-pager to summarize the key information for Application Insights, will update this post once it is available.
Quick Validation prior to App Deployment
Before deploying the application, we can quickly validate if private endpoints and VNet integration are working properly. For Web App and Functions App connecting to Key Vault, check to see if there is a green checkmark for Key vault Reference under Application Settings in Configuration:

For Functions App to access Storage Account, check to see if the host keys are available under App Keys:

Application Deployment
Since the Azure App Service and Azure Functions App are now locked down in a private network. I am using an Azure DevOps self-hosted agent to deploy the solution, see Automation: Provision Self-Hosted Agent for Azure DevOps (link) for details. So, make sure the pipeline uses the correct pool:
pool: 'Default-Dev'
Other than that, we can just reuse the templates we developed in previous posts (link) to deploy the web application and batch job:
- stage: Dev_App
displayName: 'Deploy to DEV'
jobs:
- template: '/${{variables.yamlTemplateLoc}}/deploy-web-app.yaml'
parameters:
stageName: 'WebApp'
targetEnv: ${{variables.targetEnv}}
resourceGroupName: $(resourceGroupName)
displayAppName: ${{variables.webAppName}}
artifactName: $(webAppName)
serviceConnection: ${{variables.azureServiceConn}}
azureAppName: $(azureWebAppName)
- template: '/${{variables.yamlTemplateLoc}}/deploy-func-app.yaml'
parameters:
stageName: 'BatchJob'
resourceGroupName: $(resourceGroupName)
displayAppName: ${{variables.batchAppName}}
targetEnv: ${{variables.targetEnv}}
artifactName: $(batchAppName)
serviceConnection: ${{variables.azureServiceConn}}
azureFuncName: '$(azureFuncAppName)'
functionAppType: 'functionAppLinux'
Closing out
In this application scenario, Private Endpoint is used to control inbound traffic and make the application as ‘internal’; however, it is not the only option, alternatively, Service Endpoint and App Service Environment can also be used, see Integrate Azure services with virtual networks for network isolation (MS Doc link) for details. As you can see,
This scenario requires a lot more effort on testing, but I am getting my return-on-investment (ROI) from the previous works (link) by reusing the Bicep modules and YAML templates. Anyway, please let me know if you have any suggestion to improve documenting the recipe or structuring the sample code.