In the first of this three part series of posts, I will breakdown the components of an Azure ARM template, and the tools used to develop one. Then in part two I will cover testing an ARM template. And finally in part three, I will cover deploying the resources in the ARM template to Azure.

The ARM template will consist of the following resources:

  • Windows VM
  • Network Interface
  • Public IP
  • Virtual Network
  • Network Security Group
  • Storage Account

overview

Getting Started

Before looking at Bicep, Terraform or Pulumi, I think it’s important to get to know the process of developing Azure ARM templates from scratch. Check out Sam Cogan’s ARM Template Masterclass YouTube series on developing ARM templates. This was an invaluable resource when I got started.

I use the following tools to complete the development, testing and deployment of ARM Templates:

ARM Components

Using VS Code and with the Azure Resource Manager (ARM) Tools extension installed, create a new json file and start typing arm.

vsExt

Select arm!. This will create the basic framework of an ARM template.

armFramework

I will go through each section of the ARM template for creating the resources above.

Parameters

TIP: Hit return in the parameters braces, start typing new and select new-paramter.

newParam

The extension will create the basic parameter configuration for you

newParam2

In the template, the prefix parameter is simply a string which I am adding to each resource name. Using an ARM template function called concat, I can combine multiple string values to create a concatenated string. String concatenation is simply joining two strings together to make a new string.

"prefix": {
    "type": "string",
    "metadata": {
        "description": "Prefix used for naming resources."
    }
},

The location parameter is used to specify the location of the resources. You will quite commonly see "[resourceGroup().location]" specified as the location of each resource. However, the arm-ttk (next post) complains about this when validating the template. So the location will be stored in the parameters file.

"location": {
    "type": "string",
    "metadata": {
        "description": "Location of the resources"
    }
},

The vmSize parameter will specify the size of the Azure Virtual machine.

"vmSize": {
    "type": "string",
    "metadata": {
        "description": "Size of VM to deploy."
    }
},

The windowsOSVersion parameter will have a default value of 2016-Datacenter, which is what will be selected if the parameter value is empty during the time of deployment. Notice the list of allowedValues. This means only these three OS values are allowed as a parameter.

"windowsOSVersion": {
    "type": "string",
    "defaultValue": "2016-Datacenter",
    "allowedValues": [
        "2016-Nano-Server",
        "2016-Datacenter",
        "2019-Datacenter"
    ],
    "metadata": {
        "description": "The Windows version for the VM."
    }
},

The adminUsername parameter is used to specify the name of the local admin user to be created on the VM.

"adminUsername": {
    "type": "string",
    "metadata": {
        "description": "Username for the VM."
    }
},

And finally, the adminPassword parameter is a secure string to set the password of the newly created admin user. You can either enter this password at deployment time, store it in plain text in your parameter file, or retrieve the secret from Azure Key Vault.

"adminPassword": {
    "type": "securestring",
    "metadata": {
        "description": "Password for the adminUsername."
    }
}

Variables

Variables allow you to re-use expressions throughout a template without re-writing them. Only one variable is used in this template which is storageAccountName. As you may know every Azure Storage Account must have a unique name, so a pretty good way of creating a unique one without checking first is by using some built-in string functions. In the example below the first toLower function is setting every alphabetic character to lower case. Then the concat function will join the prefix parameter together with ‘stg’ (short for storage), along with a 13 character, random string.

"variables": {
    "storageAccountName": "[toLower(concat(parameters('prefix'),'stg',uniqueString(resourceGroup().id)))]"
},

Let’s say for example the prefix is set to “DEVTEST”:

“devtest” + “stg” + “sh5ysh38jr74j”

The storage account will be named as follows:

“devteststgsh5ysh38jr74j”

Resources

TIP: Hit return in the resources braces, start typing st and select arm-storage. The config snippet for a Storage Account will be generated for you. You can select any resource and the snippets will generate an example configuration.

newStorage

The first resource in the template is the Storage Account. The variable storageAccountName defined earlier is used for the name and displayName tag. The sku defined is Standard_LRS.

{
    "name": "[variables('storageAccountName')]",
    "type": "Microsoft.Storage/storageAccounts",
    "apiVersion": "2019-06-01",
    "location": "[parameters('location')]",
    "tags": {
        "displayName": "[variables('storageAccountName')]"
    },
    "sku": {
        "name": "Standard_LRS"
    },
    "kind": "Storage"
},

The next resource is the Public IP. Notice the name is a concatenated string of the prefix parameter and -PublicIP. This naming convention is used on all other resources in the template.

{
    "name": "[concat(parameters('prefix'),'-PublicIP')]",
    "type": "Microsoft.Network/publicIPAddresses",
    "apiVersion": "2019-11-01",
    "location": "[parameters('location')]",
    "tags": {
        "displayName": "[concat(parameters('prefix'),'-PublicIP')]"
    },
    "properties": {
        "publicIPAllocationMethod": "Dynamic",
        "dnsSettings": {
            "domainNameLabel": "[toLower(parameters('prefix'))]"
        }
    }
},

The Network Security Group (NSG) specifies an inbound allow rule on TCP port 3389 for RDP access. Any source address is allowed so it can be accessed over the internet.

{
    "name": "[concat(parameters('prefix'),'-nsg')]",
    "type": "Microsoft.Network/networkSecurityGroups",
    "apiVersion": "2018-08-01",
    "location": "[parameters('location')]",
    "properties": {
        "securityRules": [
            {
                "name": "nsgRule1",
                "properties": {
                    "description": "description",
                    "protocol": "Tcp",
                    "sourcePortRange": "*",
                    "destinationPortRange": "3389",
                    "sourceAddressPrefix": "*",
                    "destinationAddressPrefix": "*",
                    "access": "Allow",
                    "priority": 100,
                    "direction": "Inbound"
                }
            }
        ]
    }
},

The Virtual Network (VNet) has a dependsOn element set for the NSG above. Usually resources are created in parallel, but this means the VNet will not be created until the NSG has.

The VNet will also be created with an address space of 10.0.0.0/16 and a subnet, called subnet1, with an address prefix of 10.0.0.0/24. The subnet is being assigned the NSG, hence the reason why the NSG must exist before the creation of the VNet.

{
    "name": "[concat(parameters('prefix'),'-VirtualNetwork')]",
    "type": "Microsoft.Network/virtualNetworks",
    "apiVersion": "2019-11-01",
    "location": "[parameters('location')]",
    "dependsOn": [
        "[resourceId('Microsoft.Network/networkSecurityGroups', concat(parameters('prefix'),'-nsg'))]"
    ],
    "tags": {
        "displayName": "[concat(parameters('prefix'),'-VirtualNetwork')]"
    },
    "properties": {
        "addressSpace": {
            "addressPrefixes": [
                "10.0.0.0/16"
            ]
        },
        "subnets": [
            {
                "name": "Subnet01",
                "properties": {
                    "addressPrefix": "10.0.0.0/24",
                    "networkSecurityGroup": {
                        "id": "[resourceId('Microsoft.Network/networkSecurityGroups', concat(parameters('prefix'),'-nsg'))]"
                    }
                }
            }
        ]
    }
},

The Network Interface (NIC) resource has two dependsOn elements; the Public IP and VNet resources. This is because the NIC will be assigned a public IP and to the newly created subnet, so the resources must exist before the NIC.

{
    "name": "[concat(parameters('prefix'),'-NetworkInterface')]",
    "type": "Microsoft.Network/networkInterfaces",
    "apiVersion": "2019-11-01",
    "location": "[parameters('location')]",
    "dependsOn": [
        "[resourceId('Microsoft.Network/publicIPAddresses', concat(parameters('prefix'),'-PublicIP'))]",
        "[resourceId('Microsoft.Network/virtualNetworks', concat(parameters('prefix'),'-VirtualNetwork'))]"
    ],
    "tags": {
        "displayName": "[concat(parameters('prefix'),'-NetworkInterface')]"
    },
    "properties": {
        "ipConfigurations": [
            {
                "name": "ipConfig1",
                "properties": {
                    "privateIPAllocationMethod": "Dynamic",
                    "publicIPAddress": {
                        "id": "[resourceId('Microsoft.Network/publicIPAddresses', concat(parameters('prefix'),'-PublicIP'))]"
                    },
                    "subnet": {
                        "id": "[resourceId('Microsoft.Network/virtualNetworks/subnets', concat(parameters('prefix'),'-VirtualNetwork'), 'Subnet01')]"
                    }
                }
            }
        ]
    }
},

Finally, the last resource in the template is the Virtual Machine (VM). Another two dependsOn elements are defined; the Storage account and the NIC resources. This is because the VM is assigned a NIC and boot diagnostics is enabled in the Storage Account specified.

Other areas of note are the vmSize, adminUsername, adminPassword, and windowsOSVersion parameters are all referenced.

{
    "name": "[concat(parameters('prefix'),'-VM')]",
    "type": "Microsoft.Compute/virtualMachines",
    "apiVersion": "2019-07-01",
    "location": "[parameters('location')]",
    "dependsOn": [
        "[resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName'))]",
        "[resourceId('Microsoft.Network/networkInterfaces', concat(parameters('prefix'),'-NetworkInterface'))]"
    ],
    "tags": {
        "displayName": "[concat(parameters('prefix'),'-VM')]"
    },
    "properties": {
        "hardwareProfile": {
            "vmSize": "[parameters('vmSize')]"
        },
        "osProfile": {
            "computerName": "windowsVM1",
            "adminUsername": "[parameters('adminUsername')]",
            "adminPassword": "[parameters('adminPassword')]"
        },
        "storageProfile": {
            "imageReference": {
                "publisher": "MicrosoftWindowsServer",
                "offer": "WindowsServer",
                "sku": "[parameters('windowsOSVersion')]",
                "version": "latest"
            },
            "osDisk": {
                "name": "[concat(parameters('prefix'),'-VMOSDisk')]",
                "caching": "ReadWrite",
                "createOption": "FromImage"
            }
        },
        "networkProfile": {
            "networkInterfaces": [
                {
                    "id": "[resourceId('Microsoft.Network/networkInterfaces', concat(parameters('prefix'),'-NetworkInterface'))]"
                }
            ]
        },
        "diagnosticsProfile": {
            "bootDiagnostics": {
                "enabled": true,
                "storageUri": "[reference(resourceId('Microsoft.Storage/storageAccounts/', variables('storageAccountName'))).primaryEndpoints.blob]"
            }
        }
    }
}

Outputs

The final section of the ARM template is outputs. You can use outputs when you need to return values from the deployed resources. After deploying this template (future post) the VM DNS Name and the VM’s private IP values will be displayed.

"outputs": {
    "VMDNSName": {
        "type": "string",
        "value": "[reference(concat(parameters('prefix'),'-PublicIP')).dnsSettings.fqdn]"
    },
    "VMPrivateIP": {
        "type": "string",
        "value": "[reference(concat(parameters('prefix'),'-NetworkInterface')).ipConfigurations[0].properties.privateIPAddress]"
    }
}

Parameters File

You can create a separate JSON file that contains the parameter values, rather than passing them in at the time of deployment. Here is an example of the parameters file I used when deploying the template.

{
    "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
    "contentVersion": "1.0.0.0",
    "parameters": {
        "prefix": {
            "value": "mkarmdev"
        },
        "location": {
            "value": "west europe"
        },
        "vmSize": {
            "value": "Standard_A2_v2"
        },
        "windowsOSVersion": {
            "value": "2016-Datacenter"
        },
        "adminUsername": {
            "value": "azureuser"
        },
        "adminPassword": {
            "reference": {
                "keyVault": {
                    "id": "/subscriptions/<ID>/resourceGroups/<RG>/providers/Microsoft.KeyVault/vaults/<NAME>"
                },
                "secretName": "vmAdminPassword"
            }
        }
    }
}

Complete Template on GitHub

You can find the complete template and parameters file on GitHub.

Summary

Finally, save the ARM template as you wish. You will commonly see azuredeploy.json but I saved mine as NewVM.json, and the parameters file as NewVM.parameters.json.

Hopefully the above information is clear. In the next two posts I will walk through testing and deploying the template.