Azure Quickstart Templates and Azure ARM documentation provide sample code for the deployment of many types of resources, but most rely on existing resource groups to deploy into. This presents a challenge for Azure users who prefer to deploy multiple virtual machines in individual resource groups with the necessary supporting resources.
For example, the administrator needs to deploy 25 new virtual machines complete with disks, NICs, and storage accounts, and the organization requires that each VM and associated resources have their own resource group.
For the administrator to achieve this, they may need to first stage the resource groups before deploying the resources. This lengthens the time it takes to deploy and separates the stages of the deployment into multiple steps.
This workflow can be improved by leveraging nested deployment templates and copy loops. This allows an administrator to initiate resource deployment at the subscription level in Azure, where resource groups and the resources within them can be deployed in one template. This method, combined with the use of a PowerShell script, provides repeatability, traceability, and a "set it and forget it" approach to deploying as many resources as the regional compute quota allows, up to the max per type of resource.
Prerequisites
Az PowerShell module
Install the Az PowerShell module with:
install-module Az
Note: If you lack administrative rights, you'll need to append -scope CurrentUser to this line. This will take a few minutes.
A PowerShell-connected Azure instance
To connect to an Azure instance in PowerShell, first import the Az PowerShell module and then run connect-azaccount. Be sure to use the switch -environment AzureUSGovernment if you're connecting to Azure Gov instead of Azure Public.
import-module Az Connect-AzAccount
After connecting to Azure PowerShell, use the command get-azcontext to view the subscription you're connected to by default. If you've only got one Azure subscription, then you won't need to change this. If you've got access to multiple subscriptions, ensure you're connected to the one you intend to deploy resources to. You can change the subscription with Set-AzContext in PowerShell.
Once you've ensured the appropriate subscription in Azure PowerShell, you can verify your subscription-level role assignments with:
PS C:\> Get-AzRoleAssignment -SignInName myemail@example.com | FL DisplayName, RoleDefinitionName, Scope DisplayName : Your Name RoleDefinitionName : Virtual Machine Contributor Scope : /subscriptions/00000000-0000-0000-0000-000000000000/
If your organizational policy requires the use of service principals to deploy resources in Azure, ensure that your service principal has the least privileges necessary to deploy resources and connect to Az using the service principal credentials. As a rule, assign Azure RBAC roles to groups, not to individuals.
Domain join rights
You also need join rights for the domain to which the resources are being deployed.
Deployment script
Once the prerequisites are out of the way, we can set up the script used to make the deployment process repeatable, which I'll call azureDeploy.ps1. It's a simple script that leverages the New-AzDeployment cmdlet to allow for the customization of parameters in our Azure template (more on those in a minute).
The script begins with a function to open a file selection dialog box. I prefer this method over file paths, so others on my team can use the script with relative ease.
Function Get-OpenFile($initialDirectory) { [System.Reflection.Assembly]::LoadWithPartialName("system.windows.forms") | Out-Null $OpenFileDialog = New-Object System.Windows.Forms.OpenFileDialog $OpenFileDialog.initialDirectory = $initialDirectory $OpenFileDialog.filter = 'All files (*.*)| *.*' $OpenFileDialog.ShowDialog() | Out-Null $OpenFileDialog.filename }
Get-OpenFile opens a file selection dialog box:
This is incorporated into the script to allow the user to select both the Azure ARM template and the Parameters file. We'll use variables to store the paths to both files.
Write-host "Select Azure Template File" $templateFile = Get-OpenFile Write-host "Select Azure Template Parameters File" $templateParams = Get-OpenFile
From there, we'll run the New-AzDeployment cmdlet, attaching any parameters we'd like to customize with a switch (i.e., -vmSize).
New-AzDeployment ` -Name (Read-Host "Please enter deployment name") ` -TemplateFile $templateFile ` -TemplateParameterFile $templateParams ` -copyCount (Read-Host "How many VM would you like to create?") ` -copyIndexNumber (Read-Host "Enter the last 3 digits of the VM name, based on the team the VM(s) will be assigned to. Number will increment by 1 per each VM specified in the copyCount parameter.")` -adminPassword (ConvertTo-SecureString (Read-Host "Enter Password for Azure VM")` -asplaintext -force) ` -vmSize 'Standard_B2ms' ` -Location northcentralus ` -DeploymentDebugLogLevel All ` -verbose
Note: Be sure to use the backtick character ` at the end of lines in the New-AzDeployment cmdlet.
Here is the complete script:
<#Set the template file and template parameter file paths and paste into Az PowerShell. Enter the admin password of your choosing for the new VMs and your domain-join-capable user account and password for automated domain join. #> #Provides Dialog Box to select a file with list of computers. File must contain #only 1 of each of the Computer name(s) per line Function Get-OpenFile($initialDirectory) { [System.Reflection.Assembly]::LoadWithPartialName("system.windows.forms") | Out-Null $OpenFileDialog = New-Object System.Windows.Forms.OpenFileDialog $OpenFileDialog.initialDirectory = $initialDirectory $OpenFileDialog.filter = 'All files (*.*)| *.*' $OpenFileDialog.ShowDialog() | Out-Null $OpenFileDialog.filename } Write-host "Select Azure Template File" $templateFile = Get-OpenFile Write-host "Select Azure Template Parameters File" $templateParams = Get-OpenFile New-AzDeployment ` -Name (Read-Host "Please enter deployment name") ` -TemplateFile $templateFile ` -TemplateParameterFile $templateParams ` -copyCount (Read-Host "How many VM would you like to create?") ` -copyIndexNumber (Read-Host "Enter the last 3 digits of the VM name, based on the team the VM(s) will be assigned to. Number will increment by 1 per each VM specified in the copyCount parameter.")` -adminPassword (ConvertTo-SecureString (Read-Host "Enter Password for Azure VM") -asplaintext -force) ` -vmSize 'Standard_B2ms' ` -Location northcentralus ` -DeploymentDebugLogLevel All ` -verbose
ARM template
Now that we know how to deploy resources, let's take a look at a nested template that will deploy multiple virtual machines, up to the max per deployment. The hierarchy of resources deployed with this template is as follows:
Subscription
- ResourceGroup-1
- VM-1
- Storage-1
- NIC-1
- ResourceGroup-2
- VM-2
- Storage-2
- NIC-2
At the top of the parameter file, we list the schema. It is important to use the subscription-level deployment schema for this purpose. If you use the resource group-level schema, as is the case in the many Azure Quickstart Templates, the deployment will fail. Below the schema, we declare our parameters and their types, as well as any variables.
A couple of notes about this template:
- It's set up to use an image from an Azure shared image gallery. If you're running an enterprise in an Azure tenant, you'll likely have one of these configured. If not, the Azure marketplace can be used for the disk image in the VM resource.
- It assumes that you're deploying resources that will be joined to a domain. The template shown in this example assumes you're deploying Windows VMs to a Windows domain. For example, you need to deploy VMs for a new group of employees.
- You have a service account configured to join resources to your Windows domain with the password secret stored in an Azure KeyVault. If the goal is not to deploy resources to a domain, remove the joinDomain extension from the template file.
{ "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", "contentVersion": "1.0.0.0", "parameters": { "nicName": { "type": "String" }, "rgNamePrefix": { "type": "string" }, "rgLocation": { "type": "string" }, "storageAccountName": { "defaultValue": "rgdemostor", "type": "String" }, "copyCount": { "type": "Int", "metadata": { "description": "number of resource copies to make with copyindex loops" } }, "adminUsername": { "type": "String", "metadata": { "description": "Username for the Virtual Machine." } }, "adminPassword": { "type": "SecureString", "metadata": { "description": "Password for the Virtual Machine." } }, "vmSize": { "defaultValue": "Standard_B2ms", "type": "String", "metadata": { "description": "Size of the virtual machine." } }, "location": { "defaultValue": "[resourceGroup().location]", "type": "String", "metadata": { "description": "Location for all resources." } }, "domainToJoin": { "type": "String", "metadata": { "description": "The FQDN of the AD domain" } }, "domainUsername": { "type": "String", "metadata": { "description": "Username of the account on the domain" } }, "dnsLabelPrefix": { "type": "String", "metadata": { "description": "Unique public DNS prefix for the deployment. The fqdn will look something like '<dnsname>.westus.cloudapp.azure.com'. Up to 62 chars, digits or dashes, lowercase, should start with a letter: must conform to '^[a-z][a-z0-9-]{1,61}[a-z0-9]$'." } }, "domainPassword": { "type": "SecureString", "metadata": { "description": "Password of the account on the domain" } }, "ouPath": { "defaultValue": "", "type": "String", "metadata": { "description": "Specifies an organizational unit (OU) for the domain account. Enter the full distinguished name of the OU in quotation marks. Example: \"OU=testOU; DC=domain; DC=Domain; DC=com\"" } }, "domainJoinOptions": { "defaultValue": 3, "type": "Int", "metadata": { "description": "Set of bit flags that define the join options. Default value of 3 is a combination of NETSETUP_JOIN_DOMAIN (0x00000001) & NETSETUP_ACCT_CREATE (0x00000002) i.e. will join the domain and create the account on the domain. For more information see https://msdn.microsoft.com/en-us/library/aa392154(v=vs.85).aspx" } }, "copyIndexNumber": { "defaultValue": "", "type": "int" } }, "variables": { "imagename": "Custom_Windows10_Image", "imageID": "/subscriptions/00000-00000-00000-00000-00000/resourceGroups/Images/providers/Microsoft.Compute/images/Custom_Windows10_Image", "rgName": "[concat(parameters('rgNamePrefix'),parameters('dnsLabelPrefix'))]" }
Following the parameters, we'll list our subscription-level resources before getting into the nested template.
"resources": [ { "type": "Microsoft.Resources/resourceGroups", "apiVersion": "2020-06-01", "name": "[concat(variables('rgName'),copyindex(parameters('copyIndexNumber')))]", "location": "[parameters('rgLocation')]", "properties": {}, "copy": { "name": "ResourceGroupCopy", "count": "[parameters('copyCount')]" } },
In this case, the resource groups are being deployed at the subscription level, and then another template is nested below the resource group. This is the key concept being examined, as you could swap out the resources in this example template for any other resources and reuse the copyCount and copyIndex parameters to deploy multiples of just about any resource you have an existing template for.
To nest the template within the resource group, declare a resource of type "Microsoft.Resources/deployments." The resource group name and other parameters are taken from the Azure parameter file.
{ "type": "Microsoft.Resources/deployments", "apiVersion": "2020-06-01", "resourceGroup": "[concat(variables('rgName'),copyindex(parameters('copyIndexNumber')))]", "name": "[concat('vmDeployment',copyindex(parameters('copyIndexNumber')))]", "copy": { "name": "DeploymentCopy", "count": "[parameters('copyCount')]" }, "dependsOn": [ "[resourceId('Microsoft.Resources/resourceGroups/', concat(variables('rgName'),copyindex(parameters('copyIndexNumber'))))]" ], "properties": { "mode": "Incremental", "template": { "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", "contentVersion": "1.0.0.0", "parameters": {}, "variables": {}, "resources": [
The "copy" element is important and must be included for the nested template method to work correctly and keep resources linked by the copyIndex parameter. More on that here.
Now that the deployment has been declared in the template, we can fill it with the resources being deployed. This is the point at which alternate resources can be included. Simply replace what's being provided below for this template with the resources you need to deploy. Make sure to use the parameters from the file and concatenate the "copyIndexNumber" parameter to your resource names to deploy them into the correct resource group.
"resources": [ { "type": "Microsoft.Storage/storageAccounts", "apiVersion": "2018-11-01", "name": "[concat(parameters('storageAccountName'), copyindex(parameters('copyIndexNumber')))]", "location": "[parameters('location')]", "sku": { "name": "Standard_LRS" }, "kind": "Storage", "properties": { "ipRules": [ { "value": "string", "action": "Allow" } ] } }, { "type": "Microsoft.Network/networkInterfaces", "apiVersion": "2018-11-01", "name": "[concat(parameters('nicName'), copyindex(parameters('copyIndexNumber')), '_')]", "location": "[parameters('location')]", "properties": { "ipConfigurations": [ { "name": "[concat(parameters('nicName'), copyindex(parameters('copyIndexNumber')), '_', 'IP')]", "properties": { "privateIPAllocationMethod": "Dynamic", "subnet": { "id": "/subscriptions/00000-00000-00000-00000/resourceGroups/Network-01/providers/Microsoft.Network/virtualNetworks/vNet-01/subnets/Subnet-01" } } } ] } }, { "type": "Microsoft.Compute/virtualMachines", "apiVersion": "2018-10-01", "name": "[concat(parameters('dnsLabelPrefix'), copyindex(parameters('copyIndexNumber')))]", "location": "[parameters('location')]", "dependsOn": [ "[concat(parameters('nicName'), copyindex(parameters('copyIndexNumber')), '_')]" ], "properties": { "hardwareProfile": { "vmSize": "[parameters('vmSize')]" }, "licenseType": "Windows_Client", "osProfile": { "computerName": "[concat(parameters('dnsLabelPrefix'), copyindex(parameters('copyIndexNumber')))]", "adminUsername": "[parameters('adminUsername')]", "adminPassword": "[parameters('adminPassword')]" }, "storageProfile": { "imageReference": { "id": "/subscriptions/00000-00000-00000-00000/resourceGroups/RG-SIG/providers/Microsoft.Compute/galleries/mySIG/images/Custom_Windows10_Image/versions/1.1.0" }, "osDisk": { "ManagedDisk": { "storageAccountType": "StandardSSD_LRS" }, "createOption": "FromImage" } }, "networkProfile": { "networkInterfaces": [ { "id":"[concat('/subscriptions/00000-00000-00000-00000-00000/resourceGroups/','RG-',parameters('dnsLabelPrefix'), copyindex(parameters('copyIndexNumber')),'/providers/Microsoft.Network/networkInterfaces/', parameters('nicName'), copyindex(parameters('copyIndexNumber')), '_')]" } ] } } }, { "type": "Microsoft.Compute/virtualMachines/extensions", "apiVersion": "2019-07-01", "name": "[concat(parameters('dnsLabelPrefix'),copyindex(parameters('copyIndexNumber')),'/joindomain')]", "location": "[parameters('location')]", "dependsOn": [ "[concat('Microsoft.Compute/virtualMachines/',parameters('dnsLabelPrefix'), copyindex(parameters('copyIndexNumber')))]" ], "properties": { "publisher": "Microsoft.Compute", "type": "JsonADDomainExtension", "typeHandlerVersion": "1.3", "autoUpgradeMinorVersion": true, "settings": { "Name": "[parameters('domainToJoin')]", "OUPath": "[parameters('ouPath')]", "User": "[concat(parameters('domainToJoin'), '\\', parameters('domainUsername'))]", "Restart": "true", "Options": "[parameters('domainJoinOptions')]" }, "protectedSettings": { "Password": "[parameters('domainPassword')]" } } } ] } } } ] }
This concludes the template file. Using this template (with customizations for your organization), you can deploy virtual machines and their supporting resources into a resource group per VM and resources at scale and in a short amount of time. In my environment, deploying 50 resources takes about 45 minutes, although your timing would vary based on the quotas and limits mentioned previously.
Now that we've reviewed the template, we'll examine the parameter file.
Notes:
The adminPassword value is for the VM password, not the credential of the user running the deployment. It's passed as a secure string in the azureDeploy.ps1 script.
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", "contentVersion": "1.0.0.0", "parameters": { "nicName": { "value": "nic_" }, "adminUsername": { "value": "VMAdmin" }, "adminPassword": { "value": "" }, "vmSize": { "value": "Standard_B2ms" }, "location": { "value": "northcentralus" }, "domainToJoin": { "value": "contoso.Local" }, "domainUsername": { "value": "svc.azjoin" }, "dnsLabelPrefix": { "value": "VirtualMachine" }, "domainPassword": { "reference":{ "keyVault": { "id": "/subscriptions/00000-00000-00000-00000-00000/resourceGroups/RG-KV/providers/Microsoft.KeyVault/vaults/myKV" }, "secretName": "AZJoin-ServiceAccount-PW" } }, "ouPath": { "value": "OU=DC,OU=Contoso,OU=Workstations,DC=local" }, "rgLocation": { "value": "northcentralus" }, "rgNamePrefix": { "value": "RG-" }, "domainJoinOptions": { "value": 3 }, "storageAccountName": { "value": "stor" }, "copyCount":{ "value": 4 }, "copyIndexNumber":{ "value": "" } } }
A couple of points on the domainPassword and OUPath parameters.
domainPassword does not accept a value from the user (I'm not in the business of sending DA credentials around in text, and you shouldn't be either), but rather references a secret stored in an Azure KeyVault, which contains the password of a domain service account with the rights to join systems to the domain.
The OUPath parameter allows the template developer to customize the active directory OU in which to add the resources via the joinDomain extension.
Subscribe to 4sysops newsletter!
Summary
In summary, using a combination of Azure PowerShell and Azure ARM templates, we can deploy compute, storage, network, and other Azure resources and resource groups in a way that is scalable and repeatable. Azure automation provides a robust method for Azure administrators and DevOps teams to provide a seamless and reliable deployment method for users, ensuring a consistent experience for both users and Azure administrators.