A standalone module that provisions a hardened Orchestrated Virtual Machine Scale Set (Flexible orchestration mode) and all of its nested configuration as deeply-typed inputs, targeting
hashicorp/azurerm ~> 4.0.
- π₯οΈ Provisions a single
azurerm_orchestrated_virtual_machine_scale_set(Flexible orchestration mode) namedthis. - π Models the full networking shape β network interfaces, IP configurations, backend-pool membership, and optional per-configuration public IPs β as deeply-typed inputs.
- π½ Models storage (OS disk with optional ephemeral/CMK settings, and any number of data disks) and image selection (marketplace reference or image ID).
- π Models the OS profile: Linux SSH-key credentials or Windows credentials, plus certificates delivered from Key Vault.
- πͺͺ Attaches user-assigned managed identities for keyless access to Azure resources.
- β»οΈ Exposes lifecycle controls β spread across availability zones and fault domains, Spot priority, rolling/automatic upgrades, automatic instance repair, and termination notification.
π‘ Why it matters: Flexible orchestration decouples the scale set from a single VM SKU family and gives you fault-domain and availability-zone placement with per-instance visibility. This module keeps that power behind a type-safe contract and a locked-down default posture, so the common mistakes β a public instance, a password-authenticated Linux node, an unencrypted host β require the caller to type the opt-out.
If this module saves you time, please consider supporting its continued development:
- β Star the repository on GitHub
- π€ Connect on LinkedIn: linkedin.com/in/microsoftexpert
- β Buy me a coffee: buymeacoffee.com/microsoftexpert
flowchart TB
rg["terraform-azurerm-resource-group"]
vnet["terraform-azurerm-virtual-network"]
lb["terraform-azurerm-load-balancer"]
uai["terraform-azurerm-user-assigned-identity"]
this["terraform-azurerm-orchestrated-virtual-machine-scale-set"]
vmss["azurerm_orchestrated_virtual_machine_scale_set"]
diag["terraform-azurerm-monitor-diagnostic-setting"]
rg -->|"resource_group_name / location"| this
vnet -->|"subnet_id"| this
lb -->|"backend address pool id"| this
uai -->|"identity_ids"| this
this -->|"creates"| vmss
vmss -->|"id"| diag
classDef module fill:#0078D4,stroke:#004578,color:#fff;
classDef keystone fill:#004578,stroke:#002a4d,color:#fff;
classDef sibling fill:#eef3f8,stroke:#b8c4d0,color:#1b1b1b;
class this module;
class vmss keystone;
class rg,vnet,lb,uai,diag sibling;
flowchart TB
subgraph inputs["Inputs"]
id_in["name / sku_name / instances / platform_fault_domain_count"]
net_in["network_interface[] + ip_configuration[]"]
disk_in["os_disk + source_image_reference + data_disk[]"]
sec_in["os_profile (SSH keys) / identity / encryption_at_host_enabled"]
life_in["zones / priority / upgrade_mode / automatic_instance_repair"]
end
vmss["azurerm_orchestrated_virtual_machine_scale_set (this)"]
subgraph blocks["Rendered nested blocks"]
b1["os_disk (+ diff_disk_settings)"]
b2["network_interface -> ip_configuration -> public_ip_address"]
b3["os_profile -> linux/windows_configuration"]
b4["identity (UserAssigned only)"]
b5["extension / boot_diagnostics / termination_notification / rolling_upgrade_policy"]
end
subgraph outputs["Outputs"]
o_id["id"]
o_name["name"]
o_uid["unique_id"]
end
id_in --> vmss
net_in --> vmss
disk_in --> vmss
sec_in --> vmss
life_in --> vmss
vmss --> b1
vmss --> b2
vmss --> b3
vmss --> b4
vmss --> b5
vmss --> o_id
vmss --> o_name
vmss --> o_uid
classDef keystone fill:#004578,stroke:#002a4d,color:#fff;
classDef sibling fill:#eef3f8,stroke:#b8c4d0,color:#1b1b1b;
class vmss keystone;
class id_in,net_in,disk_in,sec_in,life_in,b1,b2,b3,b4,b5,o_id,o_name,o_uid sibling;
Resource inventory: one keystone azurerm_orchestrated_virtual_machine_scale_set.this, rendered through 31 nested block instances across 29 block types (os_disk, diff_disk_settings, source_image_reference, data_disk, os_profile, linux_configuration, admin_ssh_key, secret, certificate, windows_configuration, winrm_listener, additional_unattend_content, identity, network_interface, ip_configuration, public_ip_address, ip_tag, extension, protected_settings_from_key_vault, additional_capabilities, automatic_instance_repair, boot_diagnostics, termination_notification, rolling_upgrade_policy, plan, priority_mix, sku_profile, virtual_machine_size, timeouts).
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
| azurerm provider | ~> 4.0 |
| Provider block | None β the caller configures provider "azurerm" { features {} }, auth, and subscription |
Schema notes that bite (verified against the live provider schema):
- Changing any of
name,resource_group_name,location, orplatform_fault_domain_countforces replacement. - The force-new set, verified field by field against the resource's schema:
name,resource_group_name,location,platform_fault_domain_count,priority,eviction_policy,upgrade_mode,zone_balance,extension_operations_enabled,capacity_reservation_group_idandproximity_placement_group_id.locationandresource_group_nameget it from the provider's shared schema helpers, so they appear in neither aForceNewgrep of the resource nor the binary schema. sku_nameis NOT force-new β resizing the fleet is an in-place update. Neither aresingle_placement_group,encryption_at_host_enabled,source_image_id,license_typeornetwork_api_version.zonesis force-new in ONE DIRECTION ONLY. AForceNewIfChangewalks the old list and fires if any entry is missing from the new one, so adding a zone is an in-place update and removing one replaces every VM in the set. The rule is per removed element β not "the list changed", and not "the list became empty" β and nothing in the schema shows it.sku_profileis force-new conditionally: removing it while changingsku_nameaway fromMixreplaces the set.priority,eviction_policy, andupgrade_modeare force-new; you cannot flip a Regular set to Spot in place.encryption_at_host_enabledupdates in place. It does require a subscription feature registration and a supporting VM size, neither of which Terraform can see, so the failure mode is an apply error rather than a rebuild.proximity_placement_group_id,capacity_reservation_group_idandextension_operations_enabledare force-new.network_api_version,source_image_idandlicense_typeare not.- The
planandrolling_upgrade_policyblocks are force-new as WHOLE BLOCKS. Editing any field inside either β a batch percentage, a pause time, a plan product name β destroys and recreates the scale set and every instance in it. The rule sits in each block's schema helper rather than in this resource's schema map, so it appears in no walk of the top-level arguments; the plan is the only warning. Note that underupgrade_mode = "Automatic"the rolling policy has no effect on behaviour and still rebuilds the fleet if you edit it. - Twelve cross-field rules bind this resource, and eight of them are not in a schema entry at all β they live in a
CustomizeDiffor in the Create function, so none of the twelve appears in the binary provider schema.sku_profileandsku_name = "Mix"require each other in both directions;rolling_upgrade_policyandupgrade_mode = "Rolling"likewise, while"Manual"refuses the policy; arankneedsallocation_strategy = "Prioritized";vm_sizesandvirtual_machine_sizeconflict and exactly one is required;auxiliary_modeandauxiliary_skurequire each other;capacity_reservation_group_idconflicts withproximity_placement_group_id; andsource_image_idconflicts withsource_image_reference. This module mirrors every one of them. - π΄
auxiliary_modeandauxiliary_skuare refused at the DEFAULTnetwork_api_version. The provider rejects them whenever that value is2020-11-01, which is exactly what you get by not setting the argument β so the obvious configuration is unplannable, and the provider's error names the API version rather than the field being edited. Set a later version. - π΄ Two rules are checked in the Create function rather than in a
CustomizeDiff, so unmirrored they fail at apply, after everything earlier in the apply has been built:zone_balance = truerequires a non-emptyzones, andpriority_mixrequirespriority = "Spot". Both halves of each are module inputs, so this module catches them at plan. sku_profileVM size names are validated more tightly thansku_name: each must beginStandard_and belong to the A, B, D, E or F families β andDCandECsizes are refused even though they begin with D and E, because the provider tests those two-letter prefixes first.Standard_EC8ads_v5is rejected;Standard_E8ds_v5is accepted.platform_fault_domain_countcarries no provider validation at all, and its legal range is region-dependent. A value of 1 means maximum spreading, which is the opposite of what the number reads like and is the value to pair with zones.- Within
network_interface, changingnameis force-new; withinos_profile,custom_data,admin_username,computer_name_prefix, andprovision_vm_agentare force-new. - This resource supports user-assigned managed identities only (
identity.type = "UserAssigned"); there is no system-assigned identity and noprincipal_id.
Virtual Machine Contributoron the target resource group (create/update the scale set), or a custom role grantingMicrosoft.Compute/virtualMachineScaleSets/*at the resource-group scope.Network Contributor(orMicrosoft.Network/virtualNetworks/subnets/join/actionplus the network-interface write actions) where the scale set joins an existing subnet, load-balancer backend pool, or application-gateway backend pool.Managed Identity Operatoron any user-assigned identity supplied throughidentity.identity_ids.
- An existing resource group in a supported US Azure region (examples use
eastus/eastus2/westus2/centralus). - The
Microsoft.Computeresource provider registered on the subscription. - For
encryption_at_host_enabled = true(the default), theEncryptionAtHostfeature registered on the subscription. - Any referenced subnet, backend pool, network security group, user-assigned identity, and disk encryption set already exist β their
ids are inputs. - The caller configures the
provider "azurerm" { features {} }block, auth, and subscription.
terraform-azurerm-orchestrated-virtual-machine-scale-set/
βββ providers.tf # required_version >= 1.12.0; azurerm ~> 4.0; no provider block
βββ variables.tf # deeply-typed object() schemas + tags/timeouts tail
βββ main.tf # keystone `this`; dynamic blocks + try() for every optional field
βββ outputs.tf # id first, then name, unique_id, identity type/ids
βββ README.md # this document
βββ SCOPE.md # the cross-module contract
βββ LICENSE # MIT, Copyright (c) 2026 Casey Wood
βββ .gitignore # the canonical library ignore set
provider "azurerm" {
features {}
}
module "orchestrated_vmss" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-orchestrated-virtual-machine-scale-set.git?ref=v1.0.0"
name = "vmss-web-eastus2"
resource_group_name = "rg-platform-eastus2"
location = "eastus2"
platform_fault_domain_count = 1
sku_name = "Standard_D2s_v5"
instances = 3
source_image_reference = {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
os_disk = {
caching = "ReadWrite"
storage_account_type = "Premium_LRS"
}
os_profile = {
linux_configuration = {
admin_username = "azureuser"
admin_ssh_key = [{
username = "azureuser"
public_key = file("~/.ssh/id_rsa.pub")
}]
}
}
network_interface = [{
name = "nic-primary"
primary = true
ip_configuration = [{
name = "ipconfig1"
primary = true
subnet_id = "/subscriptions/.../subnets/snet-web"
}]
}]
tags = {
environment = "prod"
workload = "web"
}
}βΉοΈ The caller owns the provider, its authentication, and the mandatory
features {}block. The module never declares aproviderblock.
| Input | Type | Source module |
|---|---|---|
resource_group_name |
string |
terraform-azurerm-resource-group (name) |
location |
string |
caller / terraform-azurerm-resource-group (location) |
network_interface[*].ip_configuration[*].subnet_id |
string |
terraform-azurerm-virtual-network (subnet_ids) |
network_interface[*].ip_configuration[*].load_balancer_backend_address_pool_ids |
list(string) |
terraform-azurerm-load-balancer (backend pool id) |
network_interface[*].network_security_group_id |
string |
terraform-azurerm-network-security-group (id) |
identity.identity_ids |
set(string) |
terraform-azurerm-user-assigned-identity (id) |
os_disk.disk_encryption_set_id |
string |
disk-encryption-set (customer-managed key) |
| Output | Description | Consumed by |
|---|---|---|
id |
Scale set Resource ID (first) | diagnostics, RBAC, autoscale, downstream modules |
name |
Scale set name | diagnostics / tagging |
unique_id |
Immutable unique ID stable across the resource's lifetime | correlation / tagging |
identity_type |
Managed identity type in effect (or null) | documentation / RBAC wiring |
identity_ids |
User-assigned identity resource IDs attached (or null) | role-assignment wiring |
1 Β· Minimal Linux SSH-key scale set
module "vmss" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-orchestrated-virtual-machine-scale-set.git?ref=v1.0.0"
name = "vmss-min-eastus2"
resource_group_name = "rg-platform-eastus2"
location = "eastus2"
platform_fault_domain_count = 1
sku_name = "Standard_D2s_v5"
instances = 2
source_image_reference = {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
os_disk = {
caching = "ReadWrite"
storage_account_type = "Premium_LRS"
}
os_profile = {
linux_configuration = {
admin_username = "azureuser"
admin_ssh_key = [{
username = "azureuser"
public_key = file("~/.ssh/id_rsa.pub")
}]
}
}
network_interface = [{
name = "nic-primary"
primary = true
ip_configuration = [{
name = "ipconfig1"
primary = true
subnet_id = var.subnet_id
}]
}]
}π The empty security path applies:
disable_password_authenticationistrue,encryption_at_host_enabledistrue, and no public IP is attached.
2 Β· Zone-redundant across three availability zones
module "vmss" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-orchestrated-virtual-machine-scale-set.git?ref=v1.0.0"
name = "vmss-zr-eastus2"
resource_group_name = var.resource_group_name
location = "eastus2"
platform_fault_domain_count = 1
sku_name = "Standard_D4s_v5"
instances = 6
zones = ["1", "2", "3"]
zone_balance = true
source_image_reference = {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
os_disk = { caching = "ReadWrite", storage_account_type = "Premium_ZRS" }
os_profile = { linux_configuration = { admin_username = "azureuser", admin_ssh_key = [{ username = "azureuser", public_key = file("~/.ssh/id_rsa.pub") }] } }
network_interface = [{
name = "nic-primary"
primary = true
ip_configuration = [{ name = "ipconfig1", primary = true, subnet_id = var.subnet_id }]
}]
}
β οΈ zone_balanceis force-new.zonesis force-new only when a zone is removed β adding one is an in-place update, so a set can grow across zones but not shrink without a rebuild. Pair zone spread with a zone-redundant OS disk (Premium_ZRS), and notezone_balance = trueis refused unlesszonesis non-empty.
3 Β· Spot instances (eviction, priority, max bid price)
module "vmss" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-orchestrated-virtual-machine-scale-set.git?ref=v1.0.0"
name = "vmss-spot-eastus2"
resource_group_name = var.resource_group_name
location = "eastus2"
platform_fault_domain_count = 1
sku_name = "Standard_D2s_v5"
instances = 10
priority = "Spot"
eviction_policy = "Delete"
max_bid_price = 0.25
source_image_reference = {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
os_disk = { caching = "ReadWrite", storage_account_type = "Standard_LRS" }
os_profile = { linux_configuration = { admin_username = "azureuser", admin_ssh_key = [{ username = "azureuser", public_key = file("~/.ssh/id_rsa.pub") }] } }
network_interface = [{
name = "nic-primary"
primary = true
ip_configuration = [{ name = "ipconfig1", primary = true, subnet_id = var.subnet_id }]
}]
}π‘
max_bid_price = -1(the default) never evicts for price and bills at the on-demand rate. Set a positive cap only for cost-bounded, interruptible batch workloads.
4 Β· Regular / Spot priority mix
module "vmss" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-orchestrated-virtual-machine-scale-set.git?ref=v1.0.0"
name = "vmss-mix-eastus2"
resource_group_name = var.resource_group_name
location = "eastus2"
platform_fault_domain_count = 1
sku_name = "Standard_D2s_v5"
instances = 20
priority = "Spot"
eviction_policy = "Delete"
priority_mix = {
base_regular_count = 4
regular_percentage_above_base = 50
}
source_image_reference = { publisher = "Canonical", offer = "0001-com-ubuntu-server-jammy", sku = "22_04-lts-gen2", version = "latest" }
os_disk = { caching = "ReadWrite", storage_account_type = "Standard_LRS" }
os_profile = { linux_configuration = { admin_username = "azureuser", admin_ssh_key = [{ username = "azureuser", public_key = file("~/.ssh/id_rsa.pub") }] } }
network_interface = [{ name = "nic-primary", primary = true, ip_configuration = [{ name = "ipconfig1", primary = true, subnet_id = var.subnet_id }] }]
}βΉοΈ
priority_mixkeeps a guaranteed floor of Regular instances while filling the rest with Spot; it applies only whenpriority = "Spot".
5 Β· Customer-managed key (CMK) on the OS disk
module "vmss" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-orchestrated-virtual-machine-scale-set.git?ref=v1.0.0"
name = "vmss-cmk-eastus2"
resource_group_name = var.resource_group_name
location = "eastus2"
platform_fault_domain_count = 1
sku_name = "Standard_D2s_v5"
instances = 3
source_image_reference = { publisher = "Canonical", offer = "0001-com-ubuntu-server-jammy", sku = "22_04-lts-gen2", version = "latest" }
os_disk = {
caching = "ReadWrite"
storage_account_type = "Premium_LRS"
disk_encryption_set_id = var.disk_encryption_set_id
}
os_profile = { linux_configuration = { admin_username = "azureuser", admin_ssh_key = [{ username = "azureuser", public_key = file("~/.ssh/id_rsa.pub") }] } }
network_interface = [{ name = "nic-primary", primary = true, ip_configuration = [{ name = "ipconfig1", primary = true, subnet_id = var.subnet_id }] }]
}π Encryption at rest is always on; supplying
disk_encryption_set_idupgrades the OS disk to a customer-managed key. Host-level encryption (encryption_at_host_enabled) remains on by default.
6 Β· Data disks (including UltraSSD)
module "vmss" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-orchestrated-virtual-machine-scale-set.git?ref=v1.0.0"
name = "vmss-data-eastus2"
resource_group_name = var.resource_group_name
location = "eastus2"
platform_fault_domain_count = 1
sku_name = "Standard_D4s_v5"
instances = 3
additional_capabilities = { ultra_ssd_enabled = true }
data_disk = [
{
caching = "ReadOnly"
storage_account_type = "Premium_LRS"
lun = 0
disk_size_gb = 256
create_option = "Empty"
},
{
caching = "None"
storage_account_type = "UltraSSD_LRS"
lun = 1
disk_size_gb = 1024
create_option = "Empty"
ultra_ssd_disk_iops_read_write = 5000
ultra_ssd_disk_mbps_read_write = 200
}
]
source_image_reference = { publisher = "Canonical", offer = "0001-com-ubuntu-server-jammy", sku = "22_04-lts-gen2", version = "latest" }
os_disk = { caching = "ReadWrite", storage_account_type = "Premium_LRS" }
os_profile = { linux_configuration = { admin_username = "azureuser", admin_ssh_key = [{ username = "azureuser", public_key = file("~/.ssh/id_rsa.pub") }] } }
network_interface = [{ name = "nic-primary", primary = true, ip_configuration = [{ name = "ipconfig1", primary = true, subnet_id = var.subnet_id }] }]
}
β οΈ UltraSSD_LRSdata disks requireadditional_capabilities.ultra_ssd_enabled = trueand an AZ-enabled region/SKU combination.
7 Β· Behind a load-balancer backend pool with automatic instance repair
module "vmss" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-orchestrated-virtual-machine-scale-set.git?ref=v1.0.0"
name = "vmss-lb-eastus2"
resource_group_name = var.resource_group_name
location = "eastus2"
platform_fault_domain_count = 1
sku_name = "Standard_D2s_v5"
instances = 4
automatic_instance_repair = {
enabled = true
action = "Replace"
grace_period = "PT30M"
}
source_image_reference = { publisher = "Canonical", offer = "0001-com-ubuntu-server-jammy", sku = "22_04-lts-gen2", version = "latest" }
os_disk = { caching = "ReadWrite", storage_account_type = "Premium_LRS" }
os_profile = { linux_configuration = { admin_username = "azureuser", admin_ssh_key = [{ username = "azureuser", public_key = file("~/.ssh/id_rsa.pub") }] } }
network_interface = [{
name = "nic-primary"
primary = true
ip_configuration = [{
name = "ipconfig1"
primary = true
subnet_id = var.subnet_id
load_balancer_backend_address_pool_ids = [var.lb_backend_pool_id]
}]
}]
}π‘ Automatic instance repair needs a health signal. A load-balancer probe on the backend pool (or an application-health extension) supplies it; without one, repair stays inert.
8 Β· Application Gateway backend with accelerated networking
module "vmss" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-orchestrated-virtual-machine-scale-set.git?ref=v1.0.0"
name = "vmss-agw-eastus2"
resource_group_name = var.resource_group_name
location = "eastus2"
platform_fault_domain_count = 1
sku_name = "Standard_D4s_v5"
instances = 4
source_image_reference = { publisher = "Canonical", offer = "0001-com-ubuntu-server-jammy", sku = "22_04-lts-gen2", version = "latest" }
os_disk = { caching = "ReadWrite", storage_account_type = "Premium_LRS" }
os_profile = { linux_configuration = { admin_username = "azureuser", admin_ssh_key = [{ username = "azureuser", public_key = file("~/.ssh/id_rsa.pub") }] } }
network_interface = [{
name = "nic-primary"
primary = true
enable_accelerated_networking = true
network_security_group_id = var.nsg_id
ip_configuration = [{
name = "ipconfig1"
primary = true
subnet_id = var.subnet_id
application_gateway_backend_address_pool_ids = [var.agw_backend_pool_id]
}]
}]
}βΉοΈ Accelerated networking requires a supported VM SKU; the association to the application-gateway backend pool is expressed on the primary
ip_configuration.
9 Β· User-assigned managed identity (keyless access)
module "vmss" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-orchestrated-virtual-machine-scale-set.git?ref=v1.0.0"
name = "vmss-mi-eastus2"
resource_group_name = var.resource_group_name
location = "eastus2"
platform_fault_domain_count = 1
sku_name = "Standard_D2s_v5"
instances = 2
identity = {
type = "UserAssigned"
identity_ids = [var.user_assigned_identity_id]
}
source_image_reference = { publisher = "Canonical", offer = "0001-com-ubuntu-server-jammy", sku = "22_04-lts-gen2", version = "latest" }
os_disk = { caching = "ReadWrite", storage_account_type = "Premium_LRS" }
os_profile = { linux_configuration = { admin_username = "azureuser", admin_ssh_key = [{ username = "azureuser", public_key = file("~/.ssh/id_rsa.pub") }] } }
network_interface = [{ name = "nic-primary", primary = true, ip_configuration = [{ name = "ipconfig1", primary = true, subnet_id = var.subnet_id }] }]
}π This resource supports user-assigned identities only. Prefer a managed identity over embedded credentials, and grant it least-privilege roles at the smallest scope that works.
10 Β· Rolling upgrades
module "vmss" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-orchestrated-virtual-machine-scale-set.git?ref=v1.0.0"
name = "vmss-rolling-eastus2"
resource_group_name = var.resource_group_name
location = "eastus2"
platform_fault_domain_count = 1
sku_name = "Standard_D2s_v5"
instances = 6
upgrade_mode = "Rolling"
rolling_upgrade_policy = {
max_batch_instance_percent = 20
max_unhealthy_instance_percent = 20
max_unhealthy_upgraded_instance_percent = 20
pause_time_between_batches = "PT30S"
cross_zone_upgrades_enabled = true
}
source_image_reference = { publisher = "Canonical", offer = "0001-com-ubuntu-server-jammy", sku = "22_04-lts-gen2", version = "latest" }
os_disk = { caching = "ReadWrite", storage_account_type = "Premium_LRS" }
os_profile = { linux_configuration = { admin_username = "azureuser", admin_ssh_key = [{ username = "azureuser", public_key = file("~/.ssh/id_rsa.pub") }] } }
network_interface = [{ name = "nic-primary", primary = true, ip_configuration = [{ name = "ipconfig1", primary = true, subnet_id = var.subnet_id }] }]
}
β οΈ upgrade_modeis force-new, so choose it at creation. Rolling upgrades require an application health signal to gate each batch.
11 Β· VM extension delivered from Key Vault (no inline secret)
module "vmss" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-orchestrated-virtual-machine-scale-set.git?ref=v1.0.0"
name = "vmss-ext-eastus2"
resource_group_name = var.resource_group_name
location = "eastus2"
platform_fault_domain_count = 1
sku_name = "Standard_D2s_v5"
instances = 3
extension = [{
name = "AzureMonitorLinuxAgent"
publisher = "Microsoft.Azure.Monitor"
type = "AzureMonitorLinuxAgent"
type_handler_version = "1.0"
auto_upgrade_minor_version_enabled = true
protected_settings_from_key_vault = {
secret_url = var.kv_secret_url
source_vault_id = var.key_vault_id
}
}]
source_image_reference = { publisher = "Canonical", offer = "0001-com-ubuntu-server-jammy", sku = "22_04-lts-gen2", version = "latest" }
os_disk = { caching = "ReadWrite", storage_account_type = "Premium_LRS" }
os_profile = { linux_configuration = { admin_username = "azureuser", admin_ssh_key = [{ username = "azureuser", public_key = file("~/.ssh/id_rsa.pub") }] } }
network_interface = [{ name = "nic-primary", primary = true, ip_configuration = [{ name = "ipconfig1", primary = true, subnet_id = var.subnet_id }] }]
}π Prefer
protected_settings_from_key_vaultover inlineprotected_settings, so no secret is expressed in configuration. Inlineprotected_settingsis redacted by the provider but still lives in state.
12 Β· Windows scale set (password out of band)
module "vmss" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-orchestrated-virtual-machine-scale-set.git?ref=v1.0.0"
name = "vmsswin-eastus2"
resource_group_name = var.resource_group_name
location = "eastus2"
platform_fault_domain_count = 1
sku_name = "Standard_D2s_v5"
instances = 2
license_type = "Windows_Server"
source_image_reference = {
publisher = "MicrosoftWindowsServer"
offer = "WindowsServer"
sku = "2022-datacenter-azure-edition"
version = "latest"
}
os_disk = { caching = "ReadWrite", storage_account_type = "Premium_LRS" }
os_profile = {
windows_configuration = {
admin_username = "azureadmin"
admin_password = var.windows_admin_password
enable_automatic_updates = true
}
}
network_interface = [{ name = "nic-primary", primary = true, ip_configuration = [{ name = "ipconfig1", primary = true, subnet_id = var.subnet_id }] }]
}
β οΈ Windows requiresadmin_password. Provision it out of band (a Key Vault reference passed asvar.windows_admin_password); the provider marks it sensitive so it is redacted from plan output. Never commit a plaintext value.
13 Β· Boot diagnostics + termination notification + fault-domain isolation
module "vmss" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-orchestrated-virtual-machine-scale-set.git?ref=v1.0.0"
name = "vmss-ops-eastus2"
resource_group_name = var.resource_group_name
location = "eastus2"
platform_fault_domain_count = 3
sku_name = "Standard_D2s_v5"
instances = 6
boot_diagnostics = { storage_account_uri = null } # managed storage account
termination_notification = {
enabled = true
timeout = "PT5M"
}
source_image_reference = { publisher = "Canonical", offer = "0001-com-ubuntu-server-jammy", sku = "22_04-lts-gen2", version = "latest" }
os_disk = { caching = "ReadWrite", storage_account_type = "Premium_LRS" }
os_profile = { linux_configuration = { admin_username = "azureuser", admin_ssh_key = [{ username = "azureuser", public_key = file("~/.ssh/id_rsa.pub") }] } }
network_interface = [{ name = "nic-primary", primary = true, ip_configuration = [{ name = "ipconfig1", primary = true, subnet_id = var.subnet_id }] }]
}π‘
storage_account_uri = nullenables a platform-managed boot-diagnostics store.platform_fault_domain_countis force-new, so set the fault-domain spread at creation.
14 Β· Multiple NICs, DNS, and a deliberate public IP
module "vmss" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-orchestrated-virtual-machine-scale-set.git?ref=v1.0.0"
name = "vmss-multinic-eastus2"
resource_group_name = var.resource_group_name
location = "eastus2"
platform_fault_domain_count = 1
sku_name = "Standard_D4s_v5"
instances = 3
source_image_reference = { publisher = "Canonical", offer = "0001-com-ubuntu-server-jammy", sku = "22_04-lts-gen2", version = "latest" }
os_disk = { caching = "ReadWrite", storage_account_type = "Premium_LRS" }
os_profile = { linux_configuration = { admin_username = "azureuser", admin_ssh_key = [{ username = "azureuser", public_key = file("~/.ssh/id_rsa.pub") }] } }
network_interface = [
{
name = "nic-frontend"
primary = true
dns_servers = ["10.0.0.4", "10.0.0.5"]
ip_configuration = [{
name = "ipconfig-public"
primary = true
subnet_id = var.subnet_id
public_ip_address = [{
name = "pip-instance"
idle_timeout_in_minutes = 15
}]
}]
},
{
name = "nic-backend"
primary = false
ip_configuration = [{ name = "ipconfig-internal", primary = true, subnet_id = var.backend_subnet_id }]
}
]
}
β οΈ Attaching apublic_ip_addressis the explicit opt-out from the private-by-default posture. Only onenetwork_interfaceand oneip_configurationper NIC may beprimary = true.
15 Β· ποΈ End-to-end composition (resource group + VNet subnet + load balancer + this)
provider "azurerm" {
features {}
}
module "resource_group" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-web-eastus2"
location = "eastus2"
}
module "network" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network.git?ref=v1.0.0"
name = "vnet-web-eastus2"
resource_group_name = module.resource_group.name
location = module.resource_group.location
address_space = ["10.20.0.0/16"]
subnets = {
web = {
name = "snet-web"
address_prefixes = ["10.20.1.0/24"]
}
}
}
module "load_balancer" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-load-balancer.git?ref=v1.0.0"
frontend_ip_configurations = { public = { name = "public-fe", public_ip_address_id = azurerm_public_ip.scale.id } }
name = "lb-web-eastus2"
resource_group_name = module.resource_group.name
location = module.resource_group.location
# ... frontend, backend pool, and health probe configuration ...
}
module "orchestrated_vmss" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-orchestrated-virtual-machine-scale-set.git?ref=v1.0.0"
name = "vmss-web-eastus2"
resource_group_name = module.resource_group.name
location = module.resource_group.location
platform_fault_domain_count = 1
sku_name = "Standard_D2s_v5"
instances = 4
zones = ["1", "2", "3"]
source_image_reference = {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
os_disk = { caching = "ReadWrite", storage_account_type = "Premium_ZRS" }
os_profile = {
linux_configuration = {
admin_username = "azureuser"
admin_ssh_key = [{ username = "azureuser", public_key = file("~/.ssh/id_rsa.pub") }]
}
}
automatic_instance_repair = { enabled = true, action = "Replace", grace_period = "PT30M" }
network_interface = [{
name = "nic-primary"
primary = true
ip_configuration = [{
name = "ipconfig1"
primary = true
subnet_id = module.network.subnet_ids["web"]
load_balancer_backend_address_pool_ids = [module.load_balancer.backend_pool_ids]
}]
}]
tags = { environment = "prod", workload = "web" }
}π‘ Each sibling's output feeds this module's input: the resource group's
name/location, the VNet module'ssubnet_ids["web"], and the load balancer's backend pool id. Pin every?ref=to a released tag.
Required: name, resource_group_name, location, platform_fault_domain_count.
Common optional: sku_name, instances, source_image_reference / source_image_id, os_disk, os_profile, network_interface, identity.
Placement & lifecycle: zones, zone_balance, single_placement_group, priority, eviction_policy, max_bid_price, priority_mix, upgrade_mode, rolling_upgrade_policy, automatic_instance_repair, termination_notification.
Security & platform: encryption_at_host_enabled (default true), license_type, proximity_placement_group_id, capacity_reservation_group_id, network_api_version, user_data_base64 (sensitive), extension_operations_enabled, extensions_time_budget.
Storage & extras: data_disk, additional_capabilities, boot_diagnostics, extension, plan, sku_profile.
Universal tail: tags, timeouts.
Full object() schemas
# Identity and required fields
name = string
resource_group_name = string
location = string
platform_fault_domain_count = number
# Sizing & scheduling
sku_name = optional(string)
instances = optional(number)
priority = optional(string, "Regular") # Regular | Spot
eviction_policy = optional(string) # Deallocate | Delete (Spot only)
max_bid_price = optional(number, -1)
upgrade_mode = optional(string, "Manual") # Automatic | Manual | Rolling
zones = optional(set(string), [])
zone_balance = optional(bool, false)
single_placement_group = optional(bool)
encryption_at_host_enabled = optional(bool, true)
license_type = optional(string)
proximity_placement_group_id = optional(string)
capacity_reservation_group_id = optional(string)
network_api_version = optional(string)
source_image_id = optional(string)
user_data_base64 = optional(string) # sensitive
extension_operations_enabled = optional(bool)
extensions_time_budget = optional(string)
# os_disk
os_disk = optional(object({
caching = optional(string, "ReadWrite") # None | ReadOnly | ReadWrite
storage_account_type = optional(string, "Premium_LRS") # Standard_LRS | StandardSSD_LRS | StandardSSD_ZRS | Premium_LRS | Premium_ZRS
disk_size_gb = optional(number)
disk_encryption_set_id = optional(string)
write_accelerator_enabled = optional(bool, false)
diff_disk_settings = optional(object({ option = string, placement = optional(string) }))
}))
# source_image_reference (or use source_image_id)
source_image_reference = optional(object({
publisher = string
offer = string
sku = string
version = string
}))
# data_disk
data_disk = optional(list(object({
caching = string
storage_account_type = string
lun = optional(number)
disk_size_gb = optional(number)
create_option = optional(string)
disk_encryption_set_id = optional(string)
write_accelerator_enabled = optional(bool, false)
ultra_ssd_disk_iops_read_write = optional(number)
ultra_ssd_disk_mbps_read_write = optional(number)
})), [])
# os_profile (supply exactly one of linux_configuration / windows_configuration)
os_profile = optional(object({
custom_data = optional(string)
linux_configuration = optional(object({
admin_username = string
admin_password = optional(string)
disable_password_authentication = optional(bool, true)
computer_name_prefix = optional(string)
provision_vm_agent = optional(bool, true)
patch_assessment_mode = optional(string)
patch_mode = optional(string)
admin_ssh_key = optional(list(object({ username = string, public_key = string })), [])
secret = optional(list(object({ key_vault_id = string, certificate = list(object({ url = string })) })), [])
}))
windows_configuration = optional(object({
admin_username = string
admin_password = string
computer_name_prefix = optional(string)
enable_automatic_updates = optional(bool, true)
hotpatching_enabled = optional(bool)
provision_vm_agent = optional(bool, true)
timezone = optional(string)
patch_assessment_mode = optional(string)
patch_mode = optional(string)
winrm_listener = optional(list(object({ protocol = string, certificate_url = optional(string) })), [])
secret = optional(list(object({ key_vault_id = string, certificate = list(object({ store = string, url = string })) })), [])
additional_unattend_content = optional(list(object({ setting = string, content = string })), [])
}))
}))
# identity (UserAssigned only)
identity = optional(object({
type = optional(string, "UserAssigned")
identity_ids = set(string)
}))
# network_interface
network_interface = optional(list(object({
name = string
primary = optional(bool)
enable_accelerated_networking = optional(bool, false)
enable_ip_forwarding = optional(bool, false)
dns_servers = optional(list(string), [])
network_security_group_id = optional(string)
auxiliary_mode = optional(string)
auxiliary_sku = optional(string)
ip_configuration = list(object({
name = string
primary = optional(bool)
version = optional(string, "IPv4")
subnet_id = optional(string)
application_gateway_backend_address_pool_ids = optional(list(string), [])
application_security_group_ids = optional(list(string), [])
load_balancer_backend_address_pool_ids = optional(list(string), [])
public_ip_address = optional(list(object({
name = string
domain_name_label = optional(string)
idle_timeout_in_minutes = optional(number)
public_ip_prefix_id = optional(string)
sku_name = optional(string)
version = optional(string, "IPv4")
ip_tag = optional(list(object({ tag = string, type = string })), [])
})), [])
}))
})), [])
# extension
extension = optional(list(object({
name = string
publisher = string
type = string
type_handler_version = string
auto_upgrade_minor_version_enabled = optional(bool)
failure_suppression_enabled = optional(bool)
force_extension_execution_on_change = optional(string)
extensions_to_provision_after_vm_creation = optional(list(string), [])
settings = optional(string)
protected_settings = optional(string)
protected_settings_from_key_vault = optional(object({ secret_url = string, source_vault_id = string }))
})), [])
# capabilities / health / lifecycle
additional_capabilities = optional(object({ ultra_ssd_enabled = optional(bool, false) }))
automatic_instance_repair = optional(object({ enabled = bool, action = optional(string), grace_period = optional(string) }))
boot_diagnostics = optional(object({ storage_account_uri = optional(string) }))
termination_notification = optional(object({ enabled = bool, timeout = optional(string) }))
rolling_upgrade_policy = optional(object({
max_batch_instance_percent = number
max_unhealthy_instance_percent = number
max_unhealthy_upgraded_instance_percent = number
pause_time_between_batches = string
cross_zone_upgrades_enabled = optional(bool)
maximum_surge_instances_enabled = optional(bool)
prioritize_unhealthy_instances_enabled = optional(bool)
}))
plan = optional(object({ name = string, product = string, publisher = string }))
priority_mix = optional(object({ base_regular_count = optional(number), regular_percentage_above_base = optional(number) }))
sku_profile = optional(object({
allocation_strategy = string
vm_sizes = optional(set(string))
virtual_machine_size = optional(list(object({ name = string, rank = optional(number) })), [])
}))
# universal tail
tags = optional(map(string), {})
timeouts = optional(object({ create = optional(string), read = optional(string), update = optional(string), delete = optional(string) }))| Output | Description | Notes |
|---|---|---|
id |
The Azure Resource ID of the scale set | emitted first |
name |
The scale set name | |
location |
Azure region, in the canonical form Azure uses. | Read from the resource, not var.location. |
unique_id |
Immutable unique ID, stable across the resource's lifetime | |
identity_type |
Managed identity type in effect | null when no identity configured |
identity_ids |
User-assigned identity resource IDs attached | null when no identity configured |
resource_group_name |
Containing resource group | force-new |
sku_name |
The VM size, or Mix |
not force-new β resizing is in place |
instances |
The instance count | Computed; reports Azure's value when the input is omitted |
platform_fault_domain_count |
Fault-domain spread | force-new; 1 means maximum spreading |
zones |
Availability zones spanned | adding is in place, removing replaces |
priority |
Regular or Spot | force-new |
network_api_version |
Networking API contract | the default refuses the auxiliary fields |
upgrade_mode |
Manual, Automatic or Rolling | force-new |
instances_are_evictable |
Derived: priority == "Spot" |
Azure may reclaim these VMs at 30 seconds' notice |
spot_eviction_is_price_triggered |
Derived: Spot and a bid other than -1 | adds a second eviction cause β a price movement can empty the set |
model_changes_do_not_reach_running_instances |
Derived: upgrade_mode == "Manual" |
a patch delivered this way reaches new instances only, indefinitely |
automatic_repair_cannot_work |
Derived: repair configured while extension operations are disabled | the feature is accepted, planned and inert |
removing_a_zone_replaces_the_scale_set |
Always true |
the one-directional force-new nothing in the schema shows |
uses_the_deprecated_vm_sizes_argument |
Derived | will not plan against provider 5.0 |
user_data_is_readable_by_every_process_on_the_instance |
Derived | user data is not a place for a credential |
identity_grants_no_permissions |
Always true |
attaching an identity is not authorising it |
No secret is emitted.
admin_password,custom_data,user_data_base64, and extensionprotected_settingsare redacted by the provider.
- One keystone, thin renderer. The module owns exactly one resource,
azurerm_orchestrated_virtual_machine_scale_set.this. Every optional or repeating nested block is rendered with adynamicblock gated on a non-null input, and every optional leaf usestry(x, null), so an omitted input renders as absent rather than as an error. - Force-new fields bite hard β but the set is smaller than it looks.
name,resource_group_name,location,platform_fault_domain_count,priority,eviction_policy,upgrade_mode,zone_balance,extension_operations_enabled,capacity_reservation_group_idandproximity_placement_group_idforce replacement, pluszoneswhen a zone is removed andsku_profilewhen it is removed alongside asku_namechange away fromMix.sku_name,single_placement_group,encryption_at_host_enabled,source_image_id,license_typeandnetwork_api_versionupdate in place β so resizing the fleet, moving to a patched image and turning on host encryption are all ordinary updates, not rebuilds. - User-assigned identity only. Unlike the Uniform scale sets, the Orchestrated resource does not offer a system-assigned identity, so there is no
principal_idattribute to emit. Grant roles to the user-assigned identity you attach. - Private by default. No
public_ip_addressblock is rendered unless the caller adds one to anip_configuration. Instances stay reachable only through the VNet, a load balancer, or an application gateway. - Secrets never leak from the module. The provider marks
admin_password,custom_data,user_data_base64, and extensionprotected_settingsas sensitive; they are redacted from plan output and never emitted as outputs. Prefer SSH keys (Linux) andprotected_settings_from_key_vault(extensions) so no secret is expressed at all. features {}dependence. The provider will not initialize without a caller-sideprovider "azurerm" { features {} }block. That is expected β library modules never carry it.
| Concern | Secure default (empty call) | Opt-out (caller must type it) |
|---|---|---|
| Linux password auth | os_profile.linux_configuration.disable_password_authentication = true (SSH keys) |
set to false and supply admin_password |
| Host encryption | encryption_at_host_enabled = true |
set to false |
| Instance public IP | none attached | add a public_ip_address block to an ip_configuration |
| Managed identity | preferred over embedded credentials | omit identity |
| Data at rest | encrypted with a platform-managed key | supply disk_encryption_set_id for a customer-managed key |
| Instance priority | priority = "Regular" (on-demand) |
set to "Spot" |
| Upgrade mode | upgrade_mode = "Manual" (least disruptive) |
set to "Automatic" or "Rolling" |
| Secrets in extensions | protected_settings_from_key_vault (a reference) |
supply inline protected_settings |
# From the module folder β offline, no cloud calls:
terraform init -backend=false
terraform validate
terraform fmt -check- Pin the module to a released tag:
?ref=v1.0.0. Never track a branch. - This library is plan-only during authoring; a human runs
terraform plan/applyfrom CI against real credentials. - The caller supplies the
provider "azurerm" { features {} }block and authentication (Azure CLI, managed identity, or OIDC) β never the module.
The offline proof gate below runs without Azure credentials and without a backend:
| Gate | What it proves |
|---|---|
terraform init -backend=false |
Providers resolve against the ~> 4.0 pin; no backend is configured. |
terraform validate |
The configuration is type-correct against the pinned provider schema β every object() typo is caught here. It also fires the provider's own schema checks, but only where values are literals; it does not evaluate this module's validation {} blocks, which are conditions on variables that validate never resolves. |
terraform plan with real values |
What actually exercises this module's checks β all 41 of them, offline and without credentials: the five separate name rules, the sku_name format, the instances and priority_mix ranges, the max_bid_price two-value rule, the extensions_time_budget format and its PT15MβPT2H range, every sku_profile rule including the size-family check, both directions of the sku_profile/Mix and rolling_upgrade_policy/upgrade_mode pairings, both ConflictsWith rules, the auxiliary-networking pairing and its API-version dependency, and the two rules the provider defers to its Create function. |
terraform fmt -check |
Canonical formatting. |
Only a human-run apply exercises the Azure Resource Manager API: whether the VM size exists in the region, whether quota allows the instance count, whether the fault-domain count is legal there, whether Spot capacity is available, and whether the subscription has registered host encryption. This module is never applyd during authoring.
$ terraform output
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-web-eastus2/providers/Microsoft.Compute/virtualMachineScaleSets/vmss-web-eastus2"
name = "vmss-web-eastus2"
unique_id = "8f2d1c34-6b9a-4c1e-9f7a-2b0d5e6c1a3f"
identity_type = "UserAssigned"
identity_ids = [
"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-identity/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id-web",
]| Symptom | Cause | Fix |
|---|---|---|
Provider configuration not present / features error on init |
No caller-side provider "azurerm" { features {} } block |
Add the provider block with features {} in the root module. |
| Plan proposes destroy-and-recreate after a small edit | You changed a genuinely force-new field (platform_fault_domain_count, priority, eviction_policy, upgrade_mode, zone_balance, extension_operations_enabled, either placement-group ID, or the identity trio) β or you removed a zone, which is force-new where adding one is not. Note sku_name, source_image_id, encryption_at_host_enabled and license_type are not force-new and will not cause this. |
Revert the change, or accept replacement deliberately with a maintenance window. |
sku_profile rejected, or demanded, unexpectedly |
sku_profile and sku_name = "Mix" require each other in both directions |
Set both or neither. |
auxiliary_mode rejected on an otherwise valid network interface |
network_api_version is at its default of 2020-11-01, at which the provider refuses the auxiliary fields |
Set a later network_api_version. |
Apply fails on zone_balance or priority_mix after a clean plan |
Both rules live in the provider's Create function, not in a CustomizeDiff |
This module mirrors both at plan; if you see it from a different module, add zones or set priority = "Spot". |
identity.type must be "UserAssigned" |
Set SystemAssigned on identity.type |
This resource supports user-assigned identities only; supply identity_ids. |
os_profile may set only one of linux_configuration or windows_configuration |
Both sub-blocks supplied | Keep exactly one OS configuration. |
| Automatic instance repair never triggers | No application health signal | Add a load-balancer health probe on the referenced backend pool, or an application-health extension. |
| UltraSSD data disk rejected | additional_capabilities.ultra_ssd_enabled not set |
Set additional_capabilities = { ultra_ssd_enabled = true } and use an AZ-enabled SKU/region. |
| Spot eviction / bid-price fields ignored | priority left as Regular |
Set priority = "Spot" before eviction_policy, max_bid_price, or priority_mix take effect. |
EncryptionAtHost feature error |
Subscription feature not registered | Register the EncryptionAtHost feature, or set encryption_at_host_enabled = false. |
- azurerm_orchestrated_virtual_machine_scale_set
- Azure Virtual Machine Scale Sets β Flexible orchestration
- Sibling modules:
terraform-azurerm-resource-group,terraform-azurerm-virtual-network,terraform-azurerm-load-balancer,terraform-azurerm-network-security-group,terraform-azurerm-user-assigned-identity,terraform-azurerm-monitor-diagnostic-setting. - This module's
SCOPE.mdβ the cross-module contract.
π "Infrastructure as Code should be standardized, consistent, and secure."