Run a script during deployment with DeploymentScripts in Bicep

On a recent project, I was using Terraform to build some infrastructure. It contained an Azure Web App with a custom domain configured. A custom domain name on a Web App allows you to access it using a friendly URL instead of the <your_name>.azurewebsites.net. In this project, the DNS records for the domain were hosted and managed on Cloudflare. Luckily, Terraform has a provider for both Azure and Terraform, and thus I could write a single module that would create the Web App, set the domain in Cloudflare, and configure the custom domain. You can find an example of how to do that with Terraform here. Doing this in Terraform is relatively easy as Terraform has providers for both systems. Terraform is what we call a multi-cloud tool. Bicep, on the other hand, is not. It can only manage infrastructure on Azure. More specifically, you can only interact with the Azure control plane. Azure operations can be divided into two categories - the control plane and the data plane. Simply put, you use the control plane to manage resources in your subscription, and you use the data plane to manage the internals of a resource. For example, Bicep allows you to create a SQL Database but does not let you create a user in that database. Bicep also does not let you interact with Active Directory. There are mainly two alternative approaches here; run some code in your CICD pipeline and feed the result to Bicep on deploy or use DeploymentScripts resource. DeploymentScripts resource allows you to run an Azure CLI or PowerShell script during the execution of Bicep. This blog post will show you how to use it to configure a custom domain on a Web App.

Create a Web App

First, we will create a Web App. We will use the following Bicep code for that:

resource appServicePlan 'Microsoft.Web/serverfarms@2019-08-01' = {
  name: 'asp-${name}'
  location: location
  sku: {
    name: 'B1'
    capacity: 1
  }
}

resource webApplication 'Microsoft.Web/sites@2018-11-01' = {
  name: 'app-${name}'
  location: location
  properties: {
    serverFarmId: appServicePlan.id

    siteConfig: {
      netFrameworkVersion: 'v6.0'
    }
  }
}

The above code will create a Web App with a name like app-<your_name>.azurewebsites.net. We will use this name later on to configure the custom domain.

Configure the domain records in Cloudflare

As mentioned before, Bicep cannot directly interact with Cloudflare. We will use a DeploymentScript to do that. The DeploymentScript will run a PowerShell script that, in turn, will use the Cloudflare API to create the DNS records. What happens under the hood when you use a DeploymentScripts resource is that an Azure Container Instance will be created, and a container will be deployed to run your script. The code for the PowerShell script is shown below:

param([string] $hostname, [string] $domain, [string] $destination)

$zoneid = "72e0e6d795ec809b9158033c4a4c73d3"
$url = "https://api.cloudflare.com/client/v4/zones/$zoneid/dns_records"

$addresses = (
    ("awverify.$hostname.$domain", "awverify.$destination"),
    ("$hostname.$domain", "$destination")
)

foreach($address in $addresses)
{
    $name = $address[0]
    $content = $address[1]
    $token = $Env:CLOUDFLARE_API_TOKEN

    $existingRecord = Invoke-RestMethod -Method get -Uri "$url/?name=$name" -Headers @{
        "Authorization" = "Bearer $token"
    }

    if($existingRecord.result.Count -eq 0)
    {
        $Body = @{
            "type" = "CNAME"
            "name" = $name
            "content" = $content
            "ttl" = "120"
        }
        
        $Body = $Body | ConvertTo-Json -Depth 10
        $result = Invoke-RestMethod -Method Post -Uri $url -Headers @{ "Authorization" = "Bearer $token" } -Body $Body -ContentType "application/json"
        
        Write-Output $result.result
    }
    else 
    {
        Write-Output "Record already exists."
    }
}

Note how this script creates two DNS records. The first one is used to verify that you own the domain. The second one is the actual custom domain record. The script also checks if the record already exists. If it does, it will not create it again. This is important as the script will be run every time you deploy your infrastructure. The script also uses an environment variable to get the Cloudflare API token. This is a good practice as it allows you to store the token securely. The Bicep code for the DeploymentScript is shown below:

resource cloudflare 'Microsoft.Resources/deploymentScripts@2020-10-01' = {
  name: 'cloudflare'
  location: location
  kind: 'AzurePowerShell'
  properties: {
    forceUpdateTag: '1'
    azPowerShellVersion: '8.3'
    arguments: '-hostname "${record}" -domain "${domain}" -destination "${webApplication.properties.defaultHostName}"'
    environmentVariables: [
      {
        name: 'CLOUDFLARE_API_TOKEN'
        secureValue: cloudFlareToken
      }
    ]
    scriptContent: '''
      param([string] $hostname, [string] $domain, [string] $destination)

      $zoneid = "72e0e6d795ec809b9158033c4a4c73d3"
      $url = "https://api.cloudflare.com/client/v4/zones/$zoneid/dns_records"
      
      $addresses = (
          ("awverify.$hostname.$domain", "awverify.$destination"),
          ("$hostname.$domain", "$destination")
      )
      
      foreach($address in $addresses)
      {
          $name = $address[0]
          $content = $address[1]
          $token = $Env:CLOUDFLARE_API_TOKEN
      
          $existingRecord = Invoke-RestMethod -Method get -Uri "$url/?name=$name" -Headers @{
              "Authorization" = "Bearer $token"
          }
      
          if($existingRecord.result.Count -eq 0)
          {
              $Body = @{
                  "type" = "CNAME"
                  "name" = $name
                  "content" = $content
                  "ttl" = "120"
              }
              
              $Body = $Body | ConvertTo-Json -Depth 10
              $result = Invoke-RestMethod -Method Post -Uri $url -Headers @{ "Authorization" = "Bearer $token" } -Body $Body -ContentType "application/json"
              
              Write-Output $result.result
          }
          else 
          {
              Write-Output "Record already exists"
          }
      }    
    '''
    supportingScriptUris: []
    timeout: 'PT30M'
    cleanupPreference: 'OnSuccess'
    retentionInterval: 'P1D'
  }
}

The above code will create a DeploymentScript resource running the PowerShell script. Notice how we set the Cloudflare API token as an environment variable and mark that as a secret value instead of passing it in as a standard parameter. This will ensure that it will not show up in the deployment logs.

Configure the custom domain

Now that we have created the DNS records, we can configure the custom domain in the Web App. The Bicep code for that is shown below:

resource hostName 'Microsoft.Web/sites/hostNameBindings@2022-03-01' = {
  name: '${record}.${domain}'
  parent: webApplication

  dependsOn: [
    cloudflare
  ]
}

The above code will create a hostname binding for the custom domain. Notice how we have added a dependency on the DeploymentScript resource. This will ensure that the DNS records are created before the hostname binding is created.