Skip to content

Latest commit

Β 

History

104 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Terraform Azure Windows VM

Hardened Windows VMs by default: Trusted Launch, managed identity, a verified image catalog, hotpatching-aware, and VM Insights wired in one attribute.

CI Release Terraform Registry License


Overview

VMs keyed by name, each with its own NIC, built so the SECURE shape is the ZERO-CONFIG shape:

  • Trusted Launch (secure boot + vTPM) on by default.
  • A system-assigned managed identity on every VM (what the monitor agent and RBAC grants use).
  • Managed boot diagnostics, automatic OS updates, and platform patch assessment on by default.
  • Public IPs are inputs only (they live in the public-ip module), and attaching one is plan-visible; the intended door is the bastion module's free Developer SKU.
  • Passwords, handled properly: admin_password is required (Windows has no key-only mode); generate it with random_password and vault the retrievable copy write-only with the keyvault-secret module, as the complete example shows. Computer names are capped at 15 characters on Windows, so they derive from the VM name automatically.

The image catalog replaces the old external "SKU calculator" modules: source_image_simple takes a friendly key (Ubuntu2204, Ubuntu2404, Debian12, RHEL9, Sles15, Rocky9; discover them via the image_catalog_keys output) resolving to marketplace references verified against the live platform, all Gen2 and Trusted Launch capable; Rocky carries its marketplace plan automatically. source_image_reference and source_image_id remain first-class, and marketplace plan images get their azurerm_marketplace_agreement created (deduplicated) when accept_marketplace_agreement = true.

VM Insights in one attribute: vm_insights = { log_analytics_workspace_id = ... } installs the Azure Monitor agent on every VM (per-VM opt-out), creates the VM Insights data collection rule (or associates an existing data_collection_rule_id), and associates each VM, using the VMs' managed identities.

The rest of the surface: flexible identity (SystemAssigned by default, UserAssigned, both, or None), timezone, WinRM listeners, additional unattend content, Azure Hybrid Benefit licensing, data disks with auto-assigned LUNs, spot pricing, zones and every placement option (availability sets, VMSS attachment, proximity groups, capacity reservations, dedicated hosts), ASG associations, static private IPs, accelerated networking, encryption at host, gallery applications, key vault certificate secrets (with their Windows certificate store), termination and OS image notifications, modern run commands (serialized after the monitor agent), and full patching controls. The often-forgotten subscription plumbing is opt-in and conditional too: resource_provider_feature_registrations (for example Microsoft.Compute/EncryptionAtHost, which a check reminds you about) and resource_provider_registrations, both documented as subscription-wide and single-owner.

Disclaimers (from the provider, worth knowing)

  • Terraform removes the OS disk with the VM by default (configurable via the provider features block).
  • All arguments, including the administrator login and password, are stored in the raw state as plain text: protect the state (and vault the retrievable copy write-only, as the examples show).
  • azurerm_windows_virtual_machine does not support unmanaged disks or attaching existing OS disks (capture an image, or fall back to azurerm_virtual_machine).
  • public_ip_address outputs may be unpopulated for Dynamic public IPs.
  • vm_agent_platform_updates_enabled is platform-controlled and read-only; this module does not touch it.

Usage

module "windows_vm" {
  source  = "libre-devops/windows-vm/azurerm"
  version = "~> 4.0"

  resource_group_id = module.rg.ids["rg-ldo-uks-prd-001"]
  location          = "uksouth"
  tags              = module.tags.tags

  vm_insights = {
    log_analytics_workspace_id = module.log_analytics.workspace_ids["log-ldo-uks-prd-001"]
  }

  windows_virtual_machines = {
    "vm-ldo-app-uks-prd-001" = {
      size                = "Standard_D2s_v5"
      admin_username      = "azureadmin"
      admin_password      = random_password.admin.result
      source_image_simple = "WindowsServer2022AzureEdition"
      subnet_id           = module.network.subnet_ids["snet-app"]

      patch_mode          = "AutomaticByPlatform"
      hotpatching_enabled = true
      timezone            = "GMT Standard Time"
    }
  }
}

Examples

  • examples/minimal - one VM with the secure defaults from a catalog image and a generated password.
  • examples/complete - the Windows "secure VM estate in a pinch" build: tags, rg, vnet with an NSG admitting RDP only from inside the vnet, forward and reverse private DNS with auto-registration, a free Developer bastion as the door, a vault holding the generated admin password (written write-only), Log Analytics with VM Insights on every VM, and two hardened VMs exercising the full surface (hotpatching on an AzureEdition image, timezone, data disks, spot, zones, static IPs, Azure Hybrid Benefit, a PowerShell run command).

