Recently I explored creating a BICEP file for an ASP.NET AppService to connect to SQL Server using Identity which can be used to deploy any number of distinct regions (dedicated data regions) in any of the Azure datacenters around the world. This alone would allow you to run eu.example.com and us.example.com, but would require the user to remember the right domain to use.
A better approach is to serve up the closest region when a user visits the main domain, i.e. app.example.com. But this poses challenges for some users that are closer to a specific region, but have their data hosted in a different region. Similarly you want to provide the speed advantage wherever possible of the closest region but still have the data isolated to the chosen region.
The solution to this in Azure is called Frontdoor. A combined geo load balancer and CDN solution. You can deploy the same app to different regions each with their own database and other resources and in front of it all (in a special “global” region) you deploy Frontdoor which acts as the starting point for your domains. Each appservice only retains it’s internal AppService domain and has no custom domains. Those are deployed and managed by Frontdoor directly.
You can download the complete BICEP and parameters file for this article here.
Our goal
Let’s take a look at the setup we want to achieve:
- app.example.com: The main app domain should use the closest available region.
- us.example.com, eu.example.com: Each region should have a region specific domain so that gets served only from that region.
- cdn.example.com: A special cdn domain should load from the closest available region but provide caching for static assets.
Brief note on separating app and cdn domains
Strictly speaking you can configure a single endpoint with the app domain to handle only /assets/ with compression and caching (therefore performing like a CDN) and all other routes would just be handed down to the respective AppService. This would mean you don’t need the cdn subdomain, but in my opinion separating the concerns to a dedicated domain makes this solution future proof, when you might want to host assets on the CDN domain without impacting the underlying AppService.
Creating Frontdoor with BICEP
We will now run through all the necessary steps to create a Frontdoor instance with endpoints, rules and custom domains using BICEP, Microsoft’s deployment language for Azure.
The important parts of the BICEP file are shown and explained below but you can download the full example (including the region specific BICEP files) here.
1. Define parameters
First we will create the required parameters. Part of them follow the naming scheme introduced by the regions BICEP. But we now add the name of each appservice we are targeting and the subdomains to setup.
@description('Prefix used for grouping resources (2-10 characters), e.g. project or product short code')
param prefix string
@description('Environment name that determines the deployment context, e.g. prod, staging, dev')
@minLength(2)
@maxLength(10)
@allowed([
'prod'
'staging'
'dev'
])
param environment string
@description('Hostname for the US App Service')
@minLength(3)
param usAppServiceHostName string
@description('Hostname for the EU App Service')
@minLength(3)
param euAppServiceHostName string
@description('Global domain name, e.g. app.example.com')
@minLength(3)
param globalDomain string
@description('US domain name, e.g. us.example.com')
@minLength(3)
param usDomain string
@description('EU domain name, e.g. eu.example.com')
@minLength(3)
param euDomain string
@description('CDN domain name, e.g. cdn.example.com')
@minLength(3)
param cdnDomain string
2. Resource definition
First we define the Frontdoor instance with two endpoints. One for the main app domain which will also handle each region specific domain. A second one for the CDN. Note the location for these resources is ‘Global’ but they are still deployed to a resource group in a specific region.
resource frontDoor 'Microsoft.Cdn/profiles@2024-09-01' = {
name: '${prefix}-${environment}-global-frontdoor'
location: 'Global'
sku: {
name: 'Standard_AzureFrontDoor'
}
}
// Endpoints
resource mainEndpoint 'Microsoft.Cdn/profiles/afdEndpoints@2024-09-01' = {
parent: frontDoor
name: '${prefix}-${environment}-main-endpoint'
location: 'Global'
properties: {
enabledState: 'Enabled'
}
}
resource cdnEndpoint 'Microsoft.Cdn/profiles/afdEndpoints@2024-09-01' = {
parent: frontDoor
name: '${prefix}-${environment}-cdn-endpoint'
location: 'Global'
properties: {
enabledState: 'Enabled'
}
}
Next we take a look at origin groups and origins.
- Origin Group: A collection of backend servers (origins, in our example AppService but could be any server). Important only that they are interchangeable. Front Door load balances requests across all healthy origins within a group or based on rules as we will use for the regions later.
- Origin is a specific backend endpoint (like an App Service, Storage account, or any web server) that hosts the actual content. Each origin has its own hostname, HTTP/HTTPS port, and health probe settings.
For our setup we need three origin groups: one for all global request and one specific to each region.
Each origin group is fairly similar and uses default settings for load balancing and health probing.
// Origin Groups Example
resource globalOriginGroup 'Microsoft.Cdn/profiles/originGroups@2024-09-01' = {
parent: frontDoor
name: 'global-origin-group'
properties: {
loadBalancingSettings: {
sampleSize: 4
successfulSamplesRequired: 3
}
healthProbeSettings: {
probePath: '/'
probeRequestType: 'HEAD'
probeProtocol: 'Https'
probeIntervalInSeconds: 100
}
sessionAffinityState: 'Disabled'
}
}
When we define the origins, this requires a bit of repetition as origins are tied 1:1 to origin groups. You will be creating origins for each region in the global group but also for each individual origin group, but they all point to the same actual AppService resources.
// Origins
resource globalGroupUSOrigin 'Microsoft.Cdn/profiles/originGroups/origins@2024-09-01' = {
parent: globalOriginGroup
name: 'global-group-us-appservice'
properties: {
hostName: usAppServiceHostName
httpPort: 80
httpsPort: 443
originHostHeader: usAppServiceHostName
priority: 1
weight: 1000
enabledState: 'Enabled'
enforceCertificateNameCheck: true
}
}
resource globalGroupEUOrigin 'Microsoft.Cdn/profiles/originGroups/origins@2024-09-01' = {
parent: globalOriginGroup
name: 'global-group-eu-appservice'
properties: {
hostName: euAppServiceHostName
...
}
}
resource usGroupOrigin 'Microsoft.Cdn/profiles/originGroups/origins@2024-09-01' = {
parent: usOriginGroup
name: 'us-group-appservice'
properties: {
hostName: usAppServiceHostName
...
}
}
resource euGroupOrigin 'Microsoft.Cdn/profiles/originGroups/origins@2024-09-01' = {
parent: euOriginGroup
name: 'eu-group-appservice'
properties: {
hostName: euAppServiceHostName
...
}
}
Main Route
Next we setup the route. The main route will serve both the app.example.com subdomain and the region specific domains using the global origin group (which contains origins for both regional AppServices).
We reference a rule set we will define next that routes the region specific custom domains to the specific origins for that region (using overrides).
resource mainRouteConfig 'Microsoft.Cdn/profiles/afdEndpoints/routes@2024-09-01' = {
parent: mainEndpoint
name: 'main-route'
properties: {
originGroup: {
id: globalOriginGroup.id
}
supportedProtocols: [
'Http'
'Https'
]
patternsToMatch: [
'/*'
]
forwardingProtocol: 'HttpsOnly'
linkToDefaultDomain: 'Enabled'
httpsRedirect: 'Enabled'
enabledState: 'Enabled'
customDomains: [
{ id: globalCustomDomain.id }
{ id: euCustomDomain.id }
{ id: usCustomDomain.id }
]
ruleSets: [
{
id: routingRuleSet.id
}
]
}
dependsOn: [globalGroupEUOrigin, globalGroupUSOrigin]
}
The rule set can contain multiple rules. We will setup one rule set with two rules, one for each region.
The example shows the setup for the US domain. If the hostname matches the US custom domain, then the origin group is overridden with the US specific origin group.
The EU (not shown) will do the same and any traffic that does not match these rules will be served by the default (global) origin group.
resource routingRuleSet 'Microsoft.Cdn/profiles/ruleSets@2024-09-01' = {
parent: frontDoor
name: 'regionRoutingRules'
dependsOn: [globalGroupEUOrigin, globalGroupUSOrigin]
}
resource usRoutingRule 'Microsoft.Cdn/profiles/ruleSets/rules@2024-09-01' = {
parent: routingRuleSet
name: 'usRouting'
properties: {
order: 1
conditions: [
{
name: 'HostName'
parameters: {
typeName: 'DeliveryRuleHostNameConditionParameters'
operator: 'Equal'
negateCondition: false
matchValues: [
usDomain
]
transforms: [
'Lowercase'
]
}
}
]
actions: [
{
name: 'RouteConfigurationOverride'
parameters: {
typeName: 'DeliveryRuleRouteConfigurationOverrideActionParameters'
originGroupOverride: {
forwardingProtocol: 'HttpsOnly'
originGroup: {
id: usOriginGroup.id
}
}
}
}
]
}
dependsOn: [globalGroupUSOrigin]
}
CDN Route
The CDN route uses only the CDN custom domain but sets up caching and compression.
Here we reference a rule to setup CORS headers which we will define next.
resource cdnRouteConfig 'Microsoft.Cdn/profiles/afdEndpoints/routes@2024-09-01' = {
parent: cdnEndpoint
name: 'cdn-route'
properties: {
originGroup: {
id: globalOriginGroup.id
}
supportedProtocols: [
'Http'
'Https'
]
patternsToMatch: [
'/*'
]
forwardingProtocol: 'HttpsOnly'
linkToDefaultDomain: 'Enabled'
httpsRedirect: 'Enabled'
enabledState: 'Enabled'
cacheConfiguration: {
queryStringCachingBehavior: 'UseQueryString'
compressionSettings: {
isCompressionEnabled: true
contentTypesToCompress: [
'text/html'
'text/css'
'text/plain'
'text/xml'
'text/javascript'
'application/javascript'
'application/x-javascript'
'application/json'
'application/xml'
'application/xhtml+xml'
'application/rss+xml'
'application/atom+xml'
'application/x-font-ttf'
'application/x-font-opentype'
'application/vnd.ms-fontobject'
'image/svg+xml'
'image/x-icon'
'font/ttf'
'font/otf'
'font/eot'
'font/woff'
'font/woff2'
'application/manifest+json'
'application/vnd.api+json'
'text/x-component'
'application/x-font-truetype'
'application/x-font-woff'
'application/x-font-woff2'
'application/x-font-otf'
'application/x-font-eot'
'application/x-font-ttc'
]
}
}
customDomains: [
{ id: cdnCustomDomain.id }
]
ruleSets: [
{
id: corsRuleSet.id
}
]
}
dependsOn: [globalGroupEUOrigin, globalGroupUSOrigin]
}
The CORS rule set will contain a rule for each valid origin (i.e. app.example.com, us.example.com and eu.example.com) and set the appropriate Access-Control-Allow-Origin
header values.
resource corsRuleSet 'Microsoft.Cdn/profiles/ruleSets@2024-09-01' = {
parent: frontDoor
name: 'allowCorsRules'
dependsOn: [globalGroupEUOrigin, globalGroupUSOrigin]
}
resource allowCorsRuleGlobal 'Microsoft.Cdn/profiles/rulesets/rules@2024-09-01' = {
parent: corsRuleSet
name: 'allowCorsGlobal'
properties: {
order: 100
conditions: [
{
name: 'RequestHeader'
parameters: {
typeName: 'DeliveryRuleRequestHeaderConditionParameters'
operator: 'Equal'
selector: 'Origin'
negateCondition: false
matchValues: [
'https://${globalDomain}'
]
transforms: [
'Lowercase'
]
}
}
]
actions: [
{
name: 'ModifyResponseHeader'
parameters: {
typeName: 'DeliveryRuleHeaderActionParameters'
headerAction: 'Overwrite'
headerName: 'Access-Control-Allow-Origin'
value: 'https://${globalDomain}'
}
}
]
matchProcessingBehavior: 'Continue'
}
dependsOn: [
globalGroupEUOrigin
globalGroupUSOrigin
]
}
resource allowCorsRuleEU 'Microsoft.Cdn/profiles/rulesets/rules@2024-09-01' = {
parent: corsRuleSet
name: 'allowCorsEu'
...
}
resource allowCorsRuleUS 'Microsoft.Cdn/profiles/rulesets/rules@2024-09-01' = {
parent: corsRuleSet
name: 'allowCorsUs'
...
}
Custom Domains
Last we have to define the custom domains we are going to use, which are referenced in the different resources.
// Custom Domain Example
resource globalCustomDomain 'Microsoft.Cdn/profiles/customDomains@2024-09-01' = {
parent: frontDoor
name: 'global-domain'
properties: {
hostName: globalDomain
tlsSettings: {
certificateType: 'ManagedCertificate'
minimumTlsVersion: 'TLS12'
}
}
}
3. Parameters file
To run this script we need to create a parameters file called frontdoor-parameters.json
and fill it with the values from our AppService setup and the domains we want to use.
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"prefix": {
"value": "demo"
},
"environment": {
"value": "prod"
},
"usDomain": {
"value": "us.example.com"
},
"euDomain": {
"value": "eu.example.com"
},
"globalDomain": {
"value": "app.example.com"
},
"cdnDomain": {
"value": "cdn.example.com"
},
"usAppServiceHostName": {
"value": "demo-prod-us-appservice-web1.azurewebsites.net"
},
"euAppServiceHostName": {
"value": "demo-prod-eu-appservice-web1.azurewebsites.net"
}
}
}
4. Running the BICEP file with parameters
And now we can run the file with this az command:
az deployment group create --resource-group <RG> --template-file frontdoor.bicep --parameters frontdoor-parameters.json
5. Add DNS records for custom domains
Once the deployment has succeeded head to the Frontdoor Manager in Azure Portal to the custom domains section. There you will find the verification values you will need to create for each domain.
Each domain must have two records:
- CNAME pointing to the endpoint route.
- TXT record with the verification values.
This can take a bit to propagate but once done, Frontdoor will start serving your domains with managed SSL certificates.
Bonus: Configure Solution to use CDN
If you are using the default “ASP.NET with React Frontend” template in Visual Studio the published AppService will serve the assets by default from the /assets/
directory. In order to tell VITE to point to the CDN first, change the vite.config.ts file and modify the base path if the build command is used:
export default defineConfig(({ command }) => ({
base: command === 'build' ? 'https://cdn.example.com/' : '/', // [!code highlight]
plugins: [plugin()],
resolve: {
...
},
server: {
...
}
}))
When the solution is built, the npm run build
command will output the assets and in the index.html file the CDN will be referenced. The assets will still be published to the AppService so the CDN can then access them.