IaC Azure

Terraform Module: Azure Windows Virtual Machine — a hardened, boot-diagnostic-ready VM you can stamp out per environment

Quick take — A production Terraform module for azurerm_windows_virtual_machine: managed OS disk, system-assigned identity, boot diagnostics, optional Key Vault admin password, and validated SKU/size inputs. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "azurerm" {
  features {}
}

module "windows_virtual_machine" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-windows-virtual-machine?ref=v1.0.0"

  name                = "..."  # VM name; also prefixes the NIC and OS disk (2-64 chars,…
  resource_group_name = "..."  # Resource group for the VM, NIC, and disks.
  location            = "..."  # Azure region.
  subnet_id           = "..."  # Subnet resource ID the NIC attaches to.
  admin_password      = "..."  # Local admin password, 12-123 chars; source from Key Vau…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

azurerm_windows_virtual_machine is the modern, opinionated resource for running a single Windows Server (or Windows client) instance on Azure. Unlike the legacy azurerm_virtual_machine, it bakes in sane defaults: it always uses a managed OS disk, it forces you to think about patching modes, and it exposes first-class arguments for things like hotpatching_enabled, secure_boot_enabled, and automatic_updates_enabled. The trade-off is that it manages the VM and the OS disk, but it deliberately does not create the NIC, the public IP, or the data disks — you wire those up around it.

That separation is exactly why a reusable module pays off. Every Windows VM you stand up needs the same boilerplate stitched together in the same order: a network interface bound to a subnet, an admin credential that must never land in state as a plaintext literal, boot diagnostics so you can actually see a blue screen at 3 a.m., and a managed identity so the box can pull secrets without a stored password. Hand-rolling that per project guarantees drift — one team enables boot diagnostics, another forgets patch_mode, a third hardcodes Standard_D2s_v3 everywhere and blows the monthly budget. This module wraps azurerm_windows_virtual_machine plus its essential companions (the NIC and a system-assigned identity) behind a small, validated input surface so a Windows box is one module block, not forty lines of copy-paste.

When to use it

Module structure

terraform-module-azure-windows-virtual-machine/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

main.tf

# Network interface the VM attaches to. The module owns the NIC so that the
# subnet + optional static IP travel with the VM as one unit.
resource "azurerm_network_interface" "this" {
  name                = "${var.name}-nic"
  location            = var.location
  resource_group_name = var.resource_group_name

  ip_configuration {
    name                          = "ipconfig1"
    subnet_id                     = var.subnet_id
    private_ip_address_allocation = var.private_ip_address == null ? "Dynamic" : "Static"
    private_ip_address            = var.private_ip_address
    public_ip_address_id          = var.public_ip_address_id
  }

  tags = var.tags
}

resource "azurerm_windows_virtual_machine" "this" {
  name                = var.name
  computer_name       = var.computer_name # defaults to var.name when null; max 15 chars
  location            = var.location
  resource_group_name = var.resource_group_name
  size                = var.size
  zone                = var.zone

  admin_username = var.admin_username
  admin_password = var.admin_password

  network_interface_ids = [azurerm_network_interface.this.id]

  # Patching + lifecycle behaviour. Hotpatch requires patch_mode =
  # "AutomaticByPlatform" and a supported (Datacenter Core) image.
  provisioning_type        = "Microsoft.Compute"
  patch_mode               = var.patch_mode
  hotpatching_enabled      = var.hotpatching_enabled
  patch_assessment_mode    = var.patch_assessment_mode
  automatic_updates_enabled = var.automatic_updates_enabled
  enable_automatic_updates  = var.automatic_updates_enabled

  # Trusted Launch security profile.
  secure_boot_enabled = var.secure_boot_enabled
  vtpm_enabled        = var.vtpm_enabled

  license_type = var.license_type

  os_disk {
    name                 = "${var.name}-osdisk"
    caching              = "ReadWrite"
    storage_account_type = var.os_disk_storage_account_type
    disk_size_gb         = var.os_disk_size_gb
  }

  source_image_reference {
    publisher = var.source_image.publisher
    offer     = var.source_image.offer
    sku       = var.source_image.sku
    version   = var.source_image.version
  }

  # System-assigned identity is created when var.identity_type includes it.
  dynamic "identity" {
    for_each = var.identity_type == null ? [] : [var.identity_type]
    content {
      type         = identity.value
      identity_ids = var.user_assigned_identity_ids
    }
  }

  # Boot diagnostics: empty block => managed storage account (recommended).
  boot_diagnostics {
    storage_account_uri = var.boot_diagnostics_storage_account_uri
  }

  tags = var.tags

  lifecycle {
    # The platform may rotate the admin_password out of band (e.g. via a
    # break-glass reset). Ignore it after create so plans stay clean.
    ignore_changes = [admin_password]
  }
}

