Issues

Umbraco DevOps, Part 2: Automating Azure Resources Creation With ARM Templates

In the first part of the series you have learned the differences between hosting a Umbraco site on-prem compared to running on Azure and how to configure Umbraco to address those differences.

In this second article of the series, you will learn about ARM templates and how they can automate creating and linking the resources needed to run a Umbraco website on Azure.

If you have not read the first part, I recommend you read it now since it sets the base for what we are going to do in this article.

Why Automate?

If you want to test something new on Azure or build a one-off site, you can get away with creating the resources directly in the portal. But if you wish to have a replicable and error-free process for creating your websites, the best approach is to use a script to create resources.

Let's see more in detail some of the advantages of using a script instead of a manual process:

  • Avoid human errors
  • Consistent naming patterns
  • Consistent configuration, so you are sure all the projects have the best practice configuration specific for Azure
  • Automatically configure links between resources
  • Can be versioned in source control
  • Traceability of changes, due to being versioned
  • Accepts input parameters so you can reuse the same script for multiple projects
  • Automatic deployment

There are multiple languages to achieve what we can also call "Infrastructure as Code" (IaC). But in this article I'm going to use the ARM (Azure Resource Manager) templates.

Introduction to ARM Templates

ARM templates are the native Azure language for deploying Azure resources.

A template is a JSON object that is submitted to Azure using the following Azure CLI command.

az deployment group create --name DeploymentMnemonicName --resource-group RG-ResourceGroupName --template-name arm.json --parameters arm.parameters.dev.json

One key feature of the ARM templates is that they are idempotent. This means that you can use the same template for the initial creation of resources and for updating them.

Structure of an ARM Template

The ARM template has the following sections:

  • Parameters: Here you can define the values you want to be configurable to reuse the same template on multiple projects.
  • Variables: in this section, you define "hardcoded" values or combine parameters to generate the final values used in the resources.
  • Resources: the bulk of the template. Here you configure the resources you want to deploy
  • Outputs: in this final section you can query the result of the deployment so that can be used in the next steps of your deployment pipeline.
{
    "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
    "contentVersion": "1.0.0.0",
    "parameters": { },
    "variables": { },
    "resources": [ ],
    "outputs": { }
}

Parameters

As the name suggests, the parameters section contains the input values for the template. This allows you to reuse the same template in multiple environments and even similar projects.

Let's see how a parameter is defined by looking at some of the parameters used in the template for the Umbraco best-practice setup.

A simple parameter defines the type of the value (stringintbool), a default value if no parameter is provided, some constraint on the length and some metadata like a description.

"applicationName": {
    "type": "string",
    "defaultValue": "umbracoDevops",
    "minLength": 3,
    "metadata": {
        "description": "Application Name"
    }
},

In some cases, you might want to limit the values that can be specified, for example, when limiting the SKU or the region of the resource, using the allowedValues property.

"location": {
    "type": "string",
    "defaultValue": "westeurope",
    "allowedValues": [
        "westeurope",
        "northeurope"
    ],
    "metadata": {
        "description": "Location for all resources"
    }
},
"storageSKU": {
    "type": "string",
    "defaultValue": "Standard_LRS",
    "allowedValues": [
        "Standard_LRS",
        "Standard_GRS",
        "Standard_ZRS",
        "Standard_GZRS",
        "Standard_RAGZRS"
    ],
    "metadata": {
        "description": "Storage Account type"
    }
},

Other important parameters used for optimizing Umbraco on Azure are:

  • Name of the environment envName (DEVTSTSTGPRD).
  • The SKUs for the various resources, defined with a cheap but sensible default for keeping cost down but overridable for maximum performance.
  • The Umbraco version.
  • Whether Frontend and backoffice share the same application plan (to reduce cost).

Variables

With the parameters all defined, we combine all of them in the variable section to create the names of the resources.

Keeping consistent naming helps to understand what each resource does and for which project it is used, simply by looking at the name. Tagging also helps to filter resources when analyzing costs in the portal.

The naming convention I propose in this article is the one Microsoft recommends. It concatenates the type the resource with the project or application name and with the environment name. And an optional instance name at the end if you have multiple installations of the same application for different customers.

<resource>-<applicationName>-<env>-<instance>

We use some functions to concatenate the variables correctly, keeping it lower when needed and using the prefixes recommended by Microsoft for different types of resources.

Resources

We defined all the names and the SKUs of all the resources. Now it's time to create them.

This section is usually the longest part of the template, so I won't cover all the resources in detail but highlight some essential aspects. You can see the whole template in the repository on Github.

Let's start with the storage account, an excellent example because it contains all the elements used when defining a resource.

{
    "type": "Microsoft.Storage/storageAccounts",
    "apiVersion": "2019-06-01",
    "name": "[variables('storageName')]",
    "location": "[parameters('location')]",
    "sku": {
        "name": "[parameters('storageSKU')]"
    },
    "kind": "StorageV2",
    "tags": "[variables('resourceTags')]",
    "properties": {
        "supportsHttpsTrafficOnly": true
    },
    "resources": [
        {
            "type": "blobServices/containers",
            "apiVersion": "2019-06-01",
            "name": "[concat('default/', parameters('mediaContainerName'))]",
            "dependsOn": [
                "[variables('storageName')]"
            ],
            "properties": {
                "publicAccess": "Container"
            }
        }
    ]
},

Every resource starts with the definition of the type, in the case Microsoft.Storage/storageAccounts and an apiVersion. This last is very important because the Resource Manager uses it to validate the properties provided and allows Azure to introduce breaking changes in the model while keeping backward compatibility.

You then define the name, location and the SKU of the resource, and the kind of resource (only certain resources have this option, for example, windows or linux in web apps).

As mentioned above, you can also tag the resource for easier organization.

Next is the properties object, with is specific for each resource type. There will be more examples later.

And finally is the list of sub-resources to be created within the primary resource. In this case, we create a container for storing the media files for Umbraco. Another critical property used is dependsOn. It tells the resource manager that it cannot create it before the ones mentioned in the property.

The database is similar to the storage account since it has a main resource (the SQL Server) and a few sub-resources (the DB and firewall rules).

{
    "name": "[variables('dbServerName')]",
    "type": "Microsoft.Sql/servers",
    "location": "[parameters('location')]",
    "apiVersion": "2019-06-01-preview",
    "tags": "[variables('resourceTags')]",
    "properties": {
        "administratorLogin": "[parameters('dbAdministratorLogin')]",
        "administratorLoginPassword": "[parameters('dbAdministratorLoginPassword')]"
    },
    "resources": [
        {
            "type": "databases",
            "name": "[variables('dbName')]",
            "location": "[parameters('location')]",
            "apiVersion": "2019-06-01-preview",
            "dependsOn": [
                "[variables('dbServerName')]"
            ],
            "tags": "[variables('resourceTags')]",
            "sku": {
                "name": "[parameters('dbSKU')]"
            },
            "properties": {}
        },
        {
            "type": "firewallRules",
            "apiVersion": "2015-05-01-preview",
            "name": "AllowAllWindowsAzureIps",
            "dependsOn": [
                "[resourceId('Microsoft.Sql/servers/', variables('dbServerName'))]"
            ],
            "tags": "[variables('resourceTags')]",
            "properties": {
                "startIpAddress": "0.0.0.0",
                "endIpAddress": "0.0.0.0"
            }
        }
    ]
},

The creation of the search service is straightforward and uses the searchServiceEnvName as a name to have different search services per environment.

{
    "type": "Microsoft.Search/searchServices",
    "apiVersion": "2020-08-01",
    "location": "[parameters('location')]",
    "name": "[variables('searchServiceEnvName')]",
    "sku": {
        "name": "[toLower(parameters('searchSKU'))]"
    }
},

The same goes for the Application Insight resource:

{
    "apiVersion": "2018-05-01-preview",
    "name": "[variables('appInsight')]",
    "type": "microsoft.insights/components",
    "location": "[parameters('location')]",
    "tags": "[variables('resourceTags')]",
    "properties": {
        "applicationId": "[variables('appInsight')]"
    }
}

And then comes the creation of the web apps with their hosting plans. We will create one or two separate hosting plans, depending on the value of the shareHostPlan parameter.

We always create the first hosting plan.

{
    "apiVersion": "2018-02-01",
    "name": "[variables('hostingPlanName')]",
    "type": "Microsoft.Web/serverfarms",
    "location": "[parameters('location')]",
    "tags": "[variables('resourceTags')]",
    "properties": {
        "name": "[variables('hostingPlanName')]"
    },
    "sku": {
        "name": "[parameters('webAppSKU')]",
        "capacity": "[parameters('webAppSKUCapacity')]"
    }
},

And we create the second hosting plan, specific for the backend, only if the shareHostPlan parameter is false.

This conditional creation is achieved by adding the condition property in the model of the resource.

{
    "condition": "[not(parameters('shareHostPlan'))]",
    "name": "[variables('hostingPlanBOName')]",
    "type": "Microsoft.Web/serverfarms",
    ...
},

With all the components created, we can create the web apps and linking them to the other resources.

To do that, we need to make sure the web apps are created as the last resource, using the dependsOn property.

"dependsOn": [
    "[resourceId('Microsoft.Storage/storageAccounts', variables('storageName'))]",
    "[resourceId('Microsoft.Insights/components/', variables('appInsight'))]",
    "[resourceId('Microsoft.Web/serverfarms/', variables('hostingPlanName'))]",
    "[resourceId('Microsoft.Sql/servers/databases/', variables('dbServerName'), variables('dbName'))]",
    "[resourceId('Microsoft.Search/searchServices/', variables('searchServiceEnvName'))]"
],

In the creation of the backend web app, the hosting plan depends on the shareHostPlan parameter, so the resource name to depend on must be computed with a condition:

"[resourceId('Microsoft.Web/serverfarms/', if(parameters('shareHostPlan'),variables('hostingPlanName'),variables('hostingPlanBOName')))]",

The properties section is where all the configuration of the web apps happens. Here you see the configuration for the frontend web app.

"properties": {
    "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('hostingPlanName'))]",
    "siteConfig": {
        "connectionStrings": [
            {
                "name": "[parameters('connectionStringName')]",
                "connectionString": "[variables('connectionString')]",
                "type": "SQLAzure"
            }
        ],
        "appSettings": [
            {
                "name": "My.Core.LoadBalancing.ServerRole",
                "value": "Replica"
            },
            {
                "name": "Umbraco.Examine.LuceneDirectoryFactory",
                "value": "Examine.LuceneEngine.Directories.TempEnvDirectoryFactory, Examine"
            },
            {
                "name": "Umbraco.Core.ConfigurationStatus",
                "value": "[parameters('umbracoVersion')]"
            },
            {
                "name": "Umbraco.Core.MainDom.Lock",
                "value": "SqlMainDomLock"
            },
            {
                "name": "Umbraco.Core.LocalTempStorage",
                "value": "EnvironmentTemp"
            },
            {
                "name": "My.Azure.AI.InstrumentationConnectionString",
                "value": "[reference(resourceId('microsoft.insights/components/', variables('appInsight')), '2020-02-02').ConnectionString]"
            },
            {
                "name": "My.Azure.AI.Enabled",
                "value": "true"
            },
            {
                "name": "AzureBlobFileSystem.RootUrl:media",
                "value": "[reference(variables('storageName')).primaryEndpoints.blob]"
            },
            {
                "name": "AzureBlobFileSystem.ConnectionString:media",
                "value": "[concat('DefaultEndpointsProtocol=https;AccountName=',variables('storageName'),';AccountKey=',listKeys(variables('storageName'), '2019-04-01').keys[0].value,';EndpointSuffix=core.windows.net')]"
            },
            {
                "name": "AzureBlobFileSystem.ContainerName:media",
                "value": "[parameters('mediaContainerName')]"
            },
            {
                "name": "ExamineX.AzureSearchServiceName",
                "value": "[variables('searchServiceName')]"
            },
            {
                "name": "ExamineX.AzureSearchKey",
                "value": "[listAdminKeys(variables('searchServiceEnvName'), '2020-08-01').primaryKey]"
            },
            {
                "name": "ExamineX.EnvironmentName",
                "value": "[toLower(concat(parameters('envName')))]"
            }
        ]
    }
}

The application settings specified here in the ARM template will be visible in the portal, and they will always override the ones coming from the web.config file in the project. This ensures that Umbraco is configured correctly even if some settings has been forgotten during development.

We also avoid lots of copy&paste to configure connection strings and access keys. They are automatically discovered during the deployment and retrieved via the ARM template functions. For example, the storage account:

{
    "name": "AzureBlobFileSystem.ConnectionString:media",
    "value": "[concat('DefaultEndpointsProtocol=https;AccountName=',variables('storageName'),';AccountKey=',listKeys(variables('storageName'), '2019-04-01').keys[0].value,';EndpointSuffix=core.windows.net')]"
},

The backend web app is configured the same way, just with slightly different values, as explained in the first article of the series.

Outputs

After the template has been deployed, you might want to know which resources have been created. It can be useful for a following step in your CD pipeline, or to see it as results of the az cli command.

In the output section, you can use any of the ARM functions, and you can call any of the ARM APIs, retrieve the information you need, and put them in a property of the JSON object that is returned as result.

For example, you can get the url of the newly created web app.

"websiteUrl": {
    "type": "string",
    "value": "[reference(variables('webAppFEName')).defaultHostName]"
},

Or you can call the list method on the webapp REST method to retried the publishing credentials.

"publishProfile": {
    "type": "object",
    "value": "[list(concat('Microsoft.Web/sites/', variables('webAppFEName') ,'/config/publishingcredentials'), '2019-08-01')]"
},

How to Manage Multiple Environments

You can deploy the ARM template using all the default values for the parameters, or you can customize the deployment providing different ones.

The easiest way to do so is to create one parameters file per environment. This way also the parameters can be version controlled.

For example, you might want to keep the cost down and use the lowest SKU possible for a development environment while sharing the hosting plans.

{
  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "envName": {
      "value": "DEV"
    },
    "location": {
      "value": "westeurope"
    },
    "dbAdministratorLogin": {
      "value": "umbracodevops-admin"
    },
    "dbAdministratorLoginPassword": {
      "value": "xxxxxxxxxxxxxxxxxx"
    },
    "shareHostPlan": {
      "value": true
    },
    "webAppSKU": {
      "value": "B2"
    },
    "dbSKU": {
      "value": "S3"
    }
  }
}

Alternatively, you can also provide the parameters as key=value pairs in the call to the az cli.

And you can also combine both approaches and specify some parameters inline together with the parameters file: --parameters umbracoVersion="8.16.0" ./umbracodevops.parameters.dev.json

How to Deploy an ARM Template

To deploy an ARM template you can use the AZ CLI command deployment group create.

az deployment group create \
  --name InitialDeployment \
  --resource-group RG-UmbracoDevOps-Dev \
  --template-file ./UmbracoDevOps.json \
  --parameters umbracoVersion="8.16.0" ./UmbracoDevOps.parameters.dev.json \
  --query properties.outputs

You can also do a dry-run, and preview what is going to be deployed, using the command what-if instead of create.

Additional Resources

ARM templates might look intimidating, but development can be greatly simplified using the right tools and resources. Here is a collection of links that will help with your learning.

Conclusion

At the end of this article, you have learned the ARM syntax and created a replicable and error-free way for creating all the resources needed to host your best-practice Umbraco site on Azure.

In the next article of the series, you will see how to automate the build of the project and the deployment to Azure using Github actions.

Articles in this series

Simone Chiaretta

Simone Chiaretta is a long time Umbraco user and developer, working at the Council of the European Union where the whole public web presence is now based exclusively on Umbraco. Simone was a Microsoft MVP for 8 years; he authored several books on ASP.NET MVC and gave speeches at various international conferences, including 5 editions of Codegarden. He is also a member of the Umbraco Unicore team. When not working on code he spoils his recently born son and is racing in triathlon events
comments powered by Disqus