The single deeply-typed boundary for the whole Azure Dev Test Labs family — labs, lab virtual networks, Linux & Windows lab VMs, cost-control policies, lab start/stop schedules, and standalone ARM-VM auto-shutdown — every resource a
for_eachmap collection, secure by default. Built for AzureRM v4.x.
This composite module provisions the entire Azure Dev Test Labs surface behind one module call:
- 🧪 Dev Test Labs — the lab boundary that owns cost policies, images, schedules and an auto-provisioned Key Vault.
- 🌐 Lab virtual networks — VNets registered to a lab, with subnet public-IP and VM-creation permissions.
- 🐧 Linux lab VMs — gallery-image VMs with SSH-key or password auth, claimable by lab users.
- 🪟 Windows lab VMs — gallery-image VMs with mandatory password auth.
- 📊 Lab policies —
MaxValuePolicy/AllowedValuesPolicyfact rules that cap VM count, size, cost, etc. - ⏰ Lab schedules — automated start/stop (
LabVmsShutdownTask/LabVmsStartupTask) for lab VMs. - 🛑 Global VM shutdown schedules (primary
this) — auto-shutdown for standalone ARM VMs that are NOT in a lab. - 🔎 Read-only lookups — existing labs and lab VNets resolved by name for downstream wiring.
💡 Why it matters: one typed contract covers the full Dev Test Labs lifecycle, and because every resource type is an independent
for_eachmap, adding a VM never re-plans your labs, and rotating a schedule never touches your VNets — small, predictable blast radius.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
flowchart LR
rg[tf-mod-azure-resource-group]
vmmod[tf-mod-azure-linux-virtual-machine / tf-mod-azure-windows-virtual-machine]
devtest[tf-mod-azure-dev-test]
ra[tf-mod-azure-role-assignment]
rg -->|resource_group_name, location| devtest
vmmod -->|virtual_machine_id| devtest
devtest -->|lab_ids / vm ids / schedule ids| ra
style devtest fill:#8957E5,color:#fff
style rg fill:#0078D4,color:#fff
Consumes resource_group_name + location (from tf-mod-azure-resource-group) and, for the primary global shutdown schedule, a virtual_machine_id (from the standalone VM modules). Emits ID/name maps for labs, VNets, VMs, policies and schedules — see the Cross-Module Contract.
flowchart TD
subgraph create["Created by this module"]
lab["azurerm_dev_test_lab • lab"]
vnet["azurerm_dev_test_virtual_network • virtual_network"]
linux["azurerm_dev_test_linux_virtual_machine • linux_virtual_machine"]
windows["azurerm_dev_test_windows_virtual_machine • windows_virtual_machine"]
policy["azurerm_dev_test_policy • policy"]
sched["azurerm_dev_test_schedule • schedule"]
this["azurerm_dev_test_global_vm_shutdown_schedule • this"]
end
subgraph read["Read-only lookups (data sources)"]
labget["data.azurerm_dev_test_lab • lab_lookup"]
vnetget["data.azurerm_dev_test_virtual_network • virtual_network_lookup"]
end
lab -->|lab_key resolves lab_name| vnet
lab -->|lab_key| policy
lab -->|lab_key| sched
lab -->|lab_key| linux
lab -->|lab_key| windows
vnet -->|virtual_network_key resolves lab_virtual_network_id| linux
vnet -->|virtual_network_key resolves lab_virtual_network_id| windows
extvm["external ARM VM (by id)"] -->|virtual_machine_id| this
style this fill:#8957E5,color:#fff
Resource inventory (one per azurerm_* type):
- 🛑
azurerm_dev_test_global_vm_shutdown_schedule.this— primary; auto-shutdown for standalone ARM VMs (lab-independent). - 🧪
azurerm_dev_test_lab.lab— the lab boundary; thefor_eachkey is the in-call wiring handle (lab_key). - 🌐
azurerm_dev_test_virtual_network.virtual_network— lab VNet; the key is thevirtual_network_keyhandle. - 🐧
azurerm_dev_test_linux_virtual_machine.linux_virtual_machine— Linux lab VM. - 🪟
azurerm_dev_test_windows_virtual_machine.windows_virtual_machine— Windows lab VM. - 📊
azurerm_dev_test_policy.policy— lab policy-set fact rule. - ⏰
azurerm_dev_test_schedule.schedule— lab VM start/stop schedule. - 🔎
data.azurerm_dev_test_lab.lab_lookup— read an existing lab. - 🔎
data.azurerm_dev_test_virtual_network.virtual_network_lookup— read an existing lab VNet.
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
| AzureRM provider | >= 4.0, < 5.0 |
| API | Microsoft.DevTestLab 2018-09-15 |
⚠️ AzureRM 4.x note: the v4 provider requires asubscription_idon theazurermprovider block (configured by the root, never by this module). This module declares the provider requirement only — it has noprovider {}block, so it composes cleanly. ℹ️ Schema quirks baked into the types: VNetallowed_ports.transport_protocolis UPPERCASETCP/UDP, while VMinbound_nat_rule.protocolis mixed-caseTcp/Udp— see Troubleshooting.
tf-mod-azure-dev-test/
├── providers.tf # Terraform + AzureRM version pins (no provider config)
├── variables.tf # Deeply-typed inputs, heredoc schemas, 20+ validation blocks
├── main.tf # 7 for_each resource collections + 2 data-source collections
├── outputs.tf # Composition contract: ID/name/fqdn maps per collection
├── SCOPE.md # Composite scope contract (allow-list, consumes/emits, gotchas)
└── README.md # You are here
module "dev_test" {
source = "git::https://github.com/microsoftexpert/tf-mod-azure-dev-test?ref=v1.0.0"
resource_group_name = module.resource_group.name
location = module.resource_group.location
labs = {
primary = {
name = "dtl-casey-dev-001"
}
}
}💡 The map key
primaryis the stable wiring handle — other collections reference this lab withlab_key = "primary".
Sourced directly from SCOPE.md.
| Input | Type | Source |
|---|---|---|
📁 resource_group_name |
string |
tf-mod-azure-resource-group.name |
📍 location |
string |
tf-mod-azure-resource-group.location |
🖥️ global_vm_shutdown_schedules[*].virtual_machine_id |
string |
tf-mod-azure-linux-virtual-machine.id / tf-mod-azure-windows-virtual-machine.id (standalone ARM VMs) |
🌐 *.lab_virtual_network_id (when external) |
string |
An existing lab VNet id, or this module's own virtual_network_ids |
| Output | Description | Consumed by |
|---|---|---|
global_vm_shutdown_schedule_ids |
Map key → primary schedule ID | tf-mod-azure-role-assignment, diagnostic/monitoring modules |
lab_ids / lab_names |
Map key → lab ID / name | tf-mod-azure-role-assignment, any lab-scoped sibling |
lab_key_vault_ids |
Map key → lab's auto-provisioned Key Vault ID | tf-mod-azure-role-assignment (KV access), tf-mod-azure-key-vault policy modules |
lab_unique_identifiers |
Map key → lab immutable unique identifier | Audit / tagging |
virtual_network_ids / virtual_network_names |
Map key → lab VNet ID / name | VM collections (in-call), downstream refs |
virtual_network_subnet_names |
Map key → provider-generated subnet name | VM lab_subnet_name wiring |
linux_virtual_machine_ids / _fqdns |
Map key → Linux VM ID / FQDN | tf-mod-azure-role-assignment, DNS/connection tooling |
windows_virtual_machine_ids / _fqdns |
Map key → Windows VM ID / FQDN | tf-mod-azure-role-assignment, DNS/connection tooling |
policy_ids / schedule_ids |
Map key → policy / schedule ID | Audit, governance reporting |
lab_lookup_ids / lab_lookup_key_vault_ids |
Map key → resolved existing-lab IDs | Brownfield wiring |
virtual_network_lookup_ids / _allowed_subnets |
Map key → resolved existing-VNet data | Brownfield VM placement |
resource_group_name |
Pass-through default RG | Sibling modules in the same RG |
Every example uses the ADO git source pinned at
?ref=v1.0.0. Keys (primary,dev, …) are identity — see Architecture Notes.
1️⃣ Minimal — a single lab
module "dev_test" {
source = "git::https://github.com/microsoftexpert/tf-mod-azure-dev-test?ref=v1.0.0"
resource_group_name = "rg-casey-devtest-dev"
location = "East US 2"
labs = {
primary = { name = "dtl-casey-dev-001" }
}
}2️⃣ Lab + virtual network (secure subnet defaults)
labs = {
primary = { name = "dtl-casey-dev-001" }
}
virtual_networks = {
core = {
name = "dtlvnet-core"
lab_key = "primary" # in-call wiring → creates the dependency edge
description = "Core lab subnet"
subnet = {
# use_public_ip_address defaults to "Deny" (secure), use_in_virtual_machine_creation to "Allow"
}
}
}🔒 The subnet defaults to
use_public_ip_address = "Deny"— VMs land on private IPs unless you explicitly opt in.
3️⃣ Linux lab VM (SSH key auth, no public IP)
labs = { primary = { name = "dtl-casey-dev-001" } }
virtual_networks = { core = { name = "dtlvnet-core", lab_key = "primary", subnet = {} } }
linux_virtual_machines = {
build01 = {
name = "build01"
lab_key = "primary"
virtual_network_key = "core"
lab_subnet_name = module.dev_test.virtual_network_subnet_names["core"]
size = "Standard_D4_v5"
username = "caseyadmin"
storage_type = "Premium"
ssh_key = file("~/.ssh/id_rsa.pub")
gallery_image_reference = {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts"
version = "latest"
}
# disallow_public_ip_address defaults to true
}
}
⚠️ lab_subnet_nameis required. The subnet name is provider-generated, so read it back fromvirtual_network_subnet_names.
4️⃣ Windows lab VM (password from the sensitive companion map)
windows_virtual_machines = {
win01 = {
name = "win01" # 1–15 chars for Windows
lab_key = "primary"
virtual_network_key = "core"
lab_subnet_name = module.dev_test.virtual_network_subnet_names["core"]
size = "Standard_D4_v5"
username = "caseyadmin"
storage_type = "Premium"
gallery_image_reference = {
publisher = "MicrosoftWindowsDesktop"
offer = "windows-11"
sku = "win11-23h2-pro"
version = "latest"
}
}
}
# Passwords live OUTSIDE the iterable map (sensitive, keyed identically):
virtual_machine_secrets = {
win01 = { password = var.win01_admin_password }
}🔒 Passwords are kept in
virtual_machine_secretsso the VM config map stays non-sensitive andfor_each-able. Windows VMs require a password.
5️⃣ Cost-control policies (MaxValue + AllowedValues)
policies = {
max_vms = {
name = "LabVmCount"
lab_key = "primary"
evaluator_type = "MaxValuePolicy"
threshold = "10"
}
allowed_sizes = {
name = "LabVmSize"
lab_key = "primary"
evaluator_type = "AllowedValuesPolicy"
threshold = jsonencode(["Standard_D2_v5", "Standard_D4_v5"])
}
}💡
policy_set_namedefaults to"default".AllowedValuesPolicythresholds are JSON-array strings;MaxValuePolicythresholds are numeric strings.
6️⃣ Lab start/stop schedules
schedules = {
nightly_shutdown = {
name = "LabVmsShutdown"
lab_key = "primary"
task_type = "LabVmsShutdownTask"
time_zone_id = "Eastern Standard Time"
status = "Enabled"
daily_recurrence = { time = "1900" }
notification_settings = {
status = "Enabled"
time_in_minutes = 30
}
}
weekday_startup = {
name = "LabVmAutoStart" # name is dictated by task_type
lab_key = "primary"
task_type = "LabVmsStartupTask"
time_zone_id = "Eastern Standard Time"
status = "Enabled"
weekly_recurrence = {
time = "0700"
week_days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
}
}
}
⚠️ For aLabVmsStartupTaskthenamemust beLabVmAutoStart. See Troubleshooting.
7️⃣ Global shutdown schedule for a standalone ARM VM (the primary `this`)
global_vm_shutdown_schedules = {
app01 = {
virtual_machine_id = module.app_vm.id # a NON-lab azurerm_*_virtual_machine
timezone = "Eastern Standard Time"
daily_recurrence_time = "2300"
notification_settings = {
enabled = true
time_in_minutes = 30
email = "platform-team@financialpartners.com"
}
}
}💡 This resource targets standard ARM VMs outside any lab — it is independent of every
lab*collection in this module.
8️⃣ Shared public IP via subnet allowed ports (UPPERCASE protocol)
virtual_networks = {
shared = {
name = "dtlvnet-shared"
lab_key = "primary"
subnet = {
use_public_ip_address = "Allow"
use_in_virtual_machine_creation = "Allow"
shared_public_ip_address = {
allowed_ports = [
{ backend_port = 22, transport_protocol = "TCP" },
{ backend_port = 3389, transport_protocol = "TCP" },
]
}
}
}
}
⚠️ transport_protocolhere is UPPERCASE (TCP/UDP) — different from VM NAT rules.
9️⃣ Linux VM with inbound NAT rule (forces private IP)
linux_virtual_machines = {
jump = {
name = "jump01"
lab_key = "primary"
virtual_network_key = "shared"
lab_subnet_name = module.dev_test.virtual_network_subnet_names["shared"]
size = "Standard_D2_v5"
username = "caseyadmin"
storage_type = "Standard"
ssh_key = file("~/.ssh/id_rsa.pub")
disallow_public_ip_address = true # mandatory with NAT rules
inbound_nat_rule = [
{ backend_port = 22, protocol = "Tcp" }, # mixed-case here
]
gallery_image_reference = {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts"
version = "latest"
}
}
}
⚠️ Anyinbound_nat_rulerequiresdisallow_public_ip_address = true— enforced by a validation block.
🔟 Brownfield — read an existing lab & VNet, then place a VM
lab_lookups = { legacy = { name = "dtl-legacy-001" } }
virtual_network_lookups = { legacy = { name = "dtlvnet-legacy", lab_name = "dtl-legacy-001" } }
linux_virtual_machines = {
migrated = {
name = "migrated01"
lab_name = "dtl-legacy-001" # external lab
lab_virtual_network_id = module.dev_test.virtual_network_lookup_ids["legacy"]
lab_subnet_name = module.dev_test.virtual_network_lookup_allowed_subnets["legacy"][0].lab_subnet_name
size = "Standard_D2_v5"
username = "caseyadmin"
storage_type = "Standard"
ssh_key = file("~/.ssh/id_rsa.pub")
gallery_image_reference = {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts"
version = "latest"
}
}
}💡 Use
lab_name/lab_virtual_network_id(external) instead oflab_key/virtual_network_key(in-call) when wiring to pre-existing resources.
1️⃣1️⃣ for_each at scale — a fleet of Linux VMs from a map(object)
locals {
build_agents = {
agent01 = { size = "Standard_D4_v5" }
agent02 = { size = "Standard_D4_v5" }
agent03 = { size = "Standard_D8_v5" }
}
}
linux_virtual_machines = {
for k, v in local.build_agents : k => {
name = k # stable key == VM name
lab_key = "primary"
virtual_network_key = "core"
lab_subnet_name = module.dev_test.virtual_network_subnet_names["core"]
size = v.size
username = "caseyadmin"
storage_type = "Premium"
ssh_key = file("~/.ssh/id_rsa.pub")
gallery_image_reference = {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts"
version = "latest"
}
}
}
⚠️ The map key is the resource's identity. Renamingagent01→agent1destroys and recreates that VM. Choose keys you will never need to change.
1️⃣2️⃣ Security / hardening — -compliant locked-down lab
labs = { secure = { name = "dtl-casey-prod-001", tags = { data_class = "internal" } } }
virtual_networks = {
core = {
name = "dtlvnet-core"
lab_key = "secure"
subnet = {
use_public_ip_address = "Deny" # no public IPs at all
use_in_virtual_machine_creation = "Allow"
}
}
}
policies = {
cap_vms = { name = "LabVmCount", lab_key = "secure", evaluator_type = "MaxValuePolicy", threshold = "5" }
cap_cost = { name = "LabTargetCost", lab_key = "secure", evaluator_type = "MaxValuePolicy", threshold = "500" }
}
linux_virtual_machines = {
app01 = {
name = "app01"
lab_key = "secure"
virtual_network_key = "core"
lab_subnet_name = module.dev_test.virtual_network_subnet_names["core"]
size = "Standard_D4_v5"
username = "caseyadmin"
storage_type = "Premium"
ssh_key = file("~/.ssh/id_rsa.pub") # key auth, no password
allow_claim = false # locked to its owner
disallow_public_ip_address = true
gallery_image_reference = {
publisher = "Canonical", offer = "0001-com-ubuntu-server-jammy", sku = "22_04-lts", version = "latest"
}
}
}
schedules = {
shutdown = {
name = "LabVmsShutdown", lab_key = "secure", task_type = "LabVmsShutdownTask"
time_zone_id = "Eastern Standard Time", status = "Enabled"
daily_recurrence = { time = "1900" }
notification_settings = { status = "Enabled", time_in_minutes = 30 }
}
}
tags = { owner = "platform-team", cost_center = "-IT" }🔒 Private IPs only, claim disabled, VM-count and cost caps enforced, nightly shutdown — the secure-by-default posture expects.
1️⃣3️⃣ 🏗️ End-to-end composition (mandatory) — RG → lab → VNet → VMs → schedules, plus a standalone-VM shutdown
module "resource_group" {
source = "git::https://github.com/microsoftexpert/tf-mod-azure-resource-group?ref=v1.0.0"
name = "rg-casey-devtest-dev"
location = "East US 2"
}
# A standalone (non-lab) ARM VM whose shutdown we want this module to manage:
module "app_vm" {
source = "git::https://github.com/microsoftexpert/tf-mod-azure-linux-virtual-machine?ref=v1.0.0"
name = "app-shared-01"
resource_group_name = module.resource_group.name
location = module.resource_group.location
#... vm config...
}
module "dev_test" {
source = "git::https://github.com/microsoftexpert/tf-mod-azure-dev-test?ref=v1.0.0"
resource_group_name = module.resource_group.name
location = module.resource_group.location
labs = { primary = { name = "dtl-casey-dev-001" } }
virtual_networks = { core = { name = "dtlvnet-core", lab_key = "primary", subnet = {} } }
linux_virtual_machines = {
build01 = {
name = "build01"
lab_key = "primary"
virtual_network_key = "core"
lab_subnet_name = module.dev_test.virtual_network_subnet_names["core"]
size = "Standard_D4_v5"
username = "caseyadmin"
storage_type = "Premium"
ssh_key = file("~/.ssh/id_rsa.pub")
gallery_image_reference = {
publisher = "Canonical", offer = "0001-com-ubuntu-server-jammy", sku = "22_04-lts", version = "latest"
}
}
}
policies = { cap = { name = "LabVmCount", lab_key = "primary", evaluator_type = "MaxValuePolicy", threshold = "10" } }
schedules = {
shutdown = {
name = "LabVmsShutdown", lab_key = "primary", task_type = "LabVmsShutdownTask"
time_zone_id = "Eastern Standard Time", status = "Enabled"
daily_recurrence = { time = "1900" }
notification_settings = { status = "Enabled", time_in_minutes = 30 }
}
}
# Primary `this` — shutdown for the standalone VM (NOT a lab VM):
global_vm_shutdown_schedules = {
app = {
virtual_machine_id = module.app_vm.id
timezone = "Eastern Standard Time"
daily_recurrence_time = "2300"
notification_settings = { enabled = true, time_in_minutes = 30 }
}
}
tags = { owner = "platform-team", environment = "dev" }
}💡 Inside one call the lab and VNet are created first (the
lab_key/virtual_network_keyhandles create real dependency edges), then VMs/policies/schedules, all wired by output → input.
Core (required):
resource_group_name(string) — default RG for all lab-scoped resources; per-entry overridable.location(string) — default region for all location-bearing resources; per-entry overridable.
Collections (all map(object), default {}):
labs,virtual_networks,linux_virtual_machines,windows_virtual_machines,policies,schedules,global_vm_shutdown_scheduleslab_lookups,virtual_network_lookups— read-only data-source collections.
Secrets:
virtual_machine_secrets(map(object),sensitive) — VM passwords, keyed identically to the VM maps.
Universal tail:
tags(map(string)) — merged into every resource (per-entrytagswin).timeouts(object) — applied to every resource (data sources usereadonly).
Full object schemas (mirrors variables.tf)
labs = map(object({
name = string # IMMUTABLE
location = optional(string) # defaults to var.location
resource_group_name = optional(string) # defaults to var.resource_group_name (IMMUTABLE)
tags = optional(map(string), {})
}))
virtual_networks = map(object({
name = string # IMMUTABLE
lab_key = optional(string) # in-call wiring → var.labs key
lab_name = optional(string) # external lab (IMMUTABLE) — exactly one of lab_key/lab_name
resource_group_name = optional(string) # IMMUTABLE
description = optional(string)
subnet = optional(object({
use_public_ip_address = optional(string, "Deny") # Allow | Default | Deny
use_in_virtual_machine_creation = optional(string, "Allow") # Allow | Default | Deny
shared_public_ip_address = optional(object({
allowed_ports = optional(list(object({
backend_port = optional(number)
transport_protocol = optional(string) # "TCP" | "UDP" (UPPERCASE)
})), [])
}))
}))
tags = optional(map(string), {})
}))
linux_virtual_machines = map(object({
name = string # IMMUTABLE — 1-62 chars
lab_key = optional(string)
lab_name = optional(string) # IMMUTABLE — exactly one of lab_key/lab_name
resource_group_name = optional(string) # IMMUTABLE
location = optional(string) # IMMUTABLE
size = string # IMMUTABLE
username = string # IMMUTABLE
storage_type = string # IMMUTABLE — "Standard" | "Premium"
virtual_network_key = optional(string)
lab_virtual_network_id = optional(string) # IMMUTABLE — exactly one of virtual_network_key/lab_virtual_network_id
lab_subnet_name = string # REQUIRED (IMMUTABLE)
ssh_key = optional(string) # IMMUTABLE — this OR a password
allow_claim = optional(bool, true)
disallow_public_ip_address = optional(bool, true) # SECURE DEFAULT (IMMUTABLE)
notes = optional(string)
gallery_image_reference = object({ publisher = string, offer = string, sku = string, version = string }) # IMMUTABLE
inbound_nat_rule = optional(list(object({
backend_port = number
protocol = string # "Tcp" | "Udp"
})), [])
tags = optional(map(string), {})
}))
windows_virtual_machines = map(object({ /* same as linux, minus ssh_key; password REQUIRED via virtual_machine_secrets; name 1-15 chars */ }))
virtual_machine_secrets = map(object({ password = optional(string) })) # sensitive
policies = map(object({
name = string # IMMUTABLE — closed set of policy fact names
policy_set_name = optional(string, "default") # IMMUTABLE
lab_key = optional(string)
lab_name = optional(string) # IMMUTABLE
resource_group_name = optional(string) # IMMUTABLE
evaluator_type = string # IMMUTABLE — "AllowedValuesPolicy" | "MaxValuePolicy"
threshold = string
fact_data = optional(string)
description = optional(string)
tags = optional(map(string), {})
}))
schedules = map(object({
name = string # IMMUTABLE
lab_key = optional(string)
lab_name = optional(string) # IMMUTABLE
resource_group_name = optional(string) # IMMUTABLE
location = optional(string) # IMMUTABLE
task_type = string # "LabVmsShutdownTask" | "LabVmsStartupTask" | "LabVmAutoStart"
time_zone_id = string
status = optional(string, "Disabled") # "Enabled" | "Disabled"
daily_recurrence = optional(object({ time = string }))
weekly_recurrence = optional(object({ time = string, week_days = optional(list(string)) }))
hourly_recurrence = optional(object({ minute = number }))
notification_settings = optional(object({
status = optional(string, "Disabled") # "Enabled" | "Disabled"
time_in_minutes = optional(number)
webhook_url = optional(string)
}), {})
tags = optional(map(string), {})
}))
global_vm_shutdown_schedules = map(object({
virtual_machine_id = string # IMMUTABLE
location = optional(string) # IMMUTABLE
enabled = optional(bool, true)
timezone = string
daily_recurrence_time = string # "HHmm"
notification_settings = optional(object({
enabled = optional(bool, true)
email = optional(string)
time_in_minutes = optional(number, 30) # 15-120
webhook_url = optional(string)
}), {})
tags = optional(map(string), {})
}))| Output | Description |
|---|---|
global_vm_shutdown_schedule_ids |
Map key → primary global VM shutdown schedule ID |
lab_ids |
Map key → Dev Test Lab ID |
lab_names |
Map key → Dev Test Lab name |
lab_key_vault_ids |
Map key → lab's auto-provisioned Key Vault ID |
lab_unique_identifiers |
Map key → lab immutable unique identifier |
virtual_network_ids |
Map key → lab VNet ID |
virtual_network_names |
Map key → lab VNet name |
virtual_network_subnet_names |
Map key → provider-generated subnet name (null when no subnet block was defined) |
linux_virtual_machine_ids |
Map key → Linux VM ID |
linux_virtual_machine_fqdns |
Map key → Linux VM FQDN |
windows_virtual_machine_ids |
Map key → Windows VM ID |
windows_virtual_machine_fqdns |
Map key → Windows VM FQDN |
policy_ids |
Map key → policy ID |
schedule_ids |
Map key → schedule ID |
lab_lookup_ids |
Map key → resolved existing-lab ID |
lab_lookup_key_vault_ids |
Map key → resolved existing-lab Key Vault ID |
virtual_network_lookup_ids |
Map key → resolved existing-VNet ID |
virtual_network_lookup_allowed_subnets |
Map key → resolved existing-VNet allowed_subnets list |
resource_group_name |
Pass-through default RG name |
ℹ️ Every output is a map keyed by your
for_eachkey (empty{}when the collection is unused).virtual_network_subnet_namesentries arenullwhen an entry omitted itssubnetblock. No output is markedsensitive— secrets live only invirtual_machine_secrets(input) and are never echoed back.
for_eachkeys are identity — and they are forever. Every collection is afor_eachover amap(object)keyed by a caller string. The key is not cosmetic: it is the resource's address in state. Renaming a key destroys and recreates that resource — and for a VM that means a rebuild, for a lab that means tearing down everything addressed off it. Pick keys you will never need to change, and never re-key to "tidy up."- In-call wiring vs. external refs. Lab-scoped children resolve their parent two ways:
lab_keypoints at a lab created in the same call (main.tfreadsazurerm_dev_test_lab.lab[key].name, which creates a real dependency edge so ordering is correct), orlab_namenames an external lab. The same pattern applies tovirtual_network_keyvslab_virtual_network_id. A validation enforces exactly one of each pair — you can't half-wire a resource. - The sensitive split is deliberate (and where
nonsensitiveearns its place). Terraform forbidsfor_eachover a sensitive value, so VM passwords live in a separatesensitive = truemap (virtual_machine_secrets) keyed identically to the non-sensitive VM config maps.main.tfjoins them per-key:password = try(var.virtual_machine_secrets[each.key].password, null). The cross-variable validations that check "every Windows VM has a password" must read those sensitive keys inside acondition, so they wrap the result innonsensitive— the one correct use of it here. - The primary
thisis lab-independent by design.azurerm_dev_test_global_vm_shutdown_schedulemanages auto-shutdown for standard ARM VMs that are not in any lab. It shares the module only because it belongs to theMicrosoft.DevTestLabfamily — it never references alab, takes avirtual_machine_idinstead, and its Azure name is forced toshutdown-computevm-<VM Name>. - VNet integration is lab-registration, not subnet delegation. Dev Test Labs does not use subnet delegation or private endpoints. A
azurerm_dev_test_virtual_networkregisters a VNet with the lab and exposes a single subnet whosenameis provider-generated (read it fromvirtual_network_subnet_names). VM placement is thenlab_subnet_name+lab_virtual_network_id. The VNet must be in the same region as the lab. Plan IP space generously — Azure guidance is a few VNets with large prefixes and large subnets rather than many small ones (especially under ExpressRoute/VPN). - Secure subnet default the provider doesn't give you. The provider defaults
subnet.use_public_ip_addresstoAllow; this module defaults it toDeny. Allowing public IPs on lab VMs is an explicit caller decision that shows up in review. VMdisallow_public_ip_addresslikewise defaults totrue. - Casing is a trap, encoded into the types. VNet
allowed_ports.transport_protocolis UPPERCASE (TCP/UDP); VMinbound_nat_rule.protocolis mixed-case (Tcp/Udp). Both are validated, so a wrong-case value fails at plan, not apply. - One
timeoutsobject fans out to nine collections. All resources accept create/read/update/delete; the two data sources acceptreadonly, so they render just thereadtimeout when set.
- 🔐 Secrets stay out of the iterable surface — passwords isolated in a
sensitivecompanion map, never echoed in outputs. - 🧱 Deeply-typed inputs — every provider block mirrored as a nested
object; noany, no loosemap. - 💥 Small blast radius — independent
for_eachcollections mean a VM change never re-plans a lab. - 🚫
main.tfis a total renderer —dynamicblocks +try(x, null)only; no business logic, nocount. - 🛡️ Secure by default — private IPs, no claim leakage when locked, certificate-of-intent for any public exposure.
- 🔁 Composable — declares the provider requirement, never configures one.
terraform init -backend=false
terraform validate
terraform fmt -check
terraform plan
terraform apply
terraform output
⚠️ Pin the version. Always source this module at an immutable tag —?ref=v1.0.0— never a branch. Branch refs move under you and turn aplaninto a surprise.
The offline proof gate — all three must pass before committing:
terraform fmt -check
terraform validate # passes with -backend=false
tflintlab_ids = {
"primary" = "/subscriptions/.../resourceGroups/rg-casey-devtest-dev/providers/Microsoft.DevTestLab/labs/dtl-casey-dev-001"
}
lab_key_vault_ids = {
"primary" = "/subscriptions/.../providers/Microsoft.KeyVault/vaults/dtlcaseydev001kv..."
}
virtual_network_subnet_names = {
"core" = "dtlvnet-coreSubnet"
}
linux_virtual_machine_fqdns = {
"build01" = "build01.eastus2.cloudapp.azure.com"
}
global_vm_shutdown_schedule_ids = {
"app" = "/subscriptions/.../providers/Microsoft.DevTestLab/schedules/shutdown-computevm-app-shared-01"
}
- 🛑
task_type↔namemismatch. ALabVmsStartupTaskschedule'snamemust beLabVmAutoStart; a shutdown schedule is typically namedLabVmsShutdown. The provider docs listtask_typevalues inconsistently (LabVmAutoStartvsLabVmsStartupTask) — this module's validation accepts all three observed values, but the API enforces the name/type correlation at apply. If apply fails with a task-type error, fix thename. - 🔤 Protocol casing.
transport_protocol(VNet shared-IP ports) is UPPERCASETCP/UDP;protocol(VMinbound_nat_rule) is mixed-caseTcp/Udp. Mixing them up trips a validation block at plan time — match the case shown in the examples. - 🌐 NAT rule requires private IP. Adding any
inbound_nat_ruleto a VM requiresdisallow_public_ip_address = true. The module validates this, but if you hit it from a hand-edited plan, set the flag. - 🏷️ Subnet name is provider-generated. You cannot choose
lab_subnet_namefreely — Dev Test Labs derives the subnet name (e.g.<vnet>Subnet). Read it fromvirtual_network_subnet_names["key"]; guessing it produces a "subnet not found" apply error. - 📍 Region mismatch. A lab VNet (and its VMs) must be in the same region as the lab. The lab portal only lists VNets in its region. If a VM apply fails to find the network, check
location. - 📦 Resource-group exhaustion → the real VM ceiling. Dev Test Labs creates a new resource group for every VM (unless shared public IPs are used). With the default 800 resource groups per subscription, that caps a subscription at roughly ~79 VMs per lab across 10 labs. There is no DevTest-service VM quota — every limit you hit is a subscription quota (default 20 cores, public IPs, 50→100 VNets). Use shared public IPs to collapse per-VM resource groups, or raise quotas via a support request.
- 🔑 Auth required, or apply fails. Linux VMs need
ssh_keyor avirtual_machine_secrets[key].password; Windows VMs require a password. The module's cross-variable validations catch a missing password at plan, but a malformed key is only rejected by Azure at apply. - ⬆️ AzureRM 4.x upgrade. The v4 provider requires a
subscription_idon the provider block and removed many 3.x aliases. None of theazurerm_dev_test_*attributes were renamed in 4.x, but VM-adjacent resources changed (e.g.vm_agent_platform_updates_enabledbecame read-only in 4.26) — pin to>= 4.0, < 5.0and re-runterraform planafter any provider bump. - ♻️ "Why is my VM being recreated?" Almost every VM and lab attribute is ForceNew (
size,username,storage_type,gallery_image_reference,lab_subnet_name,disallow_public_ip_address, …). Changing any of them — or renaming thefor_eachkey — destroys and recreates. Treat these as immutable; rebuild deliberately. - 📥 Importing an existing global shutdown schedule. Its Azure resource name is always
shutdown-computevm-<VM Name>, e.g.terraform import 'module.dev_test.azurerm_dev_test_global_vm_shutdown_schedule.this["app"]' /subscriptions/.../providers/Microsoft.DevTestLab/schedules/shutdown-computevm-app-shared-01.
- Terraform Registry —
azurerm_dev_test_lab,azurerm_dev_test_virtual_network,azurerm_dev_test_linux_virtual_machine,azurerm_dev_test_windows_virtual_machine,azurerm_dev_test_policy,azurerm_dev_test_schedule,azurerm_dev_test_global_vm_shutdown_schedule - Microsoft Learn — About Azure DevTest Labs, Configure a virtual network in DevTest Labs, Manage lab policies, Understand shared IP addresses, DevTest Labs enterprise reference architecture
- Microsoft Learn — Scale quotas and limits in DevTest Labs, Azure subscription and service limits
- HashiCorp — AzureRM provider 4.0 upgrade guide
- —
tf-mod-azure-resource-group,tf-mod-azure-linux-virtual-machine,tf-mod-azure-windows-virtual-machine,tf-mod-azure-role-assignment
💙 "Infrastructure as Code should be standardized, consistent, and secure."