Build a Blog: Custom domains for Azure Static Web Apps
In my last post, I registered a DNS domain name and set up Azure DNS for it. I want my blog to use that domain name, so I need to set up a custom domain using Azure DNS. Of course, nothing is that simple. My blog uses repeatable deployments via Azure Developer CLI, so there is a little bit of extra work to do.
This post is part of a sequence showing how to deploy a blog on Azure Static Web Apps:
- Deploying Azure Static Web Apps
- Configuring Azure DNS
- Configuring Static Web Apps Custom Domains
- Taking Static Web Apps to Production
If you are creating a custom domain as a one-off thing and not using repeatable deployments, then feel free to follow the instructions in the documentation. It will walk through doing this same thing in the Azure portal. A lot of the work is done for you.
Configure the www domain
Let’s start with configuring a subdomain. I want www.apps-on-azure.net
to point to the static web app. This is a two part process that is done AFTER both the domain and the static web app are deployed:
- Register the
www
subdomain as a CNAME pointing to the static web app. - Create the
www.apps-on-azure.net
custom domain on the static web app.
I like to do these things in a bicep module. It, for instance, allows me to easily add blog.apps-on-azure.net
later on using exactly the same code. Modularization is great!
Let’s take a look at the addition to the `main.bicep file:
1
2
3
4
5
6
7
8
9
module wwwdomain 'modules/swa-custom-subdomain.bicep' = {
name: 'www-custom-domain-${resourceToken}'
scope: rg
params: {
name: 'www'
zoneName: dnszone.outputs.name
staticWebAppName: swa.outputs.name
}
}
This is how you call any bicep module. I’ve already used ready-made modules from the Azure Verified Modules collection. This one will be my own module. Note the use of .outputs.name
here. Each module can specify a set of outputs and these outputs can be used to define other resources or modules. By putting the outputs references in this module definition, I ensure the deployments are sequenced properly so that the custom domain comes after both the DNS zone and the static web app is deployed.
Let’s take a look at the module code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
targetScope = 'resourceGroup'
@description('The name of the subdomain')
param name string
@description('The name of the static web app service.')
param staticWebAppName string
@description('The name of the Azure DNS hosted DNS zone')
param zoneName string
resource dnsZone 'Microsoft.Network/dnsZones@2018-05-01' existing = {
name: zoneName
}
resource staticSite 'Microsoft.Web/staticSites@2023-12-01' existing = {
name: staticWebAppName
}
resource cnameRecord 'Microsoft.Network/dnsZones/CNAME@2018-05-01' = {
name: name
parent: dnsZone
properties: {
TTL: 3600
CNAMERecord: {
cname: staticSite.properties.defaultHostname
}
}
}
resource customDomain 'Microsoft.Web/staticSites/customDomains@2023-01-01' = {
name: '${name}.${zoneName}'
parent: staticSite
properties: {
validationMethod: 'cname-delegation'
}
}
output domainName string = cnameRecord.properties.fqdn
Let’s walk through this:
- Line 1 says this is resource group scoped. In the
main.bicep
, I must includescope: <some-resource-group>
when I define the parameters for the module. - Lines 3-10 define the properties I can pass to the module. I use
@description()
so I know what each parameter is for. This feeds Intellisense when using Visual Studio Code to write bicep. - Lines 12-18 define the existing resources - the DNS zone and the static web app. This will ensure that they exist and allow me to access any properties of those resources later on.
- Lines 20-29 create a CNAME record in my DNS zone. This is required to create a custom domain based on a subdomain.
- Lines 31-37 create a custom domain in my static web app that points to the subdomain. It expects the subdomain to be defined via a CNAME.
The last check may fail because of timing. DNS is wierd in that it needs time to propagate to other DNS servers. This is especially true if the DNS server that the static web app uses has already cached the domain you are working on.
If it fails, then just retry the check within the Azure portal later on. Your infrastructure as code will still work in a completely new scenario, and subsequent deployments will work since the configuration of the custom domain is the same as what is defined.
Configure the apex domain
The apps-on-azure.net
domain name is called the “apex” or “root” domain. I kind of like the term “apex domain”, since the root domain (at least to me) is the gTLD - in this case “net”.
Configuring the subdomain is easy - create a CNAME in DNS, then add the custom domain to the static web app resource. Configuring an apex domain is a little more tricky. You have to add a TXT record to the domain so that you can validate domain ownership, then add an ALIAS or CNAME record set to the apex domain. That’s from the documentation, but there are many questions here. What do you put in the TXT record? What is an ALIAS record? How do you actually do this?
It turns out it is relatively easy, but it takes multiple steps.
- Create a custom domain that is your apex domain.
- Read that custom domain resource to get the domain validation key.
- Create a TXT record in the domain with that domain validation key.
- Create an alias record set in the domain to point to the static web app.
- Wait for validation to happen.
There are a couple of gotchas here that are easily overcome:
- In step 2, the custom domain resource does not product the domain validation key if the domain is already validated. This means you don’t always have the information to create the TXT record for validation.
- In step 4, you have to use a preview API to create the appropriate record.
Now I know what I’m doing, I can create this in bicep. First off, I wrote a module specifically for creating the domain validation record. I’m doing this as a separate module because I want the process to be optional. You can’t put a conditional in ARM where the condition depends on the output from another resource, but you can put a conditional in when it depends on a parameter or variable.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
targetScope = 'resourceGroup'
@description('The validation token')
param validationToken string?
@description('The name of the Azure DNS hosted DNS zone')
param zoneName string
resource dnsZone 'Microsoft.Network/dnsZones@2018-05-01' existing = {
name: zoneName
}
resource domainValidationRecord 'Microsoft.Network/dnsZones/TXT@2023-07-01-preview' = if (validationToken != null) {
name: '@'
parent: dnsZone
properties: {
TTL: 60
TXTRecords: [
{
value: [ validationToken ?? 'not-provided' ]
}
]
}
}
This module is fairly straight forward. The validation token is nullable, so I use that as the discovery mechanism to decide if I should create the TXT record or not. I do have to deal with the potentially nullable value in the value record, but that’s never going to exist because I check for it.
Now let’s look at the swa-apex-domain.bicep
module that does all the hard work:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
targetScope = 'resourceGroup'
@description('The name of the static web app service.')
param staticWebAppName string
@description('The name of the Azure DNS hosted DNS zone')
param zoneName string
resource dnsZone 'Microsoft.Network/dnsZones@2018-05-01' existing = {
name: zoneName
}
resource staticSite 'Microsoft.Web/staticSites@2023-12-01' existing = {
name: staticWebAppName
}
resource apexDomain 'Microsoft.Web/staticSites/customDomains@2023-12-01' = {
name: zoneName
parent: staticSite
properties: {
validationMethod: 'dns-txt-token'
}
}
resource apexDnsRecord 'Microsoft.Network/dnsZones/A@2023-07-01-preview' = {
name: '@'
parent: dnsZone
properties: {
TTL: 3600
targetResource: {
id: staticSite.id
}
}
}
module domainValidationRecord './domain-validation-record.bicep' = {
name: 'domain-validation-${uniqueString(zoneName, staticWebAppName, resourceGroup().location)}'
params: {
zoneName: dnsZone.name
validationToken: apexDomain.properties.?validationToken
}
}
output domainName string = dnsZone.name
There is a lot going on here, so let’s take it section by section:
- Lines 1-7 should be fairly familiar by now. I’m expecting to pass in the name of the DNS zone resource and the name of the static web app resource.
- Lines 9-15 create references to the pre-existing resources that I need to do the rest of the work.
- Lines 17-23 create the custom domain for the apex domain. Unlike a subdomain, I create the custom domain first so I have access to the validation token. Also, I’m using the “dns-txt-token” method instead of the “cname-delegation” method. You can’t put a CNAME in the root of an apex domain. This is a restriction in RFC 1912. Thus, an alternative method is required.
- Lines 25-34 creates an A record that is linked to the static site. When the IP address of the static site changes, this record will also change automatically.
- Lines 36-42 calls the domain validation bicep module to optionally install the TXT record if (and only if) the static site needs it.
Finally, I add a module reference to the end of my main.bicep
:
Note that I’m using the outputs of earlier modules to call this module. This allows me to sequence the resource provisioning correctly and parallelize the operations where possible. I’m also outputting all the URLs that you can use to access the service so that they can be used later if needed.
Final thoughts
One of the things I’ve really enjoyed is repeatable deployments, and this is extended here. There is a dance between the static web app and the DNS zone that has to happen to configure a custom domain. By producing a bicep module for this, I can easily add custom domains in a repeatable manner to any site.
I’ve made the code for my blog public, so you can see the actual code I use for generating the resource for this blog. Feel free to reuse it if you find value in it.
For the next post, I’m going to go over some other things I think I need to do before going public with a production blog.
Leave a comment