Creating a Windows VM in Azure using Terraform — which way is best?

There are 3 basic ways to create a VM in Azure using Terraform code! But which is best? This can be confusing for beginners / intermediate users, especially when looking at existing projects coded in Terraform and trying to decipher them.
I’ve attempted to summarise each method below with pros and cons listed from my experience:
- Create a VM using the Terraform ‘azurerm’ provider https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/virtual_machine
Pros: Simple, easy to understand
Cons: Not very reusable, harder to create multiple instances
2. Create a VM using a custom module
Pros: Most Flexible, easy to create multiple VMs
Cons: Harder to initially code, harder to read
3. Create a VM using the Terraform registry module https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/windows_virtual_machine
Pros: Use Azure provided module, least code required
Cons: Less flexible
Examples!
In each example, 2 VMs are created using each method, 1 windows 10 desktop and 1 windows server 2019. The password is read from key vault, the VMs are placed in an availability set as is best practice. Desktops are placed in 1 subnet, and servers in another in the same VNET.
The examples are confused slightly as they do call a custom module to access a VM password stored in Azure key vault, as obviously you wouldn’t want to store the password in plain text. I won’t go into the key vault config in any detail in this post but hope to make that a subject of a future post.
There are a few files needed to setup the base environment first.
Main.tf defines the providers and remote backend, in this case I’m using Terraform cloud.
main.tf
# Demo for presentation creating a resource group in Azure!# Defines the Azure provider and defines what subscription we are connecting to.provider "azurerm" {version = "=2.25.0"features {}subscription_id = var.subscription_idclient_id = var.client_idclient_secret = var.client_secrettenant_id = var.tenant_id}# Specifies we want to use Terraform Cloud to store our state file.# https://app.terraform.io/app/xxxxterraform {backend "remote" {organization = "xxxx"workspaces {name = "azure-vm-windows-demo"}}}
The variables.tf file looks like this:
# read in from the terraform.auto.tfvars filevariable "subscription_id" {}variable "client_id" {}variable "client_secret" {}variable "tenant_id" {}variable "global_settings" {}variable "desktop_vm_image_publisher" {}variable "desktop_vm_image_offer" {}variable "desktop_vm_image_sku" {}variable "desktop_vm_image_version" {}variable "desktop_vm_size" {}variable "server_vm_image_publisher" {}variable "server_vm_image_offer" {}variable "server_vm_image_sku" {}variable "server_vm_image_version" {}variable "server_vm_size" {}
The terraform.auto.tfvars file should look something like the below (sensitive details removed)
# This file should not be checked into source control (add to .gitignore)subscription_id = "xxxxxxxxxxx"client_id = "xxxxxxxxxxx"client_secret = "xxxxxxxxxxx"tenant_id = "xxxxxxxxxxx"## globalsettingsglobal_settings = {#Set of tagstags = {applicationName = "Windows VM Demo"businessUnit = "Technical Solutions"costCenter = "MPN Sponsorship"DR = "NON-DR-ENABLED"deploymentType = "Terraform"environment = "Dev"owner = "Jack Roper"version = "0.1"}}# Desktop VM variablesdesktop_vm_image_publisher = "MicrosoftWindowsDesktop"desktop_vm_image_offer = "Windows-10"desktop_vm_image_sku = "20h1-pro"desktop_vm_image_version = "latest"desktop_vm_size = "Standard_B1s"# Server VM Variablesserver_vm_image_publisher = "MicrosoftWindowsServer"server_vm_image_offer = "WindowsServer"server_vm_image_sku = "2019-Datacenter"server_vm_image_version = "latest"server_vm_size = "Standard_B1s"
All resources are placed in a single resource group for simplicity in the demo, probably not a good idea in real world!
rg.tf
# Creating a base resource groupresource "azurerm_resource_group" "rg" {name = "azure-vm-demo-rg"location = "UK South"}
The VNET, subnet and NSG creation is handled first to act as a base for the VMs using the Azure registry modules:
networking.tf
# Create Networkmodule "network" {source = "Azure/network/azurerm"version = "3.2.1"resource_group_name = azurerm_resource_group.rg.namevnet_name = "tfdemovnet"address_space = "10.0.0.0/16"subnet_prefixes = ["10.0.1.0/24", "10.0.2.0/24"]subnet_names = ["desktops", "servers"]tags = var.global_settings.tagsdepends_on = [azurerm_resource_group.rg]}# Create NSG# https://registry.terraform.io/modules/Azure/network-security-group/azurerm/latestmodule "network-security-group" {source = "Azure/network-security-group/azurerm"version = "3.4.1"resource_group_name = azurerm_resource_group.rg.namelocation = "UKSouth" # Optional; if not provided, will use Resource Group locationsecurity_group_name = "tfdemonsg"source_address_prefix = ["*"]predefined_rules = [{name = "RDP"priority = "500"}]tags = var.global_settings.tagsdepends_on = [azurerm_resource_group.rg]}# Associate NSG with Subnetresource "azurerm_subnet_network_security_group_association" "nsg_association0" {subnet_id = module.network.vnet_subnets[0]network_security_group_id = module.network-security-group.network_security_group_id}# Associate NSG with Subnetresource "azurerm_subnet_network_security_group_association" "nsg_association1" {subnet_id = module.network.vnet_subnets[1]network_security_group_id = module.network-security-group.network_security_group_id}
1. Create a VM using the Terraform Azurerm provider
This comes in at 191 lines to create the VMs.
vm_example.tf
# Create a VM directly using Azurerm Creates 1 for Windows 10 desktop and 1 for Windows 2019 Server.# Getting VM admin passwords from keyvaultdata "azurerm_key_vault" "keyvault" {name = "tfdemokv123"resource_group_name = azurerm_resource_group.rg.namedepends_on = [module.keyvault,module.access_policy_master,]}data "azurerm_key_vault_secret" "password" {name = "password"key_vault_id = data.azurerm_key_vault.keyvault.iddepends_on = [module.keyvault,module.access_policy_master,]}### Create Desktop VM# Configure Availiablility setresource "azurerm_availability_set" "avset" {name = "tfdtnomod-avset"location = "uksouth"resource_group_name = azurerm_resource_group.rg.nameplatform_fault_domain_count = 2platform_update_domain_count = 2managed = truetags = var.global_settings.tags}# Create Public IPresource "azurerm_public_ip" "pip" {name = "tfdtnomod-pip"location = "uksouth"resource_group_name = azurerm_resource_group.rg.nameallocation_method = "Dynamic"tags = var.global_settings.tags}# Create network interface for VMresource "azurerm_network_interface" "nic" {name = "tfdtnomod-NIC"location = "uksouth"resource_group_name = azurerm_resource_group.rg.nameip_configuration {name = "tfdtnomod-NIC"subnet_id = module.network.vnet_subnets[0]private_ip_address_allocation = "Static"private_ip_address = "10.0.1.25"public_ip_address_id = azurerm_public_ip.pip.id}tags = var.global_settings.tags}# Create virtual machineresource "azurerm_virtual_machine" "vm-desktop" {name = "tfdtnomod"location = "uksouth"resource_group_name = azurerm_resource_group.rg.namenetwork_interface_ids = [azurerm_network_interface.nic.id]vm_size = var.desktop_vm_sizeavailability_set_id = azurerm_availability_set.avset.iddelete_os_disk_on_termination = truedelete_data_disks_on_termination = truestorage_image_reference {publisher = var.desktop_vm_image_publisheroffer = var.desktop_vm_image_offersku = var.desktop_vm_image_skuversion = var.desktop_vm_image_version}storage_os_disk {name = "tfdtnomod-OsDisk"caching = "ReadWrite"create_option = "FromImage"managed_disk_type = "StandardSSD_LRS"disk_size_gb = "127"}os_profile {computer_name = "tfdtnomod"admin_username = "admin"admin_password = data.azurerm_key_vault_secret.password.value}os_profile_windows_config {timezone = "GMT Standard Time"provision_vm_agent = trueenable_automatic_upgrades = true}tags = var.global_settings.tags}### Create Server VM# Configure Availiablility setresource "azurerm_availability_set" "avsetsvr" {name = "tfsvrnomod-avset"location = "uksouth"resource_group_name = azurerm_resource_group.rg.nameplatform_fault_domain_count = 2platform_update_domain_count = 2managed = truetags = var.global_settings.tags}# Create Public IPresource "azurerm_public_ip" "pipsv" {name = "tfsvrnomod-pip"location = "uksouth"resource_group_name = azurerm_resource_group.rg.nameallocation_method = "Dynamic"tags = var.global_settings.tags}# Create network interface for VMresource "azurerm_network_interface" "nicsv" {name = "tfsvrnomod-NIC"location = "uksouth"resource_group_name = azurerm_resource_group.rg.nameip_configuration {name = "tfsvrnomod-NIC"subnet_id = module.network.vnet_subnets[1]private_ip_address_allocation = "Static"private_ip_address = "10.0.2.26"public_ip_address_id = azurerm_public_ip.pipsv.id}tags = var.global_settings.tags}# Create virtual machineresource "azurerm_virtual_machine" "vm-server" {name = "tfsvrnomod"location = "uksouth"resource_group_name = azurerm_resource_group.rg.namenetwork_interface_ids = [azurerm_network_interface.nicsv.id]vm_size = var.server_vm_sizeavailability_set_id = azurerm_availability_set.avsetsvr.iddelete_os_disk_on_termination = truedelete_data_disks_on_termination = truestorage_image_reference {publisher = var.server_vm_image_publisheroffer = var.server_vm_image_offersku = var.server_vm_image_skuversion = var.server_vm_image_version}storage_os_disk {name = "tfsvrnomod-OsDisk"caching = "ReadWrite"create_option = "FromImage"managed_disk_type = "StandardSSD_LRS"disk_size_gb = "127"}os_profile {computer_name = "tfsvrnomod"admin_username = "admin"admin_password = data.azurerm_key_vault_secret.password.value}os_profile_windows_config {timezone = "GMT Standard Time"provision_vm_agent = trueenable_automatic_upgrades = true}tags = var.global_settings.tagsdepends_on = [module.keyvault,module.access_policy_master,]}
2. Create a VM using a custom module
As you can see below, less code is needed to create a VM, once the module has been defined. The module is called twice, just passing in different variables for each to create 2 VMs.
vm_module_local_example.tf
# Create an Azure VM cluster with Terraform calling a Module. Creates 1 for Windows 10 desktop and 1 for Windows 2019 Server.module windows_desktop_vm_using_local_module {source = "./vm"resource_group_name = azurerm_resource_group.rg.namelocation = "uksouth"sloc = "uks"vm_subnet_id = module.network.vnet_subnets[0]vm_name = "tfdtlocmod"vm_size = var.desktop_vm_sizepublisher = var.desktop_vm_image_publisheroffer = var.desktop_vm_image_offersku = var.desktop_vm_image_skustatic_ip_address = "10.0.1.15"activity_tag = "Windows Desktop"admin_password = module.vmpassword.secretvalue}module windows_server_vm_using_local_module {source = "./vm"resource_group_name = azurerm_resource_group.rg.namelocation = "uksouth"sloc = "uks"vm_subnet_id = module.network.vnet_subnets[1]vm_name = "tfsvlocmod"vm_size = var.server_vm_sizepublisher = var.server_vm_image_publisheroffer = var.server_vm_image_offersku = var.server_vm_image_skustatic_ip_address = "10.0.2.15"activity_tag = "Windows Server"admin_password = module.vmpassword.secretvalue}
The module itself contains 3 .tf files (a module is just a set of files contained in a folder, in this case one called ‘vm’).
main.tf
resource "random_string" "nic_prefix" {length = 4special = false}resource "azurerm_network_interface" "vm_nic" {name = "${var.vm_name}-nic1"location = var.locationresource_group_name = var.resource_group_nameip_configuration {name = "${var.vm_name}_nic_${random_string.nic_prefix.result}"subnet_id = var.vm_subnet_idprivate_ip_address_allocation = "Static"private_ip_address = var.static_ip_address}tags = var.tags}resource "azurerm_network_interface_security_group_association" "vm_nic_sg" {network_interface_id = azurerm_network_interface.vm_nic.idnetwork_security_group_id = var.network_security_group_idcount = var.network_security_group_id == "" ? 0 : 1}resource "azurerm_virtual_machine" "windows_vm" {name = var.vm_namevm_size = var.vm_sizelocation = var.locationresource_group_name = var.resource_group_nametags = merge(var.tags, { activityName = "${var.activity_tag} " })network_interface_ids = ["${azurerm_network_interface.vm_nic.id}",]storage_image_reference {publisher = var.publisheroffer = var.offersku = var.skuversion = "latest"}identity {type = "SystemAssigned"}storage_os_disk {name = "${var.vm_name}-os-disk"caching = "ReadWrite"create_option = "FromImage"managed_disk_type = "Standard_LRS"}os_profile {admin_password = module.vmpassword.secretvalueadmin_username = "azureuser"computer_name = var.vm_name}os_profile_windows_config {provision_vm_agent = true}delete_os_disk_on_termination = var.vm_os_disk_delete_flagdelete_data_disks_on_termination = var.vm_data_disk_delete_flag}
outputs.tf
output "vm_id" {value = "${azurerm_virtual_machine.windows_vm.id}"}output "vm_name" {value = "${azurerm_virtual_machine.windows_vm.name}"}output "vm_location" {value = "${azurerm_virtual_machine.windows_vm.location}"}output "vm_resource_group_name" {value = "${azurerm_virtual_machine.windows_vm.resource_group_name}"}
variables.tf
variable "resource_group_name" {}variable "location" {}variable "sloc" {}variable "vm_size" {default = "Standard_B1s"}variable "vm_subnet_id" {}variable "vm_name" {}variable "vm_os_disk_delete_flag" {default = true}variable "vm_data_disk_delete_flag" {default = true}variable "network_security_group_id" {default = ""}variable "static_ip_address" {}variable "publisher" {}variable "offer" {}variable "sku" {}variable "tags" {type = mapdescription = "All mandatory tags to use on all assets"default = {activityName = "AzureVMWindowsDemo"automation = "Terraform"costCenter1 = "A00000"dataClassification = "Demo"managedBy = "example@test.com"solutionOwner = "example@test.com"}}variable "activity_tag" {}variable "admin_password" {}
3. Create a VM using the Terraform registry module
The least lines of code needed to create the VMs, as there is no need for a module, however it is less flexible as your are using the module as defined by the Azure module maintainer, not your own so customisation may prove difficult.
vm_module_registry_example.tf
# Create an Azure VM cluster with Terraform using the Module Registry. Creates 1 for Windows 10 desktop and 1 for Windows 2019 Server using the module registry.# https://docs.microsoft.com/en-us/azure/developer/terraform/create-vm-cluster-module# Windows 10 desktop VM(s)module "windows_desktop_vm_using_registry_module" {source = "Azure/compute/azurerm"version = "3.10.0"resource_group_name = azurerm_resource_group.rg.nameis_windows_image = truevm_hostname = "tfdtregmod" // line can be removed if only one VM module per resource groupadmin_username = "admin"admin_password = var.admin_passwordpublic_ip_dns = ["tfdtregmod"] // change to a unique name per datacenter regionvm_os_publisher = var.desktop_vm_image_publishervm_os_offer = var.desktop_vm_image_offervm_os_sku = var.desktop_vm_image_skuvm_size = var.desktop_vm_sizeremote_port = "3389"nb_instances = "1"vnet_subnet_id = module.network.vnet_subnets[0]tags = var.global_settings.tagsdepends_on = [azurerm_resource_group.rg]}# Windows Server 2019 VM(s)module "windows_server_vm_using_registry_module" {source = "Azure/compute/azurerm"version = "3.10.0"resource_group_name = azurerm_resource_group.rg.nameis_windows_image = truevm_hostname = "tfsvregmod" // line can be removed if only one VM module per resource groupadmin_username = "admin"admin_password = var.admin_passwordpublic_ip_dns = ["tfsvregmod"] // change to a unique name per datacenter regionvm_os_publisher = var.server_vm_image_publishervm_os_offer = var.server_vm_image_offervm_os_sku = var.server_vm_image_skuvm_size = var.server_vm_sizeremote_port = "3389"nb_instances = "1"vnet_subnet_id = module.network.vnet_subnets[1]tags = var.global_settings.tagsdepends_on = [azurerm_resource_group.rg]}
Hope this helps clarify the different ways VMs can be created in Azure using Terraform! I’d love to get some feedback and please ask any questions you may have.
The ‘best’ way of doing it? It depends :)