Many customers require Web Applications & APIs to only be accessible via a private IP address with a Web Application Firewall on the internet edge, to protect from common exploits and vulnerabilities. Azure Front Door provides global routing and WAF capabilities to satisfy this requirement.
Azure Container Apps ingress can be exposed on either a Public or Private IP address. One option is to put Azure Front Door in front of an ACA public endpoint, but currently there is no way (other than in application code) to restrict access to the ACA public IP address from a single Azure Front Door instance. Azure App Service Access restrictions supports this scenario, but unfortunately, there is currently no equivalent access restriction for Azure Container Apps.
To work around this limitation, Azure Private Link Service can be provisioned in front of an internal ACA load balancer. A Private endpoint (NIC with private IP in a virtual network) is connected to the Private Link Service and an Azure Front Door Premium SKU instance can then be used to connect to the private endpoint (known as a Private Origin in AFD). This configuration removes the need to inspect the value of the "X-Azure-FDID" header sent from AFD since only a single AFD instance is connected to the private endpoint, guaranteeing traffic to the ACA environment occurs only from that specific AFD instance. The overall architecture is captured in the diagram below.
In order to create this architecture, we will cover the high-level steps outlined below.
1. Deploy an internal Azure Container App environment
2. Create an Azure Front Door Premium instance, origin group & route
3. Create an Azure Private Link Service (PLS) instance
4. Deploy an Azure Container App instance
5. Finally, approve the private endpoint connection to PLS
All steps above have been codified into an Azure Bicep deployment and shell script. To deploy the sample, you will need an Azure subscription and Bash or PowerShell console with the Az CLI installed. The Bicep templates and scripts referenced in this article are available on my GitHub, here.
First, let's review the Bash shell script used to deploy the Bicep template. I also included a PowerShell script in my GitHub repo, which is almost identical, if that's your preferred shell.
#!/bin/bash
LOCATION='australiaeast'
PREFIX='frontdoor'
RG_NAME="${PREFIX}-aca-rg"
# create resource group
az group create --location $LOCATION --name $RG_NAME
# deploy infrastructure
az deployment group create \
--resource-group $RG_NAME \
--name 'infra-deployment' \
--template-file ./main.bicep \
--parameters location=$LOCATION \
--parameters prefix=$PREFIX
# get deployment template outputs
PLS_NAME=`az deployment group show --resource-group $RG_NAME --name 'infra-deployment' --query properties.outputs.privateLinkServiceName.value --output tsv`
AFD_FQDN=`az deployment group show --resource-group $RG_NAME --name 'infra-deployment' --query properties.outputs.afdFqdn.value --output tsv`
PEC_ID=`az network private-endpoint-connection list -g $RG_NAME -n $PLS_NAME --type Microsoft.Network/privateLinkServices --query [0].id --output tsv`
# approve private endpoint connection
echo "approving private endpoint connection ID: '$PEC_ID'"
az network private-endpoint-connection approve -g $RG_NAME -n $PLS_NAME --id $PEC_ID --description "Approved"
# test AFD endpoint
curl https://$AFD_FQDN
The script first defines 3 environment variables used throughout the script - LOCATION, PREFIX & RG_NAME. Modify these as you see fit for your environment. A resource group is created using a reference to the $RG_NAME variable, then on line 11, the Bicep template is deployed to the resource group.
Once the deployment has completed, 3 deployment template outputs are collected and used as input to the private endpoint approval command on line 25.
Let's break down the resources the deployed by the template.
param location string = 'australiaeast'
param prefix string = 'contoso'
param imageName string = 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest'
var suffix = uniqueString(resourceGroup().id)
var vnetName = '${prefix}-vnet-${suffix}'
var frontDoorName = '${prefix}-afd-${suffix}'
var wafPolicyName = '${prefix}wafpolicy'
var workspaceName = '${prefix}-wks-${suffix}'
var appName = '${prefix}-app-${suffix}'
var plsNicName = '${prefix}-pls-nic-${suffix}'
var plsName = '${prefix}-pls-${suffix}'
var appEnvironmentName = '${prefix}-env-${suffix}'
var originName = '${prefix}-origin-${suffix}'
var originGroupName = '${prefix}-origin-group-${suffix}'
var afdEndpointName = '${prefix}-afd-ep-${suffix}'
var loadBalancerName = 'kubernetes-internal'
var defaultDomainArr = split(appEnvironment.properties.defaultDomain, '.')
var appEnvironmentResourceGroupName = 'mc_${defaultDomainArr[0]}-rg_${defaultDomainArr[0]}_${defaultDomainArr[1]}'
resource vnet 'Microsoft.Network/virtualNetworks@2022-07-01' = {
name: vnetName
location: location
properties: {
addressSpace: {
addressPrefixes: [
'10.0.0.0/16'
]
}
subnets: [
{
name: 'infrastructure-subnet'
properties: {
addressPrefix: '10.0.0.0/23'
delegations: []
privateEndpointNetworkPolicies: 'Disabled'
privateLinkServiceNetworkPolicies: 'Enabled'
}
}
{
name: 'privatelinkservice-subnet'
properties: {
addressPrefix: '10.0.2.0/28'
delegations: []
privateEndpointNetworkPolicies: 'Disabled'
privateLinkServiceNetworkPolicies: 'Disabled'
}
}
]
virtualNetworkPeerings: []
enableDdosProtection: false
}
}
resource wks 'Microsoft.OperationalInsights/workspaces@2021-12-01-preview' = {
name: workspaceName
location: location
properties: {
sku: {
name: 'pergb2018'
}
retentionInDays: 30
features: {
enableLogAccessUsingOnlyResourcePermissions: true
}
workspaceCapping: {
dailyQuotaGb: -1
}
publicNetworkAccessForIngestion: 'Enabled'
publicNetworkAccessForQuery: 'Enabled'
}
}
resource appEnvironment 'Microsoft.App/managedEnvironments@2022-06-01-preview' = {
name: appEnvironmentName
location: location
sku: {
name: 'Consumption'
}
properties: {
vnetConfiguration: {
internal: true
infrastructureSubnetId: vnet.properties.subnets[0].id
dockerBridgeCidr: '10.2.0.1/16'
platformReservedCidr: '10.1.0.0/16'
platformReservedDnsIP: '10.1.0.2'
outboundSettings: {
outBoundType: 'LoadBalancer'
}
}
appLogsConfiguration: {
destination: 'log-analytics'
logAnalyticsConfiguration: {
customerId: wks.properties.customerId
sharedKey: listKeys(wks.id, wks.apiVersion).primarySharedKey
}
}
zoneRedundant: false
}
}
resource containerApp 'Microsoft.App/containerApps@2022-06-01-preview' = {
name: appName
location: location
identity: {
type: 'None'
}
properties: {
managedEnvironmentId: appEnvironment.id
configuration: {
activeRevisionsMode: 'Single'
ingress: {
external: true
targetPort: 80
exposedPort: 0
transport: 'Auto'
traffic: [
{
weight: 100
latestRevision: true
}
]
allowInsecure: false
}
}
template: {
containers: [
{
image: imageName
name: appName
resources: {
cpu: '0.25'
memory: '0.5Gi'
}
}
]
scale: {
maxReplicas: 10
}
}
}
}
param name string
param location string
param appEnvironmentResourceGroupName string
param loadBalancerName string
param subnetId string
resource loadBalancer 'Microsoft.Network/loadBalancers@2022-07-01' existing = {
name: loadBalancerName
scope: resourceGroup(appEnvironmentResourceGroupName)
}
resource privateLinkService 'Microsoft.Network/privateLinkServices@2022-07-01' = {
name: name
location: location
properties: {
autoApproval: {
subscriptions: [
subscription().subscriptionId
]
}
visibility: {
subscriptions: [
subscription().subscriptionId
]
}
fqdns: []
enableProxyProtocol: false
loadBalancerFrontendIpConfigurations: [
{
id: loadBalancer.properties.frontendIPConfigurations[0].id
}
]
ipConfigurations: [
{
name: 'ipconfig-0'
properties: {
privateIPAllocationMethod: 'Dynamic'
subnet: {
id: subnetId
}
primary: true
privateIPAddressVersion: 'IPv4'
}
}
]
}
}
output id string = privateLinkService.id
output name string = privateLinkService.name
resource frontDoor 'Microsoft.Cdn/profiles@2022-11-01-preview' = {
name: frontDoorName
location: 'Global'
sku: {
name: 'Premium_AzureFrontDoor'
}
properties: {
originResponseTimeoutSeconds: 30
extendedProperties: {
}
}
}
resource afdOriginGroup 'Microsoft.Cdn/profiles/origingroups@2022-11-01-preview' = {
parent: frontDoor
name: originGroupName
properties: {
loadBalancingSettings: {
sampleSize: 4
successfulSamplesRequired: 3
additionalLatencyInMilliseconds: 50
}
healthProbeSettings: {
probePath: '/'
probeRequestType: 'GET'
probeProtocol: 'Https'
probeIntervalInSeconds: 60
}
sessionAffinityState: 'Disabled'
}
}
resource afdEndpoint 'Microsoft.Cdn/profiles/afdendpoints@2022-11-01-preview' = {
parent: frontDoor
name: afdEndpointName
location: 'Global'
properties: {
autoGeneratedDomainNameLabelScope: 'TenantReuse'
enabledState: 'Enabled'
}
}
resource afdRoute 'Microsoft.Cdn/profiles/afdendpoints/routes@2022-11-01-preview' = {
parent: afdEndpoint
name: 'route'
properties: {
customDomains: []
originGroup: {
id: afdOriginGroup.id
}
originPath: '/'
ruleSets: []
supportedProtocols: [
'Http'
'Https'
]
patternsToMatch: [
'/*'
]
forwardingProtocol: 'MatchRequest'
linkToDefaultDomain: 'Enabled'
httpsRedirect: 'Enabled'
enabledState: 'Enabled'
}
dependsOn: [
afdOrigin
]
}
resource afdOrigin 'Microsoft.Cdn/profiles/origingroups/origins@2022-11-01-preview' = {
parent: afdOriginGroup
name: originName
properties: {
hostName: containerApp.properties.configuration.ingress.fqdn
httpPort: 80
httpsPort: 443
originHostHeader: containerApp.properties.configuration.ingress.fqdn
priority: 1
weight: 1000
enabledState: 'Enabled'
sharedPrivateLinkResource: {
privateLink: {
id: privateLinkService.outputs.id
}
privateLinkLocation: location
status: 'Approved'
requestMessage: 'Please approve this request to allow Front Door to access the container app'
}
enforceCertificateNameCheck: true
}
}
resource wafPolicy 'Microsoft.Network/FrontDoorWebApplicationFirewallPolicies@2022-05-01' = {
name: wafPolicyName
location: 'Global'
sku: {
name: 'Premium_AzureFrontDoor'
}
properties: {
policySettings: {
enabledState: 'Enabled'
mode: 'Prevention'
requestBodyCheck: 'Enabled'
}
managedRules: {
managedRuleSets: [
{
ruleSetType: 'Microsoft_DefaultRuleSet'
ruleSetVersion: '1.1'
ruleGroupOverrides: []
exclusions: []
}
{
ruleSetType: 'Microsoft_BotManagerRuleSet'
ruleSetVersion: '1.0'
ruleGroupOverrides: []
exclusions: []
}
]
}
}
}
resource afdSecurityPolicy 'Microsoft.Cdn/profiles/securitypolicies@2022-11-01-preview' = {
parent: frontDoor
name: '${prefix}-default-security-policy'
properties: {
parameters: {
wafPolicy: {
id: wafPolicy.id
}
associations: [
{
domains: [
{
id: afdEndpoint.id
}
]
patternsToMatch: [
'/*'
]
}
]
type: 'WebApplicationFirewall'
}
}
}
One the template has deployed successfully, the 3 template output parameters are collected and used as input to the ''az network private-endpoint-connection approve" Az CLI command to approve the Private Endpoint connection to the Private Link Service, on line 25.
Once approved, you will be able to access the Azure Container app via a browser using the AFD endpoint URL saved in the AFD_FQDN environment variable.
$ echo https://$AFD_FQDN
https://frontdoor-afd-ep-rczv4qasdrrms-akcehrf2dncngxfa.z01.azurefd.net
You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.