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
- You need repeatable Windows VMs across dev/test/prod and want naming, sizing, and patching to be consistent and reviewable.
- You’re running workloads that genuinely require Windows: IIS sites, .NET Framework apps, SQL Server on IaaS, Active Directory domain controllers, or licensed ISV software with no Linux build.
- You want the admin password sourced from Key Vault (or generated) rather than checked into a
.tfvarsfile. - You want a system-assigned managed identity on by default so the VM can read Key Vault / Storage without embedded credentials.
- You do not need a full VM Scale Set or AKS — this is for pets or small, individually-addressable instances. For stateless fleets, reach for
azurerm_windows_virtual_machine_scale_setinstead.
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 config — live/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 config — live/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
- Never put the admin password in
.tfvarsor state as a literal. Sourceadmin_passwordfrom aazurerm_key_vault_secretdata source (as shown) or arandom_passwordresource, mark itsensitive, and keep your state backend encrypted. Pair it with theignore_changes = [admin_password]lifecycle so a break-glass reset doesn’t force a diff. - Keep Trusted Launch on and use Gen2 images. Leave
secure_boot_enabledandvtpm_enabledat theirtruedefaults and choose a Gen2 SKU (the default2022-datacenter-azure-editionis Gen2). This unlocks Microsoft Defender attestation and protects against boot-level rootkits. - Pick the patching model deliberately. Use
patch_mode = "AutomaticByPlatform"withhotpatching_enabled = trueonazure-edition-coreimages for reboot-free monthly patching; fall back toManualonly for apps with strict change windows, and never leave a production box onManualwithout a separate patching pipeline. - Right-size and claim Hybrid Benefit. Default to burstable
Standard_B*orD*s_v5sizes for general workloads, pinzonefor resilience, and setlicense_type = "Windows_Server"whenever you hold Software Assurance — the Windows license premium is a large fraction of the hourly cost. - Always enable boot diagnostics. Keep the
boot_diagnostics {}block (managed storage by default) so you can capture serial console and screenshots when a VM fails to boot — without it, a non-booting Windows box is nearly undiagnosable remotely. - Enforce a naming + tagging convention. Drive
name/computer_namefrom a consistent scheme (vm-<workload>-<env>-<nn>, ≤ 15-char NetBIOS hostname) and pass mandatorytags(environment, workload, owner, cost-center) so the fleet is sortable for cost and ownership reporting.