Chris Meagher

Automating Azure infrastructure deployments using Bicep and Azure pipelines

I previously wrote about using Azure Pipelines to build and deploy a Next.js app to Azure app services and when I was putting together the sample application for that demo I wanted to be able to demonstrate automating the deployment of the app to multiple environments via the pipeline. I had put most of the work into it and had it working in principle, but while the pipeline automated the deployment of the application to the target environment, the setting up of the resources in Azure for each of the environments was a manual task. It was a little tedious to do via the Azure portal, but not difficult, and not too time consuming.

Initially I was OK with that as this was just a sample app at the end of the day, but I changed my mind when I was writing up the instructions in the docs for how to do all this manual infrastructure setup in Azure. I thought that if people were to find and want to use my sample app as a reference for their own projects that they might be put off by the sheer volume of words in the documentation! It was getting really tiresome for me to write so I didn't expect anyone would want to read it either!

It seemed like the best thing to do would be to automate the infrastructure setup and configuration, but I was unsure of what approach to take and I didn't want to invest much more time in the project at that point to figure it out.

So I updated the documentation and simplified the pipeline a little to focus on targeting a single environment instead and added a section at the bottom of the docs titled "Why not automate or script the setup?" to explain in case anyone came across my work and had that question themselves!

I was happy to put a pin in it at that point and come back to it later - I had left the groundwork in the pipeline so it could be added back in, but it would have to be an exercise for the reader.

Why Bicep?

There are many options for scripting infrastructure, and the two I had prior experience with didn't seem appealing to use for my "Next on Azure" sample app as they felt too cumbersome (for different reasons):

  • Pulumi - I think Pulumi is fantastic, but would add complexity to a sample app that I wasn't comfortable with as I can't assume people would know how or even want to use the tool

  • Azure Resource Manager (ARM) templates - these would be less complex in terms of tooling, but more complex to author, document and maintain

Of those two options ARM templates felt like the better fit for the sample app, but I just was not enthused about the prospect of writing them!

Then a month or so after I had stopped working on the sample app I start reading about Bicep, which is a domain-specific language (DSL) designed and maintained by Microsoft where the domain is to simplify the authoring of Azure Resource Manager (ARM) templates.

This sounded like a good fit for my sample app as it would give me the benefits of using ARM templates to script my infrastructure, but with an improved authoring experience. Win-win!

So I found some time to give Bicep a spin to see if it could help me:

  • Script and automate the creation of the required Azure resources for my sample app

  • Integrate into the Azure Pipeline to automate creation of and allow me to target multiple environments

  • Simplify and reduce the amount of steps required to get the sample Next.js app deployed into Azure (and reduce the size of the docs in the process!)

I am happy to say that Bicep allowed me to achieve all three of those goals!

Getting started with Bicep

The first thing I needed to do was to set up my development environment so that I could write and test my Bicep scripts:

  • There is a Visual Studio Code Bicep extension that provides language support, auto-completion and validation - I am already using Code so this was a no-brainer to install and use

  • Choose either Azure PowerShell or Azure CLI to run and test my Bicep scripts - I am also already using Azure CLI so I went with that

I am a Windows user, but all these tools are cross platform. Bicep only targets Azure though so that is worth bearing in mind when comparing to other infrastructure as code tools (such as Pulumi) that can target multiple cloud service providers.

Then it was a case of starting to get familiar with the Bicep language and its capabilities, which I did by reading through the tutorial. This exposed me to the core concepts of Bicep and the main building blocks that make up a Bicep script:

  • Parameters - allow for input into the script as arguments

  • Variables - constant values or values computed from parameters, resources, or other variables

  • Resources - describe the resources and their associated state that you want to create or update in Azure

  • Outputs - any Bicep script (and therefore Module - see below) can define zero or more outputs, which allow you to receive information back from your running script such as information related to the resources being created or updated

  • Modules - any Bicep script can also be a Module, which means that you can encapsulate any of the above primitives together that describe a single unit of deployment so that it is easier to reason about and compose a set of Bicep scripts that sum together to describe all of the resources required by your application