Developing

Local work needs PowerShell 7+ and just, because the recipes wrap the LibreDevOpsHelpers PowerShell module (the same engine the libre-devops/terraform-azure action runs in CI). Install just with brew install just, or uv tool add rust-just then uv run just <recipe>.

Run just to list recipes: just update-ldo-pwsh (install or force-update LibreDevOpsHelpers from PSGallery), just validate, just scan (Trivy only), just pwsh-analyze (PSScriptAnalyzer only), just plan, just apply, just destroy, just e2e, just test, and just docs (the plan/apply/destroy recipes mirror the action, including the storage firewall dance; just e2e applies an example then always destroys it, defaulting to minimal, so nothing is left running). Releasing is also just: just increment-release [patch|minor|major] bumps, tags, and publishes a GitHub release, and the Terraform Registry picks up the tag.

Security scan exceptions

This module is scanned with Trivy; HIGH and CRITICAL findings fail the build. Any waiver is a deliberate, reviewed decision, never a way to quiet a finding that should be fixed. Waivers live in .trivyignore.yaml (the machine-applied source of truth, passed to Trivy with --ignorefile) and are mirrored in a table here so the reason is auditable.

ID Scope Reason
AVD-AZU-0013 (vault network ACL default action) examples/complete/main.tf The disposable example vault opts out of the keyvault module's deny-by-default firewall so the CI self-test runner can reach the data plane; real deployments keep the secure default, and the firewalled shape plus the terraform-azure action's key vault dance are documented alongside.

To add an exception: add an entry to .trivyignore.yaml (id, optional paths to scope it, and a statement recording why), then add a matching row here recording the reason. Both the file and the table are reviewed in the pull request.

Reference

The Requirements, Providers, Inputs, Outputs, and Resources below are generated by terraform-docs.

Requirements

Name Version
terraform >= 1.9.0, < 2.0.0
azurerm >= 4.0.0, < 5.0.0

Providers

Name Version
azurerm >= 4.0.0, < 5.0.0

Modules

No modules.

Resources

Name Type
azurerm_managed_disk.data resource
azurerm_marketplace_agreement.this resource
azurerm_monitor_data_collection_rule.vm_insights resource
azurerm_monitor_data_collection_rule_association.vm_insights resource
azurerm_network_interface.this resource
azurerm_network_interface_application_security_group_association.this resource
azurerm_resource_provider_feature_registration.this resource
azurerm_resource_provider_registration.this resource
azurerm_virtual_machine_data_disk_attachment.data resource
azurerm_virtual_machine_extension.monitor_agent resource
azurerm_virtual_machine_run_command.this resource
azurerm_windows_virtual_machine.this resource

Inputs

