8 minute read

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:

  1. Deploying Azure Static Web Apps
  2. Configuring Azure DNS
  3. Configuring Static Web Apps Custom Domains
  4. 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:

  1. Register the www subdomain as a CNAME pointing to the static web app.
  2. 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 include scope: <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.

  1. Create a custom domain that is your apex domain.
  2. Read that custom domain resource to get the domain validation key.
  3. Create a TXT record in the domain with that domain validation key.
  4. Create an alias record set in the domain to point to the static web app.
  5. 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:

module apexdomain 'modules/swa-apex-domain.bicep' = {
  name: 'apex-custom-domain-${resourceToken}'
  scope: rg
  params: {
    zoneName: dnszone.outputs.name
    staticWebAppName: swa.outputs.name
  }
}

output AZURE_LOCATION string = location
output SERVICE_URL string[] = [
  'https://${swa.outputs.defaultHostname}'
  'https://${wwwdomain.outputs.domainName}'
  'https://${apexdomain.outputs.domainName}'
]

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.

Further reading

Leave a comment