Introduction
Azure Resource Manager (ARM) templates are declarative infrastructure automation artifacts, formatted as JSON and used to repeatably deploy Azure services and resources in scripts, applications, or CI/CD pipelines.
ARM templates can include one or many resources. In particular, the ARM platform can resolve dependencies; for example, if a template declares that a Virtual Machine (VM) depends on a Network Interface Card (NIC), then the ARM platform will ensure successful deployment of the NIC (the depended-on resource) before deploying the VM (the depending resource).
In an ARM template with multiple resources to deploy, the ARM platform can parallelize resource deployments where there are no such dependencies. This can help overall deployments complete faster.
ARM templates can be complex to develop and refine. A great editor like Visual Studio (VS) Code with the ARM Tools extension will be helpful.
This post assumes you have some experience editing and deploying ARM templates. If you’re an ARM template beginner, there is great content on Microsoft Learn.
Azure Resource Deployments – Decisions, decisions
Azure resources can be deployed in multiple ways: in the Azure portal, with the Azure Command-Line Interface (CLI), Azure Powershell cmdlets, many Azure management libraries and SDKs, and of course ARM templates.
The deployment technologies can interact: for example, the Azure portal can be used to deploy resources directly in the User Interface (UI), or to deploy an ARM template provided by the user. Similarly, CLI commands and Az Powershell cmdlets directly deploy and manage Azure resources, but can also deploy ARM templates provided to the command or cmdlet.
While this sounds a bit complex, it just means we have to evaluate the options and determine which works well in our context.
For example, I have worked with organizations who have decided on using ARM templates for all deployment automation, including post-deployment configuration changes, and avoid CLI or Powershell scripts as much as possible.
Why? Because they have multiple deployment technologies, from dev/ops development environments to fully automated CI/CD pipelines on multiple platforms (like Azure DevOps Pipelines or Github Actions), and “pure” ARM templates are highly portable across their environments.
The rest of this post assumes a “pure” ARM template model, with minimal or no CLI commands or Powershell cmdlets.
Why should we care about Modularity and Reusability?
ARM templates require effort to create and refine. As your cloud estate grows, re-using artifacts like ARM templates can save time, lower cost, and help with standardization.
Many helpful ARM templates are available to start fast, such as the Azure QuickStart Templates, or those exported by the Azure portal’s “Export Template” capability (found in the left blade of each resource group and resource).
It’s common for ARM templates to combine many resources and inter-dependencies. There are justifications for this, including the ARM platform’s ability to parallelize deployment of resources submitted together in one large template, and to sequence dependencies. For occasional or every-time-is-highly-unique deployments, such large complex single ARM templates work well.
However, many cloud estates commonly deploy the same type of resource as part of larger deployments. For example: if you have a fleet of API products, each with its own compute, storage, and other resources, then you may be very frequently deploying Azure storage accounts, or Azure Functions, or other common components of larger deployments.
Using complex, all-in-one ARM templates means you may typically have a unique ARM template for each of these product deployments! When something changes (like an Azure API version, which can happen every few months), this means you may have to apply the same change, to the same resource type, across many ARM templates. This raises regression and ripple effect risks.
The next screenshot shows the ARM template outline generated by the Azure portal’s “Export template” capability for a resource group with just three VMs and associated resources, loaded into VS Code with the ARM Tools extension. That doesn’t look like an easily re-usable artifact!
An ARM template like the above, with many inter-dependent resources, makes sense in an environment where a very consistently similar or same environment is deployed repeatedly. But when aggregate deployments are very variable, maintaining many complex templates like the above can result in additional maintenance overhead due to duplication.
If typical ARM templates are hard to re-use, what is the alternative?
We can create ARM templates for each resource type, then combine the resource templates into a larger deployment in a script which runs the templates in the right sequence, and adds error-handling, logging, and addresses other non-functional concerns.
This approach does have consequences: instead of the ARM platform determining the order of operations based on dependencies and possible parallelization, you are now responsible for this in a script. You have to know, for example, to run a NIC deployment before running a VM deployment that will use that NIC.
Also, a script will not parallelize deployments, so a script-based deployment which deploys various individual ARM templates will take at least as long, and likely longer, than a single ARM template that combines all resources.
You must determine if these trade-offs make sense.
ARM templates can also include child templates, so you could define a “parent” template and nest modular “child” templates. This is another way to use individual modular ARM templates.
What does a deployment based on modular ARM templates look like?
A deployment will consist of several ARM templates, each of which contains the minimum number of resources. We could have one canonical template per resource type, then use that template in many different deployments.
Here are some examples of single-resource ARM templates, which deploy a VM and a NIC respectively (and could be used to compose the deployment shown above).
A VM template would deploy only the VM itself, and its storage disks. No NIC, no IP addresses, no network, and no other resources; each of those would have its own, separate ARM template.
Similarly, a NIC template would deploy only the NIC and no other resources.
A CD pipeline or a deployment script would then deploy each template in order, as in this sample script fragment.
az deployment group create -g "$myResourceGroup" --template-file "net.network-interface.json" \
--parameters \
location="$myAzureRegion" \
networkInterfaceName="$myNicName" \
# ... [additional parameters omitted for clarity]
az deployment group create -g "$myResourceGroup" --template-file "vm.linux.json" \
--parameters \
location="$myAzureRegion" \
virtualMachineName="$myVmName" \
virtualMachineSize="$myVmSize" \
# ... [additional parameters omitted for clarity]
I maintain these and many other single-resource ARM templates in my Github repo of Azure deployment artifacts at https://github.com/plzm/azure-deploy/tree/main/template.
Making modular ARM templates adaptable for high reusability
Modular, single-resource ARM templates are great for composability into complex, multi-resource deployments. But – this means our single-resource templates will need to adapt into many different scenarios flexibly.
The use of parameters and variables is a basic ARM template skill, and is the first step to making ARM templates modular, flexible, and re-usable.
If you are getting started with ARM templates, a great shortcut is to deploy a resource in the Azure Portal, then select “Export Template” (see above). Edit the exported template and practice replacing hard-coded resource names and other values with parameter or variable references, so that your exported template becomes more re-usable.
We can make templates much more flexible and modular by using template functions. Why would you use functions in a declarative template? To access resource information, implement conditional logic, iterate through loops, get a current date/time, concatenate strings, and much more.
Below we look at looping and dynamic element creation, but ARM contains a lot more functions than just the ones shown here.
Looping
Looping can help in many scenarios where you need to do something a number of times, but you can’t hard-code how many times. Instead, you might have a parameter or variable that stores how many times to do something.
For example, you can deploy a Virtual Machine (VM) and optionally attach data disks. Each data disk has some unique attributes, such as its Logical Unit Number (LUN). How can we make a VM ARM template handle this flexibly?
We will use a combination of the copy element and the copyIndex() function.
The copy
element encloses a block which will be deployed as many times as you specify, for example with a parameter for the number of copies.
The copyIndex()
function is used inside a copy
block and returns an integer that increments at each loop. For example, if you call copyIndex()
the first time, it will return 0. It will return 1, 2, etc. on later loops. You can use this to set unique values in each loop; for example, to set each data disk’s incrementing LUN.
How can we use copy and copyIndex() to add some number of data disks to a VM when that number varies at each deployment? Let’s use an example where the number of data disks for the VM is specified in a dataDiskCount
parameter. Here’s the ARM template block:
"copy": [
{
"name": "dataDisks",
"count": "[parameters('dataDiskCount')]",
"input": {
"lun": "[copyIndex('dataDisks')]",
"name": "[concat(parameters('dataDiskNameInfix'), copyIndex('dataDisks'))]",
"diskSizeGB": "[parameters('dataDiskSizeInGB')]"
}
}
]
Inside the copy
block is a block for a data disk. The count
attribute – the number of times this block will be deployed – is set by the dataDiskCount
parameter. This is how we make the number of data disks dynamic!
Further down in the block, we use copyIndex()
to retrieve a value that we use both for the data disk LUN as well as its name.
Wait – what if we need to deploy a VM with no data disks? No problem – specify zero (0) for the dataDiskCount
parameter.
I use this construct in a VM template.
Dynamic Element Creation
ARM templates can use the condition element for optional resource deployments. However, condition
can only be used with top-level resources. What if we need to make deployment of other parts of our template – child resources or even parts of a resource – conditional, so that they only get deployed or configured if needed for a particular deployment?
We can use the if() function, which accepts a boolean condition to test for as well as output values for true and false conditions. Inside the if()
block, we can use the createObject() function to dynamically create an object. So, instead of specifying static JSON as usual, we use a function call to dynamically construct a resource only if it is needed.
For example, we could have some logical test. If it returns false, we would emit nothing into the template. If it returns true, we would create an object that would get emitted and be deployed.
Let’s look at an example of a Network Security Group (NSG). Sometimes for dev/test, I deploy an NSG that adds a priority 100 rule to allow full access from my source IP address or network. I would like to use the same template to deploy production NSGs, but without a priority 100 inbound rule granting access from my source.
To address this, I use if()
to check if I passed a source for my inbound priority 100 rule. If I did not, then I don’t create the rule at all. If I did pass a source, then I use createObject()
to construct the rule element. createObject()
is very simple to use; you pass in any number of JSON key/value pairs. And – you can embed child objects with nested createObject()
calls!
Here’s my example securityRules
element showing the conditionally-created inbound priority 100 rule:
"securityRules": "[
if
(
empty(trim(parameters('nsgRuleInbound100Src'))),
json('[]'),
array
(
createObject
(
'name', variables('rule100Name'),
'properties', createObject
(
'protocol', '*',
'sourcePortRange', '*',
'destinationPortRange', '*',
'sourceAddressPrefix', parameters('nsgRuleInbound100Src'),
'destinationAddressPrefix', '*',
'access', 'Allow',
'priority', 100,
'direction', 'Inbound',
'sourcePortRanges', json('[]'),
'destinationPortRanges', json('[]'),
'sourceAddressPrefixes', json('[]'),
'destinationAddressPrefixes', json('[]')
)
)
)
)
]"
We first check if the nsgRuleInbound100Src
parameter has a value, using empty(trim(parameters('nsgRuleInbound100Src')))
. Note that I first use the ARM string function trim() to cut away any leading or trailing whitespace, then use empty() to determine if the trimmed value contains any characters.
The line json('[]')
is the output for when the test whether nsgRuleInbound100Src
is empty returns true. That is, if the nsgRuleInbound100Src
parameter is empty, we will emit an empty array, as the securityRules
element is an array. (Note that if we had to combine static rules with this dynamic rule, we could declare the array and always-there rules with static JSON, and make just this rule dynamic inside the static array.)
And lastly, if the test whether nsgRuleInbound100Src
is empty returns false – that is, we specified a value and so we need this rule to be created – we use createObject()
to dynamically emit a rule object. Note how the outer createObject()
has an inner (child) createObject()
– this lets us dynamically create complex structures, since JSON can have embedded structures.
Here’s the static JSON equivalent of the above createObject()
block:
"securityRules": [
{
"name": "[variables('rule100Name')]",
"properties": {
"protocol": "*",
"sourcePortRange": "*",
"destinationPortRange": "*",
"sourceAddressPrefix": "[parameters('nsgRuleInbound100Src')]",
"destinationAddressPrefix": "*",
"access": "Allow",
"priority": 100,
"direction": "Inbound",
"sourcePortRanges": [],
"destinationPortRanges": [],
"sourceAddressPrefixes": [],
"destinationAddressPrefixes": []
}
}
]
The JSON is very similar to the createObject()
block – but createObject()
gives us control over whether or not to deploy this child resource at every deployment based on some condition, which static JSON for a child resource cannot do (recall condition
is for top-level resources only).
I use this construct in an NSG template.
Flex your Bicep
Currently in preview is a new Domain-Specific Language (DSL) named Bicep (get it? ARM – Bicep? Yes, Microsoft does sometimes have a sense of humor). Bicep’s goal is to make resource creation easier and treat ARM as an Intermediate Language (IL). There are already several tutorials available, including one on advanced resource declarations with loops and conditions.
So what should you use? ARM? Bicep? Terraform? It’s up to you. Personally, I am very comfortable working with ARM templates in Visual Studio Code, but you may be more comfortable using Bicep or another tool. Choices are great – so experiment and find what’s best for you. Happy deploying!