Name Description Type Default Required
location Azure region for the VMs. string n/a yes
resource_group_id Resource id of the resource group to create the VMs in. The name is parsed from it (pass the rg module's ids output). string n/a yes
resource_provider_feature_registrations Preview/opt-in features to register, as "Namespace/FeatureName" strings (for example
"Microsoft.Compute/EncryptionAtHost", the classic forgotten prerequisite for
encryption_at_host_enabled). Empty (the default) manages nothing. Registration is subscription-wide
and can take several minutes to propagate.
set(string) [] no
resource_provider_registrations Resource provider namespaces to register on the subscription (for example Microsoft.Monitor), for
subscriptions where the provider has never been used. Empty (the default) manages nothing. NOTE: a
namespace already registered elsewhere will conflict on import/destroy; this is for the deliberate
owner only, and the provider's own resource_provider_registrations setting must not also manage it.
set(string) [] no
tags Tags applied to every resource this module creates (merged with any per-VM tags). map(string) {} no
vm_insights Opt-in VM Insights for every VM in this call: the module installs the Azure Monitor agent (each VM's
system-assigned identity is what the agent authenticates with, which the module enables by default),
creates the VM Insights data collection rule pointed at log_analytics_workspace_id (or associates an
existing one passed as data_collection_rule_id), and associates every VM with it. null (the default)
creates nothing.
object({
log_analytics_workspace_id = optional(string)
data_collection_rule_id = optional(string)
})
null no
windows_virtual_machines The Windows VMs to create, keyed by VM name. Each VM gets its own NIC (subnet_id is where it lives;
public_ip_address_id is ONLY an input, public IPs live in the public-ip module).

SECURE DEFAULTS: Trusted Launch (secure_boot_enabled and vtpm_enabled true; the image catalog
entries are all Gen2 and Trusted Launch capable), a system-assigned managed identity, managed boot
diagnostics, automatic OS updates, and platform patch assessment. admin_password is required
(Windows has no key-only mode); generate it with random_password and copy it into a key vault with
the keyvault-secret module's write-only handling.

IMAGE SELECTION, exactly one of:
- source_image_simple: a friendly catalog key (WindowsServer2022, WindowsServer2022AzureEdition,
WindowsServer2025, WindowsServer2025AzureEdition; see the image_catalog_keys output), verified
Gen2/Trusted Launch marketplace references. The AzureEdition entries are the hotpatching-capable
ones.
- source_image_reference: { publisher, offer, sku, version (default latest) } for anything else.
- source_image_id: a custom or gallery image id.
Marketplace plan images: set plan { name, product, publisher } and optionally
accept_marketplace_agreement = true to create the azurerm_marketplace_agreement.

NETWORKING per VM: subnet_id (required), private_ip_address (static when set), public_ip_address_id,
accelerated_networking_enabled (default false; not every size supports it), ip_forwarding_enabled,
dns_servers, application_security_group_ids (associations only; ASGs live with the network modules),
nic_name / ipconfig_name overrides.

DISKS: os_disk (caching ReadWrite, StandardSSD_LRS by default, plus size, encryption set, security
encryption, write accelerator, diff_disk_settings) and data_disks keyed by name (size_gb required;
lun auto-assigned by declaration order unless set; storage_account_type, caching, create_option,
encryption set, zone follows the VM).

EVERYTHING ELSE: zone, availability_set_id, virtual_machine_scale_set_id, proximity_placement_group_id,
capacity_reservation_group_id, dedicated_host_id / dedicated_host_group_id, platform_fault_domain,
edge_zone; spot { max_bid_price, eviction_policy }; additional_capabilities (ultra SSD, hibernation);
encryption_at_host_enabled (subscription feature-gated; see resource_provider_feature_registrations
for the often-forgotten registration); identity (SystemAssigned by default, UserAssigned, both as
"SystemAssigned, UserAssigned", or None for no identity at all); patching (patch_mode default
AutomaticByOS, patch_assessment_mode default AutomaticByPlatform, automatic_updates_enabled default
true, hotpatching_enabled for AzureEdition images with AutomaticByPlatform patching, reboot_setting,
bypass flag); timezone; license_type (None by default; "Windows_Server" claims Azure Hybrid
Benefit); user_data / custom_data; computer_name (Windows caps it at 15 characters, so it defaults
to the VM name upper-cased, separators stripped, truncated to 15);
disk_controller_type; extensions_time_budget; gallery_applications; secrets (key vault certificates
with their Windows certificate store); winrm_listeners; additional_unattend_content;
termination_notification; os_image_notification; boot_diagnostics_storage_account_uri (unset =
managed storage); run_command { script | script_uri | command_id, run_as_user, run_as_password };
monitor_agent_enabled (default true, only relevant when vm_insights is set) and per-VM tags.

PROVIDER DISCLAIMERS worth knowing: the OS disk is deleted with the VM by default (configurable via
the provider features block); all arguments including the administrator login and password are
stored in the raw state as plain text (protect the state); unmanaged disks and attaching existing
OS disks are not supported by azurerm_windows_virtual_machine; and public_ip_address outputs may be
unpopulated for Dynamic public IPs.
map(object({
size = string
admin_username = string
admin_password = string

source_image_simple = optional(string)
source_image_reference = optional(object({
publisher = string
offer = string
sku = string
version = optional(string, "latest")
}))
source_image_id = optional(string)
plan = optional(object({
name = string
product = string
publisher = string
}))
accept_marketplace_agreement = optional(bool, false)

subnet_id = string
private_ip_address = optional(string)
public_ip_address_id = optional(string)
accelerated_networking_enabled = optional(bool, false)
ip_forwarding_enabled = optional(bool, false)
dns_servers = optional(list(string))
application_security_group_ids = optional(list(string), [])
nic_name = optional(string)
ipconfig_name = optional(string)

os_disk = optional(object({
name = optional(string)
caching = optional(string, "ReadWrite")
storage_account_type = optional(string, "StandardSSD_LRS")
disk_size_gb = optional(number)
disk_encryption_set_id = optional(string)
secure_vm_disk_encryption_set_id = optional(string)
security_encryption_type = optional(string)
write_accelerator_enabled = optional(bool, false)
diff_disk_settings = optional(object({
option = string
}))
}), {})

data_disks = optional(map(object({
disk_size_gb = number
lun = optional(number)
storage_account_type = optional(string, "StandardSSD_LRS")
caching = optional(string, "ReadWrite")
create_option = optional(string, "Empty")
source_resource_id = optional(string)
disk_encryption_set_id = optional(string)
})), {})

secure_boot_enabled = optional(bool, true)
vtpm_enabled = optional(bool, true)
encryption_at_host_enabled = optional(bool)

identity = optional(object({
type = optional(string, "SystemAssigned")
identity_ids = optional(list(string))
}), {})

boot_diagnostics_enabled = optional(bool, true)
boot_diagnostics_storage_account_uri = optional(string)

patch_mode = optional(string, "AutomaticByOS")
automatic_updates_enabled = optional(bool, true)
hotpatching_enabled = optional(bool, false)
timezone = optional(string)
patch_assessment_mode = optional(string, "AutomaticByPlatform")
bypass_platform_safety_checks_on_user_schedule_enabled = optional(bool, false)
reboot_setting = optional(string)
provision_vm_agent = optional(bool, true)
allow_extension_operations = optional(bool, true)
extensions_time_budget = optional(string)

zone = optional(string)
availability_set_id = optional(string)
virtual_machine_scale_set_id = optional(string)
proximity_placement_group_id = optional(string)
capacity_reservation_group_id = optional(string)
dedicated_host_id = optional(string)
dedicated_host_group_id = optional(string)
platform_fault_domain = optional(number)
edge_zone = optional(string)

spot = optional(object({
max_bid_price = optional(number, -1)
eviction_policy = optional(string, "Deallocate")
}))

additional_capabilities = optional(object({
ultra_ssd_enabled = optional(bool, false)
hibernation_enabled = optional(bool, false)
}))

license_type = optional(string)
user_data = optional(string)
custom_data = optional(string)
computer_name = optional(string)
disk_controller_type = optional(string)

gallery_applications = optional(list(object({
version_id = string
automatic_upgrade_enabled = optional(bool)
configuration_blob_uri = optional(string)
order = optional(number)
tag = optional(string)
treat_failure_as_deployment_failure_enabled = optional(bool)
})), [])

secrets = optional(list(object({
key_vault_id = string
certificates = list(object({
url = string
store = optional(string)
}))
})), [])

winrm_listeners = optional(list(object({
protocol = string
certificate_url = optional(string)
})), [])

additional_unattend_content = optional(list(object({
setting = string
content = string
})), [])

termination_notification = optional(object({
enabled = bool
timeout = optional(string)
}))
os_image_notification_timeout = optional(string)

run_command = optional(object({
name = optional(string)
script = optional(string)
script_uri = optional(string)
command_id = optional(string)
run_as_user = optional(string)
run_as_password = optional(string)
}))

monitor_agent_enabled = optional(bool, true)
tags = optional(map(string))
}))
{} no

Outputs

Name Description
data_collection_rule_id The VM Insights data collection rule in effect (created or passed in); null when vm_insights is off.
data_disk_ids Map of "vm|disk" to managed disk id.
data_disks The data disks, keyed "vm|disk". Full resource objects.
identity_principal_ids Map of VM name to the system-assigned identity principal id (what RBAC assignments target; null when the VM has no system identity).
ids Map of VM name to resource id.
ids_zipmap Map of VM name to { name, id }, for easy composition with other modules.
image_catalog The full image catalog (key => { publisher, offer, sku }), verified Gen2 / Trusted Launch capable marketplace references.
image_catalog_keys Every friendly key the image catalog offers for source_image_simple.
names Map of VM name to name (convenience passthrough).
network_interface_ids Map of VM name to NIC id.
network_interfaces The NICs, keyed by VM name. Full resource objects.
private_ip_addresses Map of VM name to primary private IP address.
resource_group_name The resource group the VMs live in, parsed from resource_group_id.
virtual_machine_ids Map of VM name to the unique VM id (the compute fabric's GUID, not the resource id).
windows_virtual_machines The VMs, keyed by name: every attribute except the provider's deprecated vm_agent_platform_updates_enabled and enable_automatic_updates (a full-object output would trip their deprecation warnings). Sensitive because admin_password and custom_data are inside.

About

πŸͺŸ Hardened-by-default Windows VMs: Trusted Launch, managed identity, a hotpatch-aware image catalog, and VM Insights in one attribute

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages