Provisions an Azure Dev Test Lab β the governance and cost-sandbox container that dev/test compute runs inside β as a focused standalone primitive, targeting
hashicorp/azurerm ~> 4.0.
- π§ͺ Creates one
azurerm_dev_test_labβ the container that scopes and governs a set of dev/test virtual machines. - π Auto-provisions a Key Vault (emitted as
key_vault_id) so the VMs that run in the lab can source secrets by reference rather than embedding them. - πΎ Auto-provisions the backing storage accounts (artifacts, standard, premium, premium-data-disk) the lab's VMs consume β all surfaced as computed outputs.
- π·οΈ Carries
tagsfor cost-center / owner / environment metadata so lab spend can be grouped and reported. - π§± Stays deliberately small: compute, cost schedules, policies, and lab networks are separate sibling modules consumed by
id.
π‘ Why it matters: A Dev Test Lab is a cost-and-governance boundary, not a workload. Keeping the lab a thin primitive lets one lab anchor many downstream modules β the auto-shutdown schedules and lab policies that actually control spend β without this module owning their lifecycles.
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
graph LR
rg["terraform-azurerm-resource-group"]
mod["terraform-azurerm-dev-test-lab"]
labres["azurerm_dev_test_lab"]
vm["terraform-azurerm-dev-test-linux-virtual-machine and its Windows twin"]
sched["terraform-azurerm-dev-test-schedule"]
pol["terraform-azurerm-dev-test-policy"]
rg -->|"resource_group_name + location"| mod
mod -->|"creates keystone"| labres
labres -->|"lab id + key_vault_id"| vm
labres -->|"cost-control scope"| sched
labres -->|"governance scope"| pol
classDef this fill:#0078D4,color:#fff,stroke:#004578,stroke-width:1px;
classDef keystone fill:#004578,color:#fff,stroke:#004578,stroke-width:1px;
classDef sib fill:#f2f2f2,color:#333333,stroke:#bbbbbb,stroke-width:1px;
class mod this;
class labres keystone;
class rg,vm,sched,pol sib;
graph LR
identity["name + location + resource_group_name"]
meta["tags + timeouts"]
mod["terraform-azurerm-dev-test-lab"]
lab["azurerm_dev_test_lab.this"]
outCore["id + name + unique_identifier"]
outVault["key_vault_id (auto-provisioned)"]
outStorage["storage account ids (auto-provisioned)"]
identity -->|"required inputs"| mod
meta -->|"optional inputs"| mod
mod -->|"manages"| lab
lab -->|"emits first"| outCore
lab -->|"emits"| outVault
lab -->|"emits"| outStorage
classDef this fill:#0078D4,color:#fff,stroke:#004578,stroke-width:1px;
classDef keystone fill:#004578,color:#fff,stroke:#004578,stroke-width:1px;
classDef io fill:#f2f2f2,color:#333333,stroke:#bbbbbb,stroke-width:1px;
class mod this;
class lab keystone;
class identity,meta,outCore,outVault,outStorage io;
Resource inventory
| Resource | Role | Cardinality |
|---|---|---|
azurerm_dev_test_lab.this |
Keystone β the lab governance container | 1 |
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
| Provider | hashicorp/azurerm ~> 4.0 |
| Provider block | None in this module β the caller configures provider "azurerm" { features {} }, auth, and subscription |
Schema notes that bite (verified against the live provider schema):
nameis immutable β it is embedded in the lab's Azure Resource ID, so changing it forces replacement.resource_group_nameis immutable β changing it forces replacement.locationis force-new β moving the lab between regions replaces it.- There is no
storage_typeargument on the lab in the~> 4.0line. Storage tiering (Standard vs Premium) is a property of the dev/test VM resources that run inside the lab, not of the lab. Settingstorage_typehere is an unsupported argument. - The Key Vault and storage accounts the lab creates are computed outputs, not inputs β do not try to configure them through this module.
DevTest Labs Userto consume an existing lab.Contributor(or equivalent administrative access) on the target resource group to create and manage the lab. Because creation also provisions a Key Vault and storage accounts on the caller's behalf, the identity must be able to create those resource types in the target resource group.- Grant at the smallest scope that works β prefer the resource group over the subscription.
- An existing resource group in a supported US Azure region.
- The
Microsoft.DevTestLabresource provider registered on the target subscription. - The caller configures the
provider "azurerm" { features {} }block, auth, and subscription; this module declares none of these.
terraform-azurerm-dev-test-lab/
βββ providers.tf # required_version + azurerm ~> 4.0 pin; no provider block
βββ variables.tf # deeply-typed inputs; tags + timeouts universal tail
βββ main.tf # keystone azurerm_dev_test_lab.this + dynamic timeouts
βββ outputs.tf # id first, then name, then computed lab references
βββ README.md # this document
βββ SCOPE.md # cross-module contract
βββ LICENSE # MIT, Copyright (c) 2026 Casey Wood
βββ .gitignore # canonical library ignore set
provider "azurerm" {
features {}
# auth + subscription are the caller's concern (Azure CLI, Managed Identity, OIDC, ...)
}
module "dev_test_lab" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-lab.git?ref=v1.0.0"
name = "lab-sandbox-eastus2"
location = "eastus2"
resource_group_name = "rg-devtest-eastus2"
tags = {
environment = "sandbox"
cost_center = "engineering"
}
}βΉοΈ Pin
?ref=v1.0.0β never a branch. The caller owns the provider, auth, and the mandatoryfeatures {}block; the module never declares them.
Consumes
| Input | Type | Source module |
|---|---|---|
resource_group_name |
string |
terraform-azurerm-resource-group (name) |
location |
string |
caller / terraform-azurerm-resource-group (location) |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
Dev Test Lab Resource ID (first) | VM / schedule / policy / network siblings |
name |
Dev Test Lab name | diagnostics / tagging |
unique_identifier |
Immutable Azure identifier | audit / correlation |
key_vault_id |
Auto-provisioned Key Vault ID | out-of-band secret provisioning |
artifacts_storage_account_id |
Artifacts storage account ID | lab tooling |
default_storage_account_id |
Default (standard) storage account ID | lab VMs |
default_premium_storage_account_id |
Default premium storage account ID | lab VMs |
premium_data_disk_storage_account_id |
Premium data-disk storage account ID | lab VMs |
1 Β· Minimal lab (the secure empty call)
module "lab" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-lab.git?ref=v1.0.0"
name = "lab-min"
location = "eastus"
resource_group_name = "rg-devtest"
}π The lab surface exposes no public-access or protection toggles, so the minimal call is already the hardened resource. Cost and exposure decisions live on the sibling modules that run workloads inside it.
2 Β· Cost-attribution tags
module "lab" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-lab.git?ref=v1.0.0"
name = "lab-tagged"
location = "eastus2"
resource_group_name = "rg-devtest"
tags = {
environment = "sandbox"
cost_center = "platform-eng"
owner = "team-a"
expiry = "2026-12-31"
}
}π‘ Tags are the primary lever for grouping and reporting lab spend. Standardize
cost_centerandowneracross every lab so finance can attribute the sandbox bill.
3 Β· Custom timeouts
module "lab" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-lab.git?ref=v1.0.0"
name = "lab-timeouts"
location = "westus2"
resource_group_name = "rg-devtest"
timeouts = {
create = "45m"
delete = "45m"
}
}βΉοΈ Lab creation fans out to a Key Vault and several storage accounts, so a longer create/delete window is reasonable in busy subscriptions.
4 Β· A specific US region
module "lab" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-lab.git?ref=v1.0.0"
name = "lab-central"
location = "centralus"
resource_group_name = "rg-devtest-central"
}
β οΈ locationis force-new. Choose the region deliberately β moving it later replaces the lab and everything keyed off its Resource ID.
5 Β· One lab per team with for_each
variable "teams" {
type = map(object({
location = string
rg_name = string
}))
default = {
payments = { location = "eastus", rg_name = "rg-devtest-payments" }
lending = { location = "eastus2", rg_name = "rg-devtest-lending" }
data = { location = "westus2", rg_name = "rg-devtest-data" }
}
}
module "lab" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-lab.git?ref=v1.0.0"
for_each = var.teams
name = "lab-${each.key}"
location = each.value.location
resource_group_name = each.value.rg_name
tags = { team = each.key }
}π‘
for_eachover a keyed map keeps each team's lab stable β adding or removing a team never re-indexes the rest.
6 Β· Consuming the auto-provisioned Key Vault
module "lab" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-lab.git?ref=v1.0.0"
name = "lab-secrets"
location = "eastus"
resource_group_name = "rg-devtest"
}
output "lab_key_vault_id" {
value = module.lab.key_vault_id
}π The lab creates its own Key Vault for VM secrets. This module emits only the vault's
idβ provision secret values out of band (a Key Vault reference, a pipeline secret), never in Terraform state.
7 Β· Dev/test VM sibling β Standard storage
module "lab" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-lab.git?ref=v1.0.0"
name = "lab-std"
location = "eastus"
resource_group_name = "rg-devtest"
}
# Storage tiering lives on the VM, not the lab. The VM sibling module sets
# storage_type and references this lab BY NAME -- it is not owned here. The lab
# network module (declared in full in the composition below) supplies the subnet.
module "lab_vm" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-linux-virtual-machine.git?ref=v1.0.0"
name = "vm-dev-01"
lab_name = module.lab.name
resource_group_name = "rg-devtest"
location = "eastus"
size = "Standard_DS2_v2"
username = "devuser"
storage_type = "Standard"
ssh_key = file("~/.ssh/id_rsa.pub")
lab_virtual_network_id = module.lab_net.id
lab_subnet_name = module.lab_net.expected_subnet_name_derived_from_the_network_name
gallery_image_reference = {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts"
version = "latest"
}
}βΉοΈ In
azurerm ~> 4.0the lab has nostorage_type. "A Standard-storage lab" means its VMs requeststorage_type = "Standard", set on the VM sibling module.
8 Β· Dev/test VM sibling β Premium storage
module "lab" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-lab.git?ref=v1.0.0"
name = "lab-premium"
location = "eastus2"
resource_group_name = "rg-devtest"
}
# The lab already surfaces default_premium_storage_account_id for premium disks.
# The VM sibling is not owned here; this shows how it attaches. The lab network
# module (declared in full in the composition below) supplies the subnet.
module "lab_vm" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-linux-virtual-machine.git?ref=v1.0.0"
name = "vm-dev-premium"
lab_name = module.lab.name
resource_group_name = "rg-devtest"
location = "eastus"
size = "Standard_DS2_v2"
username = "devuser"
storage_type = "Premium"
ssh_key = file("~/.ssh/id_rsa.pub")
lab_virtual_network_id = module.lab_net.id
lab_subnet_name = module.lab_net.expected_subnet_name_derived_from_the_network_name
gallery_image_reference = {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts"
version = "latest"
}
}
output "premium_storage" {
value = module.lab.default_premium_storage_account_id
}π‘ The lab provisions both standard and premium backing storage. A "Premium lab" is one whose VMs opt into
storage_type = "Premium"; the premium account ID is ready to consume viadefault_premium_storage_account_id.
9 Β· Auto-shutdown schedule sibling (the cost control)
module "lab" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-lab.git?ref=v1.0.0"
name = "lab-scheduled"
location = "eastus"
resource_group_name = "rg-devtest"
}
# Auto-shutdown is the single most effective lab cost control. It is a separate
# module, and it references this lab BY NAME rather than by id.
module "lab_autoshutdown" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-schedule.git?ref=v1.0.0"
name = "LabVmsShutdown"
resource_group_name = "rg-devtest"
location = "eastus"
lab_name = module.lab.name # the NAME -- this is what creates the dependency edge
task_type = "LabVmsShutdown"
time_zone_id = "Eastern Standard Time"
status = "Enabled" # the provider defaults to Disabled
daily_recurrence = { time = "1900" }
notification_settings = {}
}π‘ Keeping the schedule external means one lab can carry a shutdown policy that finance owns, without this module taking on the schedule's lifecycle.
β οΈ Notelab_name = module.lab.name, not an id: the schedule resource takes the lab's name plus aresource_group_name, so a literal would leave Terraform with no dependency edge to this lab at all. Andstatusmust be typed β the provider creates a scheduleDisabledby default.
10 Β· Lab policy sibling (the guardrails)
module "lab" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-lab.git?ref=v1.0.0"
name = "lab-guarded"
location = "eastus2"
resource_group_name = "rg-devtest"
}
# VM size / count / allowed-image guardrails are lab policies, owned by a sibling module.
# Note the policy's `name` SELECTS which guardrail it is, from a closed set of eight.
module "max_vms_per_user" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-policy.git?ref=v1.0.0"
name = "UserOwnedLabVmCount"
policy_set_name = "default"
lab_name = module.lab.name
resource_group_name = "rg-devtest"
evaluator_type = "MaxValuePolicy"
threshold = "3"
description = "At most three machines per user."
}π Guardrails (max VMs per user, allowed VM sizes, allowed images) are the governance half of the sandbox. They belong to the policy sibling so they can be reviewed and versioned independently.
11 Β· Consuming the backing storage account outputs
module "lab" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-lab.git?ref=v1.0.0"
name = "lab-storage"
location = "eastus"
resource_group_name = "rg-devtest"
}
output "artifacts_storage" { value = module.lab.artifacts_storage_account_id }
output "default_storage" { value = module.lab.default_storage_account_id }
output "premium_data_disk_sa" { value = module.lab.premium_data_disk_storage_account_id }βΉοΈ These accounts are created and managed by the lab. Read their IDs when a sibling needs to reference them; do not attempt to reconfigure them through this module.
12 Β· Least-privilege consumption (existing lab)
# A team member with only the DevTest Labs User role can reference an existing
# lab by id/name without holding rights to create it.
data "azurerm_dev_test_lab" "existing" {
name = "lab-shared"
resource_group_name = "rg-devtest"
}
# Pass data.azurerm_dev_test_lab.existing.id into sibling VM/schedule modules.π Separate the identity that creates the lab (
Contributoron the RG) from the identities that use it (DevTest Labs User). Grant creation rights only in the pipeline that owns this module.
13 Β· Diagnostics via a sibling monitor module
module "lab" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-lab.git?ref=v1.0.0"
name = "lab-observed"
location = "eastus"
resource_group_name = "rg-devtest"
}
# Diagnostics are consumed by id from a reusable sibling module. `name` and
# `target_resource_id` are both REQUIRED there -- the earlier form omitted `name`.
module "lab_diagnostics" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-diagnostic-setting.git?ref=v1.0.0"
name = "diag-lab-platform"
target_resource_id = module.lab.id
log_analytics_workspace_id = var.log_analytics_workspace_id # pre-existing; not created here
}π‘ The lab emits its
idfirst precisely so cross-cutting modules (diagnostics, RBAC, private endpoints) can attach to it without this module owning them.
14 Β· Wiring from a resource-group sibling
module "rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-devtest-eastus2"
location = "eastus2"
}
module "lab" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-lab.git?ref=v1.0.0"
name = "lab-from-rg"
location = module.rg.location
resource_group_name = module.rg.name
tags = { environment = "sandbox" }
}βΉοΈ The lab references the resource group; it never creates one. Feed
module.rg.nameandmodule.rg.locationstraight in.
15 Β· ποΈ End-to-end composition
provider "azurerm" {
features {}
}
# 1) The resource group that contains the sandbox.
module "rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-devtest-eastus2"
location = "eastus2"
tags = { environment = "sandbox", cost_center = "engineering" }
}
# 2) The lab (this module) β the governance/cost container.
module "lab" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-lab.git?ref=v1.0.0"
name = "lab-sandbox-eastus2"
location = module.rg.location
resource_group_name = module.rg.name
tags = { environment = "sandbox", cost_center = "engineering" }
}
# 3) The lab's own network wrapper. Its subnet's name is DERIVED from this name, so the
# module predicts it at plan time -- the resource's own subnet_name is computed.
module "lab_net" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-virtual-network.git?ref=v1.0.0"
name = "lab-net"
lab_name = module.lab.name
resource_group_name = module.rg.name
subnet = {
use_in_virtual_machine_creation = "Allow"
use_public_ip_address = "Deny"
}
}
# 4) A dev/test VM sibling that runs INSIDE the lab (a separate module).
# It references the lab BY NAME. Note it takes no key_vault_id: the lab's
# auto-provisioned vault is consumed out of band, not passed to the VM.
module "lab_vm" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-dev-test-linux-virtual-machine.git?ref=v1.0.0"
name = "vm-dev-01"
lab_name = module.lab.name
resource_group_name = module.rg.name
location = module.rg.location
size = "Standard_DS2_v2"
username = "devuser"
storage_type = "Standard"
ssh_key = file("~/.ssh/id_rsa.pub")
lab_virtual_network_id = module.lab_net.id
lab_subnet_name = module.lab_net.expected_subnet_name_derived_from_the_network_name
gallery_image_reference = {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts"
version = "latest"
}
}
output "lab_id" { value = module.lab.id }
output "lab_key_vault_id" { value = module.lab.key_vault_id }π The lab is the anchor: the resource group contains it, the VM sibling runs inside it and draws secrets from its auto-provisioned vault, and the cost schedule / policy siblings (examples 9 and 10) attach to
module.lab.id. This module owns only the lab.
Identity
| Name | Type | Required | Default |
|---|---|---|---|
name |
string |
β | β |
Placement
| Name | Type | Required | Default |
|---|---|---|---|
location |
string |
β | β |
resource_group_name |
string |
β | β |
Metadata (universal tail)
| Name | Type | Required | Default |
|---|---|---|---|
tags |
map(string) |
β | {} |
timeouts |
object({...}) |
β | null |
Full input schemas
variable "name" {
type = string
# Immutable β changing forces replacement (embedded in the Resource ID).
}
variable "location" {
type = string
# US Azure regions (eastus, eastus2, westus2, centralus). Force-new.
}
variable "resource_group_name" {
type = string
# Existing resource group; referenced, not created. Immutable.
}
variable "tags" {
type = map(string)
default = {}
}
variable "timeouts" {
type = object({
create = optional(string)
read = optional(string)
update = optional(string)
delete = optional(string)
})
default = null
}| Output | Description | Kind |
|---|---|---|
id |
The Azure Resource ID of the Dev Test Lab, of the form /subscriptions//resourceGroups//providers/Microsoft.DevTestLab/labs/ | Passthrough |
name |
The name of the Dev Test Lab | Passthrough |
location |
Azure region, in the canonical form Azure uses | Passthrough |
unique_identifier |
The unique immutable identifier assigned to the lab by Azure - a GUID, per the ARM property definition, distinct from the Resource ID and stable for the life of the lab | Passthrough |
key_vault_id |
Resource ID of the Key Vault the lab auto-provisions for VM secrets, named followed by four digits | Passthrough |
artifacts_storage_account_id |
Resource ID of the storage account the lab uses for artifacts | Passthrough |
default_storage_account_id |
Resource ID of the lab's default (standard) storage account | Passthrough |
default_premium_storage_account_id |
Resource ID of the lab's default premium storage account | Passthrough |
premium_data_disk_storage_account_id |
Resource ID of the storage account used for the lab's premium data disks | Passthrough |
lab_id_suffix |
The subscription-independent tail of the lab's Resource ID, assembled from resource_group_name and name: resourceGroups//providers/Microsoft.DevTestLab/labs/ | Derived |
resource_group_name |
The resource group containing the lab, as configured | Derived |
location_normalized |
The lab's region in the form the provider actually sends and compares: lowercased with spaces removed | Passthrough |
subscription_id |
The subscription containing the lab, parsed from the lab's Resource ID | Derived |
key_vault_id_is_empty_string_when_absent |
Always true, and it changes how you must test the key_vault_id output | Constant |
has_key_vault |
Whether the service reported a Key Vault for this lab | Passthrough |
destroy_deletes_all_lab_vms_and_their_resource_groups |
Constant | |
destroy_does_not_delete_the_labs_resource_group |
Always true | Constant |
labs_resource_group_cannot_be_deleted_while_the_lab_exists |
Always true, and it dictates destroy ordering | Constant |
creates_a_resource_group_per_lab_vm |
Always true, and it is a subscription-quota fact rather than a lab fact | Constant |
implicitly_provisioned_resources |
Derived | |
service_itself_is_free_and_the_bill_is_the_resources_it_creates |
Always true | Constant |
storage_tier_the_service_defaults_to |
Derived | |
lab_is_not_network_isolated |
Constant | |
unmanaged_lab_properties |
Derived | |
sends_no_lab_properties_on_create_or_update |
Always true | Constant |
force_new_arguments |
The arguments that force replacement of the lab when changed: name, location and resource_group_name | Derived |
in_place_updatable_arguments |
The only arguments this module can change without replacing the lab: tags | Derived |
resource_group_name_is_compared_case_insensitively |
Always true | Constant |
location_is_compared_after_normalization |
Always true | Constant |
create_performs_an_existence_check_first |
Always true unless the caller has enabled the provider feature that skips import checks | Constant |
read_fails_if_the_services_vault_reference_is_unparseable |
Always true | Constant |
arm_api_version |
The ARM API version the provider uses for this resource: 2018-09-15 | Derived |
timeout_defaults |
The provider's built-in timeouts for this resource, which appear nowhere in the schema | Derived |
effective_timeouts |
The timeouts actually in force: the caller's values where supplied, the provider's defaults otherwise | Derived |
- The lab is a container, not a workload.
main.tfrenders exactly one resource. Every knob that controls cost or exposure (auto-shutdown, VM count/size policies, allowed images, network access) is deliberately out of scope and owned by sibling modules that consume this lab'sid. - Immutable identity.
name,resource_group_name, andlocationare all force-new β they are baked into the lab's Azure Resource ID or otherwise fixed at creation. Treat a rename or region move as a destroy-and-recreate, and expect anything keyed off the lab'sidto cascade. - Auto-provisioned dependencies are outputs, not inputs. On creation, the lab stands up a Key Vault and several storage accounts. This module never accepts configuration for them; it surfaces their IDs so downstream modules can reference them. The Key Vault ID is a reference only β secret values are provisioned out of band, and this module emits no secret.
- No
storage_typeon the lab. Storage tiering belongs to the dev/test VM resources, verified against the live provider schema. Passingstorage_typeto this module is an unsupported argument. timeoutsis rendered as adynamicblock guarded bytry(...), so an omitted timeout falls back to the provider default rather than erroring.features {}dependence. The provider will not initialize without the caller'sprovider "azurerm" { features {} }block. That block is the root module's responsibility; this module never carries one.
The lab surface exposes no exposure or protection toggles of its own, so its secure posture is expressed by what is deliberately delegated. The risky relaxations live on the sibling modules β the caller must reach for them explicitly.
| Concern | Secure default (empty call) | Opt-out (caller must type it) |
|---|---|---|
| VM auto-shutdown / cost control | Off the lab; provisioned by the schedule sibling | Add the schedule sibling and set an always-on window |
| VM count / size / image guardrails | Off the lab; provisioned by the policy sibling | Add the policy sibling and loosen thresholds |
| Secret handling | Secrets referenced from the auto-provisioned Key Vault | Embed secrets elsewhere (discouraged) |
| Diagnostics / RBAC / private endpoints | Composed from siblings by id |
Attach the relevant sibling module |
# From the module folder (plan-only; a human applies from CI):
terraform init -backend=false
terraform validate
terraform fmt -check- Pin the module with
?ref=v1.0.0insourceβ never a branch. - No cloud apply happens here.
init -backend=false,validate, andfmt -checkare static, offline checks; only a human runsterraform plan/applyagainst real credentials from CI.
| Check | What it proves | Reaches Azure? |
|---|---|---|
terraform init -backend=false |
Provider pin resolves; module initializes without a backend | No |
terraform validate |
Configuration is internally consistent and type-correct against the pinned provider schema | No |
terraform fmt -check |
Canonical formatting | No |
terraform plan (human, from CI) |
ARM will accept the request; force-new / quota behavior | Yes β not part of authoring |
Outputs:
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-devtest-eastus2/providers/Microsoft.DevTestLab/labs/lab-sandbox-eastus2"
name = "lab-sandbox-eastus2"
unique_identifier = "4b8f2c1a-9d3e-4f0a-bc21-7e5a1d9c6f04"
key_vault_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-devtest-eastus2/providers/Microsoft.KeyVault/vaults/labsandbox1234"
artifacts_storage_account_id = "/subscriptions/.../providers/Microsoft.Storage/storageAccounts/alabsandbox5678"
default_storage_account_id = "/subscriptions/.../providers/Microsoft.Storage/storageAccounts/dlabsandbox9012"
default_premium_storage_account_id = "/subscriptions/.../providers/Microsoft.Storage/storageAccounts/plabsandbox3456"
premium_data_disk_storage_account_id = "/subscriptions/.../providers/Microsoft.Storage/storageAccounts/pdlabsandb0789"
| Symptom | Cause | Fix |
|---|---|---|
Unsupported argument: storage_type |
Storage tiering was set on the lab; it does not exist there in ~> 4.0 |
Remove it from the lab call and set storage_type on the dev/test VM sibling instead |
| Plan shows the lab being destroyed and recreated | A force-new field (name, resource_group_name, or location) changed |
Revert the change, or accept replacement and rewire everything keyed off the lab id |
Error: features block is required |
The caller's provider has no features {} block |
Add provider "azurerm" { features {} } to the root module |
AuthorizationFailed creating the lab |
Identity lacks rights to create the lab or its backing Key Vault / storage | Grant Contributor on the target resource group to the creating identity |
| Backing storage / Key Vault not configurable | Those resources are computed outputs of the lab, not inputs | Read them via the *_storage_account_id / key_vault_id outputs; manage them in their own modules if needed |
- Terraform Registry β
azurerm_dev_test_labresource - Terraform Registry β
azurerm_dev_test_labdata source - Microsoft Learn β Azure DevTest Labs documentation
- Sibling modules β
terraform-azurerm-resource-group, the dev/test VM module(s), the dev/test schedule (auto-shutdown) module, and the dev/test policy module. - This module's
SCOPE.md.
π "Infrastructure as Code should be standardized, consistent, and secure."