There are other concepts described in the tutorial as well such as expressions, conditions, and the conversion of existing ARM templates that were useful to know about and overall I have found the documentation for Bicep to be really good. The full Bicep spec has also been very useful to clarify anything that wasn't covered in full detail in the tutorial.

After reading through the tutorial I was feeling really pleased with how much more succinct and clear it all is compared to directly authoring ARM templates, and I felt like I knew enough to start planning out and writing the Bicep scripts that would describe the resources for my sample app.

Writing the Bicep scripts

The resources required for the sample app are:

  • An App Service (and App Service Plan) for the Next.js website

  • Application Insights for monitoring the App Service

  • A CDN (Profile and Endpoint) for delivery of the website's static assets

The plan was to write a Bicep script to describe each of these three sets of resources so that I could consume each of them as a module from a "main" Bicep script. The "main" script could be called from the pipeline to create or update my sample app's resources in Azure, and I could pass input as params to and receive outputs back from the "main" script for any variables related to the environment that the pipeline is targeting.

Now that I had a plan I thought it would be a good idea to find some good examples that I could learn from to get me started with describing these resources. The examples in the tutorial are mostly based around creating and updating a blob storage account and don't cover any of the above, but it did link to some further examples. These examples are a "growing set of examples of fully converted Azure QuickStart templates" and while they didn't have an example that described what I needed exactly, between them and what I had learned in the tutorial was enough to get me started.

I found the organisation of the examples very odd, but if you dig through there is some good stuff in there and it yet another good Bicep resource!

Another resource that was invaluable to me when writing the scripts was the ARM templates reference. When you are describing a Bicep resource the properties definitions use the exact same definition as the corresponding definition in an ARM template. So if you need to look up the API version, names, types etc of properties for a Bicep resource then this reference guide is very helpful.

An App Service (and App Service Plan) for the website

The App Service Plan is really simple to define:

resource appServicePlan 'Microsoft.Web/serverfarms@2020-12-01' = {
  name: appServicePlanName
  location: location
  sku: {
    name: skuName
    capacity: skuCapacity
  }
}

appServicePlanName, location, skuName and skuCapacity are all param definitions in the script, and it is the responsibility of the "main" Bicep script to provide those when calling this module.

The App Service definition is not much more complex (in my case anyway - there are a lot of properties available for an App Service resource if you need them!):

resource appService 'Microsoft.Web/sites@2020-12-01' = {
  name: appServiceName
  location: location
  identity: {
    type: 'SystemAssigned'
  }
  tags: {
    'hidden-related:${appServicePlan.id}': 'empty'
    displayName: 'Website'
  }
  properties: {
    serverFarmId: appServicePlan.id
    httpsOnly: true
    siteConfig: {
      http20Enabled: true
      minTlsVersion: '1.2'
      nodeVersion: nodeVersion
    }
  }
}

I am using (and reusing) more param definitions here as well as referencing the appServicePlan resource to access its id property, which of course I don't want to hardcode into this script (nor would I be able to as it doesn't exist at this point!).

At this point I ran into my first snag however as there were two issues I had to overcome with regards to application settings:

  1. My instructions for setting up the App Service manually requires setting some of the application settings as "Deployment slot" (sticky) settings, but the appSettings property is defined as a NameValuePair type and has no such option

  2. I have three application settings whose values must be set using properties of other resources that I would be defining in my Bicep script - one of which is the BASE_URL, which needs to be set to the URL of the App Service itself

Issue 1 didn't seem to be covered in any examples that I could find or appear in the documentation resources I was using, but after a bit of searching around I did find a forum post where somebody was trying to do the same thing with ARM templates, and that led to a better explanation here (from 2015!).

Bicep scripts "compile" down to ARM templates so it was enough to figure out what I needed to do from there:

resource appService 'Microsoft.Web/sites@2020-12-01' = {
  // name, location etc as before, but removed here for brevity
  properties: {
    // Some properties removed here also for brevity
    siteConfig: {
      // Define the "sticky" application settings here (some values we don't know yet so we will just default to empty string
      appSettings: [
        {
          name: 'APP_ENV'
          value: 'production'
        }
        {
          name: 'BASE_URL'
          value: ''
        }
        {
          name: 'NEXT_PUBLIC_APPINSIGHTS_INSTRUMENTATIONKEY'
          value: ''
        }
        {
          name: 'NEXT_PUBLIC_CDN_URL'
          value: ''
        }
      ]
    }
  }

  // Define a config child resource and describe the settings that we wish to be "sticky" - note that the `name` HAS to be "slotConfigNames"
  resource webAppSlotSettings 'config' = {
    name: 'slotConfigNames'
    properties: {
      appSettingNames: [
        'APP_ENV'
        'BASE_URL'
        'NEXT_PUBLIC_APPINSIGHTS_INSTRUMENTATIONKEY'
        'NEXT_PUBLIC_CDN_URL'
      ]
    }
  }
}

Issue 2 is a chicken and egg situation because I can't create the App Service and set the BASE_URL application setting value to the App Service's main URL as part of the same Bicep resource definition. One way to get around this would be to pass the URL through as a constant value (for example as a param from the pipeline) but I didn't want to do that - I wanted to be able to get the App Service's URL from the Bicep resource properties during deployment.

This was easily solved by splitting the definition of the App Service's settings out from the App Service itself, which can be achieved by defining a separate Bicep resource for the App Service settings, which in turn can reference the App Service resource after it has been created / updated during the deployment.

I had other application settings that required references to the CDN and Application Insights resource definitions so I ended up moving the application settings resource into the "main" Bicep file, which I will cover in a later section.

The last thing to do was to define some outputs for the "main" script to use (for example, to set the BASE_URL setting mentioned above):

// Where `appService` is the "symbolic name" of the App Service resource defined earlier in the script
output appServiceId string = appService.id
output appServiceHostname string = appService.properties.defaultHostName

I now had a Bicep script for the App Service (and plan) that could be consumed as a module.

Application Insights for monitoring the App Service

The Application Insights resource is also pretty simple to define:

resource appInsights 'Microsoft.Insights/components@2020-02-02-preview' = {
  name: resourceName
  location: location
  tags: {
    'hidden-link:${appServiceId}': 'Resource'
    displayName: 'AppInsightsComponent'
  }
  kind: 'web'
  properties: {
    Application_Type: 'web'
  }
}

The only real thing of note here is the hidden-link tag, which must be in place to connect Application Insights with the App Service. I output the App Service ID from the App Service Bicep module I created earlier so it can be passed into the Application Insights module as a param.

I cannot for the life of me find any documentation on these "hidden" tags in ARM templates - they seem to be undocumented magic strings that you can only discover via existing examples or by exporting the ARM templates from the Azure portal.

The Bicep script for the Application Insights module was now complete.

A CDN (Profile and Endpoint) for delivery of the website's static assets

The CDN requires a profile and an endpoint resource to be created, and I used resource nesting to describe the endpoint as a dependency of the profile:

resource profile 'Microsoft.Cdn/profiles@2020-09-01' = {
  name: resourceName
  location: location
  sku: {
    name: 'Standard_Microsoft'
  }

  resource endpoint 'endpoints' = {
    name: resourceName
    location: location
    properties: {
      originHostHeader: originHostname
      isHttpAllowed: true
      isHttpsAllowed: true
      queryStringCachingBehavior: 'IgnoreQueryString'
      contentTypesToCompress: [
        'application/json'
        // Full list of content types omitted for brevity
      ]
      isCompressionEnabled: true
      optimizationType: 'GeneralWebDelivery'
      origins: [
        {
          name: replace(originHostname, '.', '-')
          properties: {
            hostName: originHostname
            priority: 1
          }
        }
      ]
    }
  }
}

There are a few ways to model resource dependencies in a Bicep script, but an explicit parent-child relationship made sense to me here.

The only other thing to point out here is the definition of the origin where again I am taking the output of the App Service hostname from the App Service module and passing it into this script as the originHostname parameter.

I now had a Bicep script for the CDN module and I could start composing my "main" Bicep script to describe the resources required for my application's infrastructure in full.

The "main" Bicep script

The "main" Bicep script is the entry point for the deployment. The script:

  • Receives input via parameters passed to the Azure CLI when the script is executed

  • Sets variables used in running the script or calling modules

  • Consumes the modules described above to create or update resources

  • Uses the outputs from the modules to set some application settings on the App Service

  • Returns some outputs that can be read back out using the Azure CLI

The script is not massive (currently ~90 lines of code), but a bit too big too include in full here. I do think Bicep scripts are pretty easy to understand so take a look on the link above if you're interested. I do want to call out a few things of interest and potential gotchas though.

App Service application settings

As mentioned earlier I deferred setting some application settings on the App Service to the "main" script because they need to be set using outputs from other resources.

This is achieved by defining application settings as their own Bicep resource like so:

resource webAppSettings 'Microsoft.Web/sites/config@2020-12-01' = if(!dryRun) {
  name: '${webAppName}/appsettings'
  properties: {
    APP_ENV: 'production'
    BASE_URL: baseUrl
    NEXT_COMPRESS: 'false'
    NEXT_PUBLIC_APPINSIGHTS_INSTRUMENTATIONKEY: webAppInsightsInstrumentationKey
    NEXT_PUBLIC_BUILD_ID: buildId
    NEXT_PUBLIC_CDN_URL: cdnEndpointUrl
    WEBSITE_NODE_DEFAULT_VERSION: nodeVersion
  }
}

There are two things to note here. The first is that like with the "sticky" settings we defined earlier in the App Service module, the name of the resource here is important - it has to be '${webAppName}/appsettings' where webAppName matches the name that is given to the App Service resource.

The second thing is that (currently at least) defining application settings as a resource will cause your script to fail if you run it as a what-if operation. I ended up adding a condition to the resource definition so I could pass in a dryRun parameter that would prevent this resource from being deployed if I wanted to use what-if. This is not ideal as you have to know about and remember to pass in this parameter, but I couldn't see a way of detecting within the script if it was running a what-if operation.

The what-if issue is not a problem with Bicep, but with ARM templates. It is a known issue so hopefully it will get fixed at some point.

Testing a Bicep script

If you are using Visual Studio Code and have installed the Bicep extension then your scripts will be validated as you are writing them, but a syntactically valid script doesn't necessarily mean an error free script at runtime (as I have experienced)!

As far as I could ascertain there are three options for testing your Bicep scripts:

  1. Bicep build

  2. What-if deployment

  3. Deployment

Bicep build

The Bicep build command essentially "compiles" your Bicep script to an equivalent ARM template. If this operation results in an error then there is no chance that your Bicep script will run!

If you are using the Azure CLI then your can use Bicep build like this:

az bicep build -f ./main.bicep

It is also a useful command if you are getting runtime errors when trying to deploy your Bicep script as you can inspect the ARM template that results from the deployment and use that to help trace the source of any error messages.

What-if deployment

As previously mentioned the Azure CLI includes a what-if operation that allows you to preview the changes that would happen if you were to deploy the ARM template that results from your Bicep script.

It can be used like this:

az deployment group what-if -f ./main.bicep -g my-resource-group

However you should bear in mind the issues mentioned previously with application settings if that is a part of your deployment and work around that. I'm not aware of any other resources that do not work with what-if though so this remains a useful command.

Deployment

If your Bicep script builds and you are happy with the the what-if results then you can run a test deployment from your development environment (assuming that you have a subscription and resource group to deploy into).

You can run a Bicep deployment like this:

az deployment group create -f ./main.bicep -g my-resource-group

Of course that will actually create or update the resources in Azure so you may want to be careful with regards to the type and number of resources and their paramaters such as SKUs / plans as even resources that you don't use can cost you money / credits.

Pulumi, for example, allows you to delete your "stack" with a single command, but there is no real equivalent with Bicep / ARM templates. However, as we are deploying resources to a single resource group the easiest option may be to just delete and recreate the resource group, which can be done via the Azure portal or with the Azure CLI.

Getting outputs from a Bicep script

Just as with ARM templates you can return information from your Bicep deployment by defining one or more output instructions from the Bicep script. For example, in the pipeline YAML for the sample app I needed to be able to get the CDN endpoint URL back out of the Bicep deployment as it is used in a public environment variable, which need to be set when the Next.js app is built.

The outputs can also be fetched using the Azure CLI:

az deployment group show -n main -g my-resource-group --query properties.outputs.cdnEndpointUrl.value

The name passed in the -n parameter is the name of the deployment, which if you haven't set explicitly will be set to the name of the script file that you deployed.

The --query is a JMESPath query string. This was the first time I had come across JMESPath and whilst it looks quite powerful I was glad I didn't need to do anything more complex with it!

In terms of the pipeline it turned out to be a little cumbersome to work with the outputs, but the approach I settled on was to use the Azure CLI task to run an inline PowerShell script to get the output value and use it to set a pipeline variable:

- task: AzureCLI@2
  displayName: 'Get ARM outputs'
  inputs:
    azureSubscription: '$(AzureServiceConnection)'
    scriptType: pscore
    scriptLocation: inlineScript
    inlineScript: |
      $cdnEndpointUrl= & az deployment group show -n main -g $(AzureResourceGroup) --query properties.outputs.cdnEndpointUrl.value
      $cdnEndpointUrl = $cdnEndpointUrl.replace("""", "")
      Write-Output("##vso[task.setvariable variable=NEXT_PUBLIC_CDN_URL;]$cdnEndpointUrl")

The value returned from the JMESPath query is a JSON object and so in this case is a quoted string. I chose to just replace the double quotes with an empty string, but with hindsight it would probably have been better to work with the JSON object and maybe I will revisit that in future.

Adding the Bicep deployment to the Azure pipeline

Now that I had written and tested my Bicep scripts it was time to integrate it into the Azure pipeline YAML file. As mentioned at the start of this post I had already laid some ground work for this, which was that:

  • I had used variable groups to provide variable values to the pipeline - a "default" variable group, called next-app-env-vars, that contained baseline values, which could then be overwritten by more specific variable groups depending on the target environment e.g. next-app-env-vars-preview or next-app-env-vars-prod

  • The variable groups were included using conditional insertion where the conditions were based on the source branch for the pipeline run - if the source branch is main then the target environment is prod(uction)

  • If there is no match on the source branch to a target environment then it would default to the "preview" environment, but the deployment to the preview environment requires review and approval

I just needed to reinstate some of the above variable groups and I ended up simplifying them because some of the variables I had in place were to do with things like setting the name of the App Service, but now that would be set using an output from the Bicep script.

Finally I had to add in the appropriate Azure CLI tasks to build, deploy and fetch outputs from the Bicep script and get it integrated into the pipeline. There is documentation for fitting Bicep into a CI/CD pipeline so I used that as an example, but had to adapt it slightly to fit the needs of my sample application, but it was pretty simple to do in the end and with that the pipeline was now complete.

The only manual steps require in the Azure portal now when setting up the sample app is to create two resource groups and the pipeline takes care of the rest!

Conclusion

Working with Bicep was a really positive experience overall and so much more pleasant than writing ARM templates directly! From the documentation, to the tooling, to the development of the scripts, to the integration into the pipeline - everything went pretty smoothly and I am pleased with the result.

The requirements for the sample app are not particularly complex, but I think Bicep scripts would scale well as they are easy to read and compose, and the language is expressive and rich with features, but not overwhelming or particularly complex.

For something that (I think) is relatively new I was pleasantly surprised by the quality of documentation and examples available and because Bicep is just a DSL for ARM templates (which have been around for a long time) you can often "reverse engineer" an ARM template example if you're not sure what to do in Bicep.

As mentioned there are plenty of other good "infrastucture as code" options out there and Bicep is not without its limitations, but I think if you are only targeting Azure then I would definitely recommend it.