# Optional data disks, created and attached per the var.data_disks map.
resource "azurerm_managed_disk" "data" {
  for_each = var.data_disks

  name                 = "${var.name}-data-${each.key}"
  location             = var.location
  resource_group_name  = var.resource_group_name
  storage_account_type = each.value.storage_account_type
  disk_size_gb         = each.value.disk_size_gb
  create_option        = "Empty"
  zone                 = var.zone

  tags = var.tags
}

resource "azurerm_virtual_machine_data_disk_attachment" "data" {
  for_each = var.data_disks

  managed_disk_id    = azurerm_managed_disk.data[each.key].id
  virtual_machine_id = azurerm_windows_virtual_machine.this.id
  lun                = each.value.lun
  caching            = each.value.caching
}

variables.tf

variable "name" {
  description = "Name of the virtual machine. Also used as the prefix for the NIC and OS disk."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9][a-zA-Z0-9-]{0,62}[a-zA-Z0-9]$", var.name))
    error_message = "name must be 2-64 chars, alphanumeric or hyphen, and may not start or end with a hyphen."
  }
}

variable "computer_name" {
  description = "Windows hostname (NetBIOS). Must be <= 15 chars. Defaults to var.name when null."
  type        = string
  default     = null

  validation {
    condition     = var.computer_name == null || length(coalesce(var.computer_name, "")) <= 15
    error_message = "computer_name must be 15 characters or fewer (Windows NetBIOS limit)."
  }
}

variable "resource_group_name" {
  description = "Resource group the VM, NIC, and disks are created in."
  type        = string
}

variable "location" {
  description = "Azure region (e.g. centralindia, eastus2)."
  type        = string
}

variable "subnet_id" {
  description = "Resource ID of the subnet the NIC attaches to."
  type        = string
}

variable "size" {
  description = "VM SKU/size (e.g. Standard_D2s_v5, Standard_B2ms)."
  type        = string
  default     = "Standard_D2s_v5"

  validation {
    condition     = can(regex("^Standard_[A-Za-z0-9]+$", var.size))
    error_message = "size must be a valid Standard_* VM SKU, e.g. Standard_D2s_v5."
  }
}

variable "zone" {
  description = "Availability zone (\"1\", \"2\", or \"3\"). Null for no zone pinning."
  type        = string
  default     = null

  validation {
    condition     = var.zone == null || contains(["1", "2", "3"], coalesce(var.zone, ""))
    error_message = "zone must be one of \"1\", \"2\", or \"3\", or null."
  }
}

variable "admin_username" {
  description = "Local administrator username. Cannot be 'administrator', 'admin', etc. (Azure reserved)."
  type        = string
  default     = "azureadmin"

  validation {
    condition = !contains(
      ["administrator", "admin", "user", "root", "guest", "console"],
      lower(var.admin_username)
    )
    error_message = "admin_username may not be a Windows reserved name (administrator, admin, user, root, guest, console)."
  }
}

variable "admin_password" {
  description = "Local administrator password. Source from Key Vault / a random_password; never hardcode."
  type        = string
  sensitive   = true

  validation {
    condition     = length(var.admin_password) >= 12 && length(var.admin_password) <= 123
    error_message = "admin_password must be 12-123 characters (Azure Windows complexity requirement)."
  }
}

variable "private_ip_address" {
  description = "Static private IP. When null the NIC uses Dynamic allocation."
  type        = string
  default     = null
}

variable "public_ip_address_id" {
  description = "Optional resource ID of a public IP to associate with the NIC. Null = private only."
  type        = string
  default     = null
}

variable "source_image" {
  description = "Marketplace image reference for the OS."
  type = object({
    publisher = string
    offer     = string
    sku       = string
    version   = string
  })
  default = {
    publisher = "MicrosoftWindowsServer"
    offer     = "WindowsServer"
    sku       = "2022-datacenter-azure-edition"
    version   = "latest"
  }
}

variable "os_disk_storage_account_type" {
  description = "OS disk type: Standard_LRS, StandardSSD_LRS, Premium_LRS, or Premium_ZRS."
  type        = string
  default     = "Premium_LRS"

  validation {
    condition = contains(
      ["Standard_LRS", "StandardSSD_LRS", "Premium_LRS", "Premium_ZRS", "StandardSSD_ZRS"],
      var.os_disk_storage_account_type
    )
    error_message = "os_disk_storage_account_type must be a supported managed disk SKU."
  }
}

variable "os_disk_size_gb" {
  description = "OS disk size in GB. Null keeps the image default (usually 127 GB)."
  type        = number
  default     = null

  validation {
    condition     = var.os_disk_size_gb == null || (var.os_disk_size_gb >= 30 && var.os_disk_size_gb <= 4095)
    error_message = "os_disk_size_gb must be between 30 and 4095, or null."
  }
}

variable "patch_mode" {
  description = "Patch orchestration: Manual, AutomaticByOS, or AutomaticByPlatform (required for hotpatch)."
  type        = string
  default     = "AutomaticByPlatform"

  validation {
    condition     = contains(["Manual", "AutomaticByOS", "AutomaticByPlatform"], var.patch_mode)
    error_message = "patch_mode must be Manual, AutomaticByOS, or AutomaticByPlatform."
  }
}

variable "patch_assessment_mode" {
  description = "Patch assessment: ImageDefault or AutomaticByPlatform."
  type        = string
  default     = "AutomaticByPlatform"

  validation {
    condition     = contains(["ImageDefault", "AutomaticByPlatform"], var.patch_assessment_mode)
    error_message = "patch_assessment_mode must be ImageDefault or AutomaticByPlatform."
  }
}

variable "hotpatching_enabled" {
  description = "Enable hotpatching. Requires patch_mode = AutomaticByPlatform and a Datacenter Core / azure-edition-core image."
  type        = bool
  default     = false
}

variable "automatic_updates_enabled" {
  description = "Whether Windows automatic updates are enabled."
  type        = bool
  default     = true
}

variable "secure_boot_enabled" {
  description = "Enable Secure Boot (Trusted Launch). Requires a Gen2 image."
  type        = bool
  default     = true
}

variable "vtpm_enabled" {
  description = "Enable vTPM (Trusted Launch). Requires a Gen2 image."
  type        = bool
  default     = true
}

variable "license_type" {
  description = "Azure Hybrid Benefit: Windows_Client, Windows_Server, or None."
  type        = string
  default     = "None"

  validation {
    condition     = contains(["Windows_Client", "Windows_Server", "None"], var.license_type)
    error_message = "license_type must be Windows_Client, Windows_Server, or None."
  }
}

variable "identity_type" {
  description = "Managed identity: SystemAssigned, UserAssigned, 'SystemAssigned, UserAssigned', or null to disable."
  type        = string
  default     = "SystemAssigned"

  validation {
    condition = var.identity_type == null || contains(
      ["SystemAssigned", "UserAssigned", "SystemAssigned, UserAssigned"],
      var.identity_type
    )
    error_message = "identity_type must be SystemAssigned, UserAssigned, 'SystemAssigned, UserAssigned', or null."
  }
}

variable "user_assigned_identity_ids" {
  description = "Resource IDs of user-assigned identities (required when identity_type includes UserAssigned)."
  type        = list(string)
  default     = null
}

variable "boot_diagnostics_storage_account_uri" {
  description = "Blob endpoint for boot diagnostics. Null uses a platform-managed storage account (recommended)."
  type        = string
  default     = null
}

variable "data_disks" {
  description = "Map of empty managed data disks to create and attach, keyed by a short name."
  type = map(object({
    lun                  = number
    disk_size_gb         = number
    storage_account_type = optional(string, "Premium_LRS")
    caching              = optional(string, "ReadWrite")
  }))
  default = {}

  validation {
    condition     = alltrue([for d in values(var.data_disks) : d.lun >= 0 && d.lun <= 63])
    error_message = "Each data disk lun must be between 0 and 63."
  }
}

variable "tags" {
  description = "Tags applied to all resources created by the module."
  type        = map(string)
  default     = {}
}

outputs.tf

output "id" {
  description = "Resource ID of the Windows virtual machine."
  value       = azurerm_windows_virtual_machine.this.id
}

output "name" {
  description = "Name of the Windows virtual machine."
  value       = azurerm_windows_virtual_machine.this.name
}

output "network_interface_id" {
  description = "Resource ID of the NIC attached to the VM."
  value       = azurerm_network_interface.this.id
}

output "private_ip_address" {
  description = "Primary private IP address assigned to the VM's NIC."
  value       = azurerm_network_interface.this.private_ip_address
}

output "principal_id" {
  description = "Principal (object) ID of the system-assigned managed identity, or null if not enabled."
  value = try(
    azurerm_windows_virtual_machine.this.identity[0].principal_id,
    null
  )
}

output "os_disk_id" {
  description = "Resource ID of the managed OS disk."
  value       = one(azurerm_windows_virtual_machine.this.os_disk[*].id)
}

output "data_disk_ids" {
  description = "Map of data-disk keys to their managed disk resource IDs."
  value       = { for k, d in azurerm_managed_disk.data : k => d.id }
}

How to use it

resource "azurerm_resource_group" "app" {
  name     = "rg-iis-prod-cin"
  location = "centralindia"
}

# Pull the admin password from Key Vault instead of a tfvars literal.
data "azurerm_key_vault" "platform" {
  name                = "kv-platform-cin"
  resource_group_name = "rg-platform-cin"
}

data "azurerm_key_vault_secret" "vm_admin" {
  name         = "iis-vm-local-admin"
  key_vault_id = data.azurerm_key_vault.platform.id
}

module "windows_virtual_machine" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-windows-virtual-machine?ref=v1.0.0"

  name                = "vm-iis-prod-01"
  computer_name       = "iis-prod-01"
  resource_group_name = azurerm_resource_group.app.name
  location            = azurerm_resource_group.app.location
  subnet_id           = azurerm_subnet.app.id
  zone                = "1"

  size                         = "Standard_D2s_v5"
  os_disk_storage_account_type = "Premium_LRS"
  os_disk_size_gb              = 128

  admin_username = "kvadmin"
  admin_password = data.azurerm_key_vault_secret.vm_admin.value

  # Hotpatch the azure-edition image and use Azure Hybrid Benefit.
  source_image = {
    publisher = "MicrosoftWindowsServer"
    offer     = "WindowsServer"
    sku       = "2022-datacenter-azure-edition-core"
    version   = "latest"
  }
  patch_mode          = "AutomaticByPlatform"
  hotpatching_enabled = true
  license_type        = "Windows_Server"

  data_disks = {
    logs = { lun = 0, disk_size_gb = 256, storage_account_type = "StandardSSD_LRS" }
  }

  tags = {
    environment = "prod"
    workload    = "iis"
    owner       = "platform-team"
  }
}

# Downstream: grant the VM's managed identity read access to the same Key Vault
# using the module's principal_id output — no stored credentials on the box.
resource "azurerm_role_assignment" "vm_kv_reader" {
  scope                = data.azurerm_key_vault.platform.id
  role_definition_name = "Key Vault Secrets User"
  principal_id         = module.windows_virtual_machine.principal_id
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "azurerm"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...azurerm state bucket/container + key per path...
  }
}

2. Module configlive/prod/windows_virtual_machine/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-windows-virtual-machine?ref=v1.0.0"
}

inputs = {
  name = "..."
  resource_group_name = "..."
  location = "..."
  subnet_id = "..."
  admin_password = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/windows_virtual_machine && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
name string Yes VM name; also prefixes the NIC and OS disk (2-64 chars, no leading/trailing hyphen).
computer_name string null No Windows hostname, ≤ 15 chars. Defaults to name when null.
resource_group_name string Yes Resource group for the VM, NIC, and disks.
location string Yes Azure region.
subnet_id string Yes Subnet resource ID the NIC attaches to.
size string "Standard_D2s_v5" No VM SKU; validated against Standard_*.
zone string null No Availability zone "1"/"2"/"3", or null.
admin_username string "azureadmin" No Local admin user; reserved names rejected.
admin_password string (sensitive) Yes Local admin password, 12-123 chars; source from Key Vault.
private_ip_address string null No Static private IP; null = Dynamic.
public_ip_address_id string null No Public IP resource ID to associate, or null.
source_image object WindowsServer 2022 azure-edition No Marketplace image reference (publisher/offer/sku/version).
os_disk_storage_account_type string "Premium_LRS" No OS disk SKU.
os_disk_size_gb number null No OS disk size 30-4095 GB; null keeps image default.
patch_mode string "AutomaticByPlatform" No Manual / AutomaticByOS / AutomaticByPlatform.
patch_assessment_mode string "AutomaticByPlatform" No ImageDefault or AutomaticByPlatform.
hotpatching_enabled bool false No Enable hotpatch (needs AutomaticByPlatform + core azure-edition image).
automatic_updates_enabled bool true No Windows automatic updates.
secure_boot_enabled bool true No Trusted Launch Secure Boot (Gen2 image).
vtpm_enabled bool true No Trusted Launch vTPM (Gen2 image).
license_type string "None" No Azure Hybrid Benefit: Windows_Client / Windows_Server / None.
identity_type string "SystemAssigned" No Managed identity type, or null to disable.
user_assigned_identity_ids list(string) null No UAMI IDs when identity_type includes UserAssigned.
boot_diagnostics_storage_account_uri string null No Boot-diag blob endpoint; null = managed storage.
data_disks map(object) {} No Empty data disks to create/attach (lun, size, sku, caching).
tags map(string) {} No Tags applied to all created resources.

Outputs

Name Description
id Resource ID of the Windows virtual machine.
name Name of the Windows virtual machine.
network_interface_id Resource ID of the attached NIC.
private_ip_address Primary private IP of the VM’s NIC.
principal_id Principal ID of the system-assigned managed identity (null if disabled).
os_disk_id Resource ID of the managed OS disk.
data_disk_ids Map of data-disk keys to their managed disk resource IDs.

Enterprise scenario

A retail group runs a legacy .NET Framework order-management portal on IIS that can’t move to Linux before the next peak season. The platform team consumes this module from a per-region stack to deploy three zone-pinned Standard_D4s_v5 VMs (one per availability zone) behind an internal Application Gateway, each fronting the same workload. Azure Hybrid Benefit (license_type = "Windows_Server") reuses their existing Software Assurance licenses to cut compute cost roughly 40%, hotpatching keeps the boxes patched without monthly reboot windows during the trading season, and every VM’s system-assigned identity is granted Key Vault Secrets User via the module’s principal_id output so the app reads its SQL connection string at runtime with zero credentials baked into the image.

Best practices

TerraformAzureWindows Virtual MachineModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading