Terraform Lab – Azure – Hands-On with Meta-Arguments

Continuation of Labs 1–4 → Part 2 (Input Variables) → Part 3 (Local Values & Outputs)
Audience: Beginners moving toward intermediate Terraform
Terraform CLI: 1.15.9+ (Terraform 1.x), as in the previous lab
AzureRM provider: ~> 5.9.0, as in the previous lab
Lab region: eastus
Updated: 13 October 2026

1. Objective and non-negotiable rules

Take the complete Part 3 code and introduce all seven requested concepts without creating any additional Azure resources. Keep the same resource group, VNet, subnet, NSG, two NSG rules, two public IPs, two network interfaces, Windows Server 2025 VM, and Ubuntu 26.04 VM.

What stays unchanged: 12 input variables, original variable values, Windows/Ubuntu images, resource names, region, CIDRs, virtual machine sizes, administrator credentials, existing local values and the nine Part 3 outputs (except their references to the now-indexed public IPs).

What changes in this lesson: The two separate public-IP blocks become one resource block with count = 2; the two NSG-rule blocks become one resource block with for_each. Four moved blocks instruct Terraform to relabel existing state addresses, rather than deleting and re-creating Azure objects. We also add an aliased AzureRM provider, a dependency example, an explicit lifecycle rule, and a harmless creation-time provisioner. To illustrate providers, we add a tiny child module that declares no Azure resources.

Critical: Continue in the same Terraform root working directory and state from Part 3; do not create a fresh state for already-running infrastructure. Do not change your working terraform.tfvars. Never apply a plan with unexpected resource creation, replacement, or destruction.

What you will learn

ConceptPurposeWorking example in this labAzure objects added
countRepeat a resource by an integer indexExisting Windows/Ubuntu Public IPs: count = 20
for_eachRepeat a resource by stable map/set keysExisting RDP/SSH NSG rules: keys "rdp", "ssh"0
depends_onAdd ordering that references do not expressVMs wait for NSG association and correct security rule0
providerSelect an individual provider configurationExisting NSG uses provider = azurerm.lab0
lifecycleAlter how Terraform handles a resourceProtect the existing resource group using prevent_destroy0
provisionerRun a command in a creation/destroy lifecycle phaseUbuntu VM local-exec prints a message on new VM creation only0
providersPass provider configurations to a child modulemodule "provider_demo" receives azurerm.lab0

Terminology: provisioner is actually a nested block, not one of the core meta-arguments in HashiCorp’s classification. It is taught here because it appears on your requested syllabus. providers works only inside a module block — placing it inside a resource block is invalid. The no-resource module in this lesson is the smallest standalone, syntactically valid illustration of it.

Part 3 → Part 4 resource inventory

ResourcePart 3Part 4
Resource group1 resource block1 resource block
Virtual network11
Subnet11
Network security group11
NSG/subnet association11
RDP + SSH rules2 separate blocks1 for_each block → 2 instances
Windows + Ubuntu public IPs2 separate blocks1 count block → 2 instances
Windows + Ubuntu NICs2 blocks2 blocks
Windows + Ubuntu VMs2 blocks2 blocks
Total Azure objects managed1313

The Terraform root file contains 11 resource blocks in Part 4, but they create/manage 13 instances. count and for_each are why block count and instance count are not the same.

2. Folder layout

Keep the Part 3 root folder and state. The only new folder is a tiny Terraform child-module example, which does not contain resources.

azure-terraform-labs/
├── main.tf                            # FULL consolidated Part 4 root configuration
├── terraform.tfvars                   # SAME values from Part 3 (not a new set)
├── modules/
│   └── provider-demo/
│       └── main.tf                    # Child module: required_providers + one output, ZERO resources
├── .terraform.lock.hcl                # Existing lock file
└── terraform.tfstate                  # Existing local state, if you use local state

Do not put a second copy of Part 3 main.tf into this directory with a .tf extension; Terraform automatically loads every .tf file in the root directory, and duplicate declarations fail validation. Backups should use .txt or a separate private location.

3. Understand the safety-critical moved blocks before replacing main.tf

In Part 3 the state contained these four addresses:

Part 3 addressPart 4 addressAzure object retained
azurerm_public_ip.windowsazurerm_public_ip.vm[0]pip-win2025-lab
azurerm_public_ip.ubuntuazurerm_public_ip.vm[1]pip-ubuntu2604-lab
azurerm_network_security_rule.rdpazurerm_network_security_rule.admin["rdp"]Allow-RDP
azurerm_network_security_rule.sshazurerm_network_security_rule.admin["ssh"]Allow-SSH

A moved block is Terraform’s native way to teach it that the address changed but the remote Azure resource did not. It does not create an Azure resource. Without the correct moves, Terraform could plan to destroy four objects and create four replacements. Do not delete or alter the four moved blocks when you first upgrade the configuration.

This lab fixes the index order as Windows = 0, Ubuntu = 1; it must stay stable. for_each avoids that index sensitivity by using meaningful keys.


4. FULL main.tf — replace the Part 3 root configuration with this file

This is a complete root configuration, not just illustrative fragments. It includes all 12 variable declarations, the local values, all 13 Azure resource instances, the new meta-arguments, four safe state moves, the no-resource module call, and all outputs.

# ============================================
# PART 4 - TERRAFORM META-ARGUMENTS (SAME 13 AZURE RESOURCES)
# Keeps the same 13 resources and images from Labs 1-4
# ============================================

terraform {
  required_version = ">= 1.15.0, < 2.0.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 5.9.0"
    }
  }
}

# ============================================================
# INPUT VARIABLES FROM PART 2 (ALL SIX TYPES)
# ============================================================

# STRING - Azure region and VM settings
variable "azure_region" {
  description = "Azure region for every resource in this lab."
  type        = string
}

variable "vm_size" {
  description = "Size used by both virtual machines."
  type        = string
}

variable "admin_username" {
  description = "Local administrator username on both VMs."
  type        = string
}

variable "windows_admin_password" {
  description = "Administrator password for the Windows Server VM."
  type        = string
  sensitive   = true
}

variable "ubuntu_admin_password" {
  description = "Administrator password for the Ubuntu VM."
  type        = string
  sensitive   = true
}

# NUMBER - NSG rule priorities
variable "rdp_priority" {
  description = "Inbound RDP rule priority, between 100 and 4096."
  type        = number
}

variable "ssh_priority" {
  description = "Inbound SSH rule priority, between 100 and 4096."
  type        = number
}

# BOOL - keep Linux password authentication enabled for this lab
variable "disable_linux_password_authentication" {
  description = "Must be false while ubuntu_admin_password is configured."
  type        = bool
}

# LIST(STRING) - ordered Azure network address prefixes
variable "vnet_address_spaces" {
  description = "Address spaces configured on the Azure virtual network."
  type        = list(string)
}

variable "subnet_address_prefixes" {
  description = "Address prefixes configured on the VM subnet."
  type        = list(string)
}

# SET(STRING) - unique source CIDRs permitted to use SSH/RDP
variable "admin_allowed_cidrs" {
  description = "Unique public IPv4 CIDRs allowed to reach SSH and RDP."
  type        = set(string)
}

# MAP(STRING) - stable names of the existing Azure resources
variable "resource_names" {
  description = "Resource names from Labs 1-4. Keep unchanged to retain state."
  type        = map(string)
}

# ============================================================
# LOCAL VALUES - REUSE PART 3 AND PREPARE META-ARGUMENT INPUTS
# ============================================================
locals {
  # Name and region from the existing input variables.
  resource_group_name = var.resource_names["resource_group"]
  azure_location      = var.azure_region
  vm_size             = var.vm_size

  # Reusable map of the names of the two existing VMs.
  vm_names = {
    windows = var.resource_names["windows_vm"]
    ubuntu  = var.resource_names["ubuntu_vm"]
  }

  # Turn the input set into a consistently ordered list for both NSG rules.
  admin_source_cidrs = sort(tolist(var.admin_allowed_cidrs))

  # Reuse the same port definitions in security rules and outputs.
  access_ports = {
    rdp = "3389"
    ssh = "22"
  }

  # IMPORTANT: Keep this order stable to preserve the previous IP objects.
  # count.index 0 = Windows, count.index 1 = Ubuntu.
  public_ip_names = [
    var.resource_names["windows_public_ip"],
    var.resource_names["ubuntu_public_ip"]
  ]

  # for_each is keyed by name, not by an integer position.
  # Both rules already existed in Part 3; the moved blocks below
  # preserve their current Azure objects.
  admin_rules = {
    rdp = {
      name     = var.resource_names["rdp_rule"]
      port     = local.access_ports.rdp
      priority = var.rdp_priority
    }
    ssh = {
      name     = var.resource_names["ssh_rule"]
      port     = local.access_ports.ssh
      priority = var.ssh_priority
    }
  }
}

# Default provider from Parts 1-3.
provider "azurerm" {
  features {}
}

# META-ARGUMENT: provider (alias configuration).
# Same subscription/credentials as default: no second deployment target.
provider "azurerm" {
  alias = "lab"
  features {}
}

# --------------------------------------------
# 1. Resource Group
# --------------------------------------------
resource "azurerm_resource_group" "lab" {
  name     = local.resource_group_name
  location = local.azure_location

  # META-ARGUMENT: lifecycle - protect this resource group from destroy.
  # Remove this rule before the final end-of-course cleanup.
  lifecycle {
    prevent_destroy = true
  }
}

# --------------------------------------------
# 2. Virtual Network
# --------------------------------------------
resource "azurerm_virtual_network" "lab" {
  name                = var.resource_names["virtual_network"]
  location            = azurerm_resource_group.lab.location
  resource_group_name = azurerm_resource_group.lab.name

  address_space = var.vnet_address_spaces
}

# --------------------------------------------
# 3. Subnet
# --------------------------------------------
resource "azurerm_subnet" "vms" {
  name                 = var.resource_names["subnet"]
  resource_group_name  = azurerm_resource_group.lab.name
  virtual_network_name = azurerm_virtual_network.lab.name

  address_prefixes = var.subnet_address_prefixes
}

# --------------------------------------------
# 4. Network Security Group
# --------------------------------------------
resource "azurerm_network_security_group" "lab" {
  # META-ARGUMENT: provider - deliberately select the alias.
  provider = azurerm.lab

  name                = var.resource_names["nsg"]
  location            = azurerm_resource_group.lab.location
  resource_group_name = azurerm_resource_group.lab.name
}

# --------------------------------------------
# 5. Associate NSG with Subnet
# --------------------------------------------
resource "azurerm_subnet_network_security_group_association" "lab" {
  subnet_id                 = azurerm_subnet.vms.id
  network_security_group_id = azurerm_network_security_group.lab.id
}

# --------------------------------------------
# 6-7. Two EXISTING NSG rules using for_each
# --------------------------------------------
resource "azurerm_network_security_rule" "admin" {
  # META-ARGUMENT: for_each - two keyed instances: "rdp" and "ssh".
  for_each = local.admin_rules

  name                        = each.value.name
  priority                    = each.value.priority
  direction                   = "Inbound"
  access                      = "Allow"
  protocol                    = "Tcp"
  source_port_range           = "*"
  destination_port_range      = each.value.port
  source_address_prefixes     = local.admin_source_cidrs
  destination_address_prefix  = "*"
  resource_group_name         = azurerm_resource_group.lab.name
  network_security_group_name = azurerm_network_security_group.lab.name
}

# --------------------------------------------
# 8 and 11. Two EXISTING public IPs using count
# --------------------------------------------
resource "azurerm_public_ip" "vm" {
  # META-ARGUMENT: count - exactly two existing IP instances.
  count = length(local.public_ip_names)

  name                = local.public_ip_names[count.index]
  location            = azurerm_resource_group.lab.location
  resource_group_name = azurerm_resource_group.lab.name

  allocation_method = "Static"
  sku               = "Standard"
}

# --------------------------------------------
# 9. Windows VM Network Interface
# --------------------------------------------
resource "azurerm_network_interface" "windows" {
  name                = var.resource_names["windows_nic"]
  location            = azurerm_resource_group.lab.location
  resource_group_name = azurerm_resource_group.lab.name

  ip_configuration {
    name                          = "internal"
    subnet_id                     = azurerm_subnet.vms.id
    private_ip_address_allocation = "Dynamic"
    public_ip_address_id          = azurerm_public_ip.vm[0].id
  }
}

# --------------------------------------------
# 10. Windows Server 2025 VM
# --------------------------------------------
resource "azurerm_windows_virtual_machine" "windows" {
  name                = local.vm_names.windows
  location            = azurerm_resource_group.lab.location
  resource_group_name = azurerm_resource_group.lab.name

  size           = local.vm_size
  admin_username = var.admin_username

  # Keep the same Windows password used when the VM was created
  admin_password = var.windows_admin_password

  network_interface_ids = [
    azurerm_network_interface.windows.id
  ]

  # META-ARGUMENT: depends_on - configure network security first.
  depends_on = [
    azurerm_subnet_network_security_group_association.lab,
    azurerm_network_security_rule.admin["rdp"]
  ]

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Standard_LRS"
  }

  source_image_reference {
    publisher = "MicrosoftWindowsServer"
    offer     = "WindowsServer"
    sku       = "2025-datacenter"
    version   = "latest"
  }
}

# --------------------------------------------
# 12. Ubuntu VM Network Interface
# --------------------------------------------
resource "azurerm_network_interface" "ubuntu" {
  name                = var.resource_names["ubuntu_nic"]
  location            = azurerm_resource_group.lab.location
  resource_group_name = azurerm_resource_group.lab.name

  ip_configuration {
    name                          = "internal"
    subnet_id                     = azurerm_subnet.vms.id
    private_ip_address_allocation = "Dynamic"
    public_ip_address_id          = azurerm_public_ip.vm[1].id
  }
}

# --------------------------------------------
# 13. Ubuntu Server 26.04 LTS VM
# --------------------------------------------
resource "azurerm_linux_virtual_machine" "ubuntu" {
  name                = local.vm_names.ubuntu
  location            = azurerm_resource_group.lab.location
  resource_group_name = azurerm_resource_group.lab.name

  size           = local.vm_size
  admin_username = var.admin_username

  # Training only - supplied through terraform.tfvars; keep secret
  admin_password = var.ubuntu_admin_password

  # Enable SSH password authentication
  disable_password_authentication = var.disable_linux_password_authentication

  network_interface_ids = [
    azurerm_network_interface.ubuntu.id
  ]

  # META-ARGUMENT: depends_on - explicitly wait for SSH/network security.
  depends_on = [
    azurerm_subnet_network_security_group_association.lab,
    azurerm_network_security_rule.admin["ssh"]
  ]

  # OS Disk
  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Standard_LRS"
  }

  # Ubuntu Server 26.04 LTS Gen2 Image
  source_image_reference {
    publisher = "Canonical"
    offer     = "ubuntu-26_04-lts"
    sku       = "server"
    version   = "latest"
  }

  # PROVISIONER: local-exec runs on the local Terraform execution machine,
  # ONLY if this VM is newly created (not for a pre-existing Part 3 VM).
  # This demo only echoes a message; it never touches the VM or secrets.
  provisioner "local-exec" {
    command = "echo 'Terraform created the Ubuntu lab VM.'"
  }
}

# ============================================================
# MOVED BLOCKS - STATE ADDRESSES ONLY; NO AZURE OBJECT CREATION
# ============================================================
# Migrate prior Lab 3/Part 3 addresses into the new count and for_each.
moved {
  from = azurerm_public_ip.windows
  to   = azurerm_public_ip.vm[0]
}

moved {
  from = azurerm_public_ip.ubuntu
  to   = azurerm_public_ip.vm[1]
}

moved {
  from = azurerm_network_security_rule.rdp
  to   = azurerm_network_security_rule.admin["rdp"]
}

moved {
  from = azurerm_network_security_rule.ssh
  to   = azurerm_network_security_rule.admin["ssh"]
}

# ============================================================
# PROVIDERS META-ARGUMENT - PASS ALIAS INTO A NO-RESOURCE MODULE
# ============================================================
module "provider_demo" {
  source = "./modules/provider-demo"

  # META-ARGUMENT: providers is valid on module blocks, not resources.
  providers = {
    azurerm = azurerm.lab
  }
}

# ============================================================
# PART 3 OUTPUT VALUES (PRESERVED) + PART 4 TEACHING OUTPUTS
# ============================================================

# Single string output: Resource Group name
output "resource_group_name" {
  description = "Name of the existing Azure resource group."
  value       = azurerm_resource_group.lab.name
}

# Single string output: VNet name
output "virtual_network_name" {
  description = "Name of the lab virtual network."
  value       = azurerm_virtual_network.lab.name
}

# Single string output: Subnet name
output "subnet_name" {
  description = "Name of the VM subnet."
  value       = azurerm_subnet.vms.name
}

# Map/object output: both VM names
output "vm_names" {
  description = "Windows and Ubuntu VM names, grouped by operating system."
  value       = local.vm_names
}

# Computed Azure IP addresses (available once resources exist)
output "windows_public_ip" {
  description = "Public IPv4 address of the Windows Server VM."
  value       = azurerm_public_ip.vm[0].ip_address
}

output "ubuntu_public_ip" {
  description = "Public IPv4 address of the Ubuntu Server VM."
  value       = azurerm_public_ip.vm[1].ip_address
}

# Computed text outputs: connection hints (no passwords)
output "windows_rdp_target" {
  description = "RDP target for Windows Remote Desktop."
  value       = "${azurerm_public_ip.vm[0].ip_address}:${local.access_ports.rdp}"
}

output "ubuntu_ssh_command" {
  description = "SSH command to access Ubuntu from an authorized IP address."
  value       = "ssh ${var.admin_username}@${azurerm_public_ip.vm[1].ip_address}"
}

# List output: stable, sorted CIDRs used by both NSG rules
output "allowed_admin_cidrs" {
  description = "Sorted source CIDRs authorized for SSH and RDP by the lab NSG."
  value       = local.admin_source_cidrs
}


# Extra outputs show the indexed and keyed meta-argument results.
output "count_public_ip_names" {
  description = "Names of the two existing public IPs managed by count."
  value       = [for ip in azurerm_public_ip.vm : ip.name]
}

output "for_each_admin_rule_names" {
  description = "Names of the two existing admin rules managed by for_each."
  value       = { for key, rule in azurerm_network_security_rule.admin : key => rule.name }
}

output "provider_demo_note" {
  description = "Message from a child module receiving azurerm.lab."
  value       = module.provider_demo.note
}

5. FULL terraform.tfvars — keep your actual Part 3 values

Existing deployment: Do not overwrite your real terraform.tfvars with the example below. Preserve the exact VM passwords, public-IP allowlist, and resource names from the applied Part 3 lab. Changing passwords, names, images, or network settings can lead to in-place changes or VM replacements.

Fresh training subscription: The example below is a template; before creating resources, replace 203.0.113.10/32 with your public IPv4 /32, and replace both demonstration passwords with unique strong passwords.

# ============================================================
# PART 2 - TERRAFORM VARIABLE VALUES
# Save as terraform.tfvars in the SAME directory as main.tf.
# IMPORTANT: Preserve the actual values used in Labs 1-4.
# ============================================================

# STRING
azure_region  = "eastus"
vm_size       = "Standard_B2s_v2"
admin_username = "labadmin"

# Replace these EXAMPLE passwords with the ones used in Lab 3 and Lab 4.
# If the VMs already exist, changing the passwords can replace the VMs.
windows_admin_password = "ChangeMe!2026WinLab#"
ubuntu_admin_password  = "ChangeMe!2026LinuxLab#"

# NUMBER: unique NSG priorities (100-4096)
rdp_priority = 100
ssh_priority = 110

# BOOL: must remain false for Ubuntu password-based login
disable_linux_password_authentication = false

# LIST(STRING): ordered collections
vnet_address_spaces      = ["10.20.0.0/16"]
subnet_address_prefixes  = ["10.20.1.0/24"]

# SET(STRING): Terraform converts this list-like literal to a set.
# REPLACE 203.0.113.10/32 with YOUR public IPv4 address/32.
# The example address is reserved for documentation, not your real IP.
admin_allowed_cidrs = ["203.0.113.10/32"]

# MAP(STRING): resource names must match the previous labs exactly.
resource_names = {
  resource_group    = "rg-terraform-lab-eastus"
  virtual_network   = "vnet-terraform-lab"
  subnet            = "snet-vms"
  nsg               = "nsg-terraform-lab"
  rdp_rule          = "Allow-RDP"
  ssh_rule          = "Allow-SSH"
  windows_public_ip = "pip-win2025-lab"
  windows_nic       = "nic-win2025-lab"
  windows_vm        = "vm-win2025-01"
  ubuntu_public_ip  = "pip-ubuntu2604-lab"
  ubuntu_nic        = "nic-ubuntu2604-lab"
  ubuntu_vm         = "vm-ubuntu2604-01"
}

6. FULL modules/provider-demo/main.tf — no Azure resources

Create the directory modules/provider-demo and put exactly the following code in its main.tf. It declares the AzureRM provider requirement and an informational output only. The calling module uses providers = { azurerm = azurerm.lab }; this is the only correct place for the providers meta-argument in this exercise.

Teaching note: The output shows that the child module was loaded and evaluated; since the module has no resources, there is no child-module Azure API operation to demonstrate. A later Modules lesson can show a full practical provider mapping. Here we introduce the syntax without violating the no-new-Azure-resources requirement.

# ============================================
# PART 4 - PROVIDERS MAPPING DEMONSTRATION
# ZERO AZURE RESOURCES IN THIS CHILD MODULE
# ============================================
terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 5.9.0"
    }
  }
}

# This module deliberately declares NO resource or data blocks.
# It exists only to demonstrate the 'providers' meta-argument.
output "note" {
  description = "Proof that the zero-resource child module was evaluated."
  value       = "The root module passes azurerm.lab as this module's azurerm provider."
}


7. Explanation of each concept using your actual Terraform code

7.1 count — two existing Public IP addresses

resource "azurerm_public_ip" "vm" {
  count = length(local.public_ip_names)
  name  = local.public_ip_names[count.index]
  # ...same location, resource group, SKU and allocation method...
}
  • local.public_ip_names is an ordered list of two existing names, so count evaluates to 2.
  • Terraform creates (or, when migrating state, retains) azurerm_public_ip.vm[0] for Windows and azurerm_public_ip.vm[1] for Ubuntu.
  • The Windows NIC points to azurerm_public_ip.vm[0].id; Ubuntu’s NIC points to [1].id.
  • Pitfall: Reordering list elements can change what an index means, potentially forcing replacements. Do not reorder this lab’s names.

7.2 for_each — the two existing NSG inbound rules

resource "azurerm_network_security_rule" "admin" {
  for_each = local.admin_rules
  name     = each.value.name
  priority = each.value.priority
  # destination_port_range = each.value.port
  # ...same other fields as Part 3...
}

The map is calculated in the existing locals block:

admin_rules = {
  rdp = {
    name     = var.resource_names["rdp_rule"]
    port     = local.access_ports.rdp
    priority = var.rdp_priority
  }
  ssh = {
    name     = var.resource_names["ssh_rule"]
    port     = local.access_ports.ssh
    priority = var.ssh_priority
  }
}
  • each.key is "rdp" or "ssh"; each.value contains the rule’s name, port and priority.
  • Addresses become azurerm_network_security_rule.admin["rdp"] and azurerm_network_security_rule.admin["ssh"].
  • Difference: count uses numeric indexes; for_each uses stable keys. You cannot put both on the same resource block.
  • The NSG is associated with the entire VM subnet. These RDP/SSH rules therefore apply to traffic for either VM, not just the VM suggested by the port. Keep source ranges limited to trusted public IPv4 /32 addresses.

7.3 depends_on — wait until access configuration exists

# Inside the Windows VM resource:
depends_on = [
  azurerm_subnet_network_security_group_association.lab,
  azurerm_network_security_rule.admin["rdp"]
]

Terraform already detects most relationships from references such as subnet_id = azurerm_subnet.vms.id. Here the Windows VM doesn’t directly reference the NSG association or inbound RDP rule in its VM attributes, but we want them configured first. depends_on makes that order explicit. Ubuntu similarly depends on the association and SSH rule.

Avoid excessive depends_on: Use it when the implicit dependency graph does not capture a genuine requirement.

7.4 provider — explicitly choose an aliased AzureRM configuration

provider "azurerm" {
  features {}
}

provider "azurerm" {
  alias = "lab"
  features {}
}

resource "azurerm_network_security_group" "lab" {
  provider = azurerm.lab
  # ...unchanged NSG arguments...
}

The original default provider remains available. The azurerm.lab alias authenticates to the same Azure subscription via the same environment-based credentials in this lab; it is not a second region, account, or deployment. We select it explicitly on the existing NSG resource. The provider alias is a Terraform configuration, not a newly created Azure resource.

A real multi-subscription example would need explicit per-provider subscription and authentication configuration and is intentionally out of scope.

7.5 lifecycle — stop accidental Resource Group destruction

resource "azurerm_resource_group" "lab" {
  # ...same name and location...
  lifecycle {
    prevent_destroy = true
  }
}

The prevent_destroy rule instructs Terraform to reject a plan that destroys or replaces the protected Resource Group while this setting is present. This is a simple and visible safety example. terraform destroy will fail until you intentionally remove or disable this rule. Deleting a resource block altogether is not protected by prevent_destroy; it only works while the block with the rule remains in the configuration.

Why not create_before_destroy = true here? Azure objects often have fixed, unique names, and some VM/NIC resources cannot coexist as replacements with the same names. Teaching create_before_destroy on a fixed-name VM would risk an Azure error. We therefore demonstrate the safer prevent_destroy rule.

7.6 provisioner "local-exec" — run a local command on VM creation

# Inside the existing Ubuntu VM block:
provisioner "local-exec" {
  command = "echo 'Terraform created the Ubuntu lab VM.'"
}
  • Runs on the machine executing Terraform (your Mac/CI runner), not inside the Ubuntu VM.
  • Normally runs only after newly creating this VM. Merely adding the provisioner to an already existing Ubuntu VM from Part 3 does not run it. Do not replace an existing VM merely to see the message.
  • The example writes only a harmless message. It doesn’t install software, log in remotely, or access credentials.
  • Provisioners are a last resort for real infrastructure; prefer cloud-init/VM extensions/configuration management for guest setup. Provisioner failures during creation can lead Terraform to mark resources for replacement.

7.7 providers — pass a configuration into a child module

module "provider_demo" {
  source = "./modules/provider-demo"
  providers = {
    azurerm = azurerm.lab
  }
}

Unlike singular provider on an individual resource, plural providers belongs in a module block. The key azurerm represents the name expected inside the child module; the value azurerm.lab is the provider configuration from the root. The child declares its own required_providers, but does not define any provider block or Azure resource block.

This no-resource module lets students write, validate and see the mapping without adding infrastructure or introducing an actual reusable infrastructure module ahead of the future module lesson.

7.8 moved — the extra state-refactoring concept required for safety

moved {
  from = azurerm_public_ip.windows
  to   = azurerm_public_ip.vm[0]
}

The other three moved blocks follow the same pattern. They change Terraform’s state addresses, not Azure’s resource IDs. Terraform plans may display “has moved to” messages; those are not resource deletion/re-creation. This is critical when retrofitting count and for_each onto live infrastructure.

8. Run the full Part 4 lab (existing Part 3 deployment)

Step 1 — Confirm Azure login and state

az login
az account set --subscription "YOUR_SUBSCRIPTION_ID"
export ARM_SUBSCRIPTION_ID="$(az account show --query id -o tsv)"

# Must be in the existing Part 3 working directory.
terraform version
terraform state list
terraform output vm_names

Expect 13 managed resource addresses from Part 3 before editing any code. If terraform state list is empty or you get an initialization/backend error, stop and locate the correct Part 3 state — do not apply this lab as though it is new.

Step 2 — Take private backups without introducing duplicate .tf files

cp main.tf main.part3.backup.txt
# Local-state users only (remote-state users follow their backend backup process):
cp terraform.tfstate terraform.tfstate.part3.backup

Step 3 — Replace and add the lesson files

  1. Replace your root main.tf with Section 4 in this guide.
  2. Keep the already-working terraform.tfvars. The code in Section 5 is a full reference, not a request to reset existing passwords or IP allowlists.
  3. Create modules/provider-demo/main.tf from Section 6. This child module creates zero Azure resources.
  4. Do not modify the four moved blocks, the Windows/Ubuntu index order, or the original resource names.

Step 4 — Initialize, format, validate, and review

terraform init
terraform fmt -recursive
terraform validate
terraform plan -out=lab-part4.tfplan
terraform show lab-part4.tfplan

Expected for a successful Part 3 → Part 4 conversion with unchanged .tfvars: Four resource-address moves in state, the same 13 Azure objects, and three new informational root outputs. Terraform may show the alias assignment in state. There should be no intentional Azure resource creation, deletion or replacement. A real plan is authoritative: if any Azure object shows + create, - destroy, or -/+ replace, do not apply. Investigate the changed names, provider settings, secrets, resource arguments and move mapping first. Even unexpected in-place updates should be reviewed.

Step 5 — Apply only the reviewed plan

terraform apply lab-part4.tfplan
terraform state list
terraform output

Step 6 — Check the changed Terraform addresses

After applying, terraform state list should contain the following 13 resource addresses, possibly in a different display order:

azurerm_resource_group.lab
azurerm_virtual_network.lab
azurerm_subnet.vms
azurerm_network_security_group.lab
azurerm_subnet_network_security_group_association.lab
azurerm_network_security_rule.admin["rdp"]
azurerm_network_security_rule.admin["ssh"]
azurerm_public_ip.vm[0]
azurerm_public_ip.vm[1]
azurerm_network_interface.windows
azurerm_network_interface.ubuntu
azurerm_windows_virtual_machine.windows
azurerm_linux_virtual_machine.ubuntu

Verify the output values:

terraform output count_public_ip_names
terraform output for_each_admin_rule_names
terraform output provider_demo_note
terraform output -raw windows_public_ip
terraform output -raw ubuntu_ssh_command

Expected names (public IP values are retrieved from Azure and will differ):

count_public_ip_names = ["pip-win2025-lab", "pip-ubuntu2604-lab"]
for_each_admin_rule_names = {rdp = "Allow-RDP", ssh = "Allow-SSH"}

The provider_demo_note output is a static explanatory message, not proof of an Azure API call. Students can inspect resource and module provider requirements with:

terraform providers

9. If students start with an empty state instead

The same three files can be used for a fresh classroom deployment into an empty training subscription, after preparing terraform.tfvars, quota, and access. Terraform simply creates the same 13 Azure objects; moved blocks do nothing when their old addresses are absent from state. The local-exec echo runs after the new Ubuntu VM is created.

However, if resources with the lab names already exist in Azure but are not in your Terraform state, Terraform will not automatically adopt them. Do not blindly apply against a mismatched/empty state; continue with the correct state or intentionally import the existing resources in a separate exercise.

10. Practice exercises

  1. Run terraform state list and identify the two indexed count instances versus the two keyed for_each instances.
  2. Inspect local.public_ip_names and explain why Windows is index 0 and Ubuntu is index 1.
  3. Explain why the RDP and SSH rules use different for_each keys even though they share the same subnet-level NSG.
  4. Identify what depends_on guarantees before the Windows VM is created.
  5. Explain which provider configuration manages the NSG and why using an alias does not add a new Azure subscription.
  6. Explain why provisioner "local-exec" did not print anything when converting a previously deployed Part 3 VM.
  7. Explain why terraform destroy is blocked by the Resource Group’s prevent_destroy = true rule.
  8. Explain why the providers meta-argument cannot be placed on the azurerm_linux_virtual_machine resource.

11. Common errors and fixes

SymptomCauseFix
Module not installedmodules/provider-demo/main.tf missing or terraform init not runCreate the child module file and run terraform init again
Reference to undeclared resource for azurerm_public_ip.windowsOld reference left behind after changing to countRefer to azurerm_public_ip.vm[0] or [1]
Missing resource instance keyReferring to a count/for_each resource without [index] or ["key"]Use azurerm_public_ip.vm[0] or azurerm_network_security_rule.admin["rdp"]
Unexpected destroy/create of NSG rules or public IPsmoved blocks missing or edited, wrong root state or reordered listRestore correct moved blocks, correct state, and original name order; do not apply
Provider configuration not presentRemoved or mistyped azurerm.lab provider aliasRetain both provider blocks and provider = azurerm.lab
Invalid provider configuration reference for providersUsed providers on a resource or mapped alias incorrectlyKeep the providers map in the module block only
Instance cannot be destroyed during end-of-course cleanuplifecycle.prevent_destroy = trueDeliberately disable the rule before destroying the full training environment
local-exec message did not appearUbuntu VM was already in stateExpected; provisioners do not run retroactively. Do not force VM replacement for this
Can’t reach RDP/SSH203.0.113.10/32 placeholder, wrong admin IP or network restrictionUse your own trusted IPv4 /32, verify NSG/host rules, then connect
Password/state exposurePlaintext values committed to a repositoryProtect terraform.tfvars, state, plan files and backups; keep secrets out of public repos

12. Safe cleanup at the end of the entire course

Because this lab teaches prevent_destroy = true, a direct terraform destroy is intentionally blocked. When the whole course is finished and you truly intend to delete every managed Azure object:

  1. Edit only azurerm_resource_group.lab in main.tf: change prevent_destroy = true to prevent_destroy = false (or remove that lifecycle block).
  2. Keep all resource blocks and moved blocks in place so Terraform still knows about each managed resource.
  3. Review destruction, then explicitly confirm:
terraform plan -destroy
terraform destroy

Destroying removes both VMs, both Public IPs, network objects, and the Resource Group. The child module and moved/locals/output blocks do not correspond to separately billable Azure resources. VM compute and public IP addresses may cost money until destroyed.

13. Final success checklist

  • [ ] Correct existing Azure subscription, working directory and Terraform state.
  • [ ] All 12 input variables still declared, and the same working terraform.tfvars used.
  • [ ] All 13 previously managed Azure objects retained; no extra Azure resources.
  • [ ] count produces two indexed public-IP resource instances.
  • [ ] for_each produces two keyed security-rule instances.
  • [ ] Four moved blocks preserve pre-existing Terraform state bindings.
  • [ ] depends_on explicitly models the network-security ordering for each VM.
  • [ ] provider = azurerm.lab works with an aliased provider configured for the same subscription.
  • [ ] lifecycle.prevent_destroy protects the Resource Group while present.
  • [ ] provisioner "local-exec" is harmless and correctly documented as creation-time only.
  • [ ] The providers map is in a child-module call; the child module creates zero Azure resources.
  • [ ] All nine old outputs still exist; three additional teaching outputs work.
  • [ ] terraform init, terraform fmt -recursive, terraform validate succeed on the student’s machine; the plan contains no unintended Azure operations.

Official documentation

Validation transparency: This document evolves the uploaded Part 3 code, preserving all 13 original Azure objects by design and keeping the same image references. Structural/static consistency checks were completed during authoring. A live Azure login, provider-backed terraform validate, and Azure terraform plan/apply have not been performed here. Consequently, a universal “100% successful deployment” cannot be guaranteed for every subscription and existing state; the included validation-and-review steps are mandatory.

Related Posts

Terraform tflint terraform-docs trivy tenv pre-commit Tools — Installation Guide for Windows PowerShell

Updated: October 2026 | Windows 10/11 | PowerShell 5.1 / 7 This guide covers installing, verifying, and running the first commands for five Terraform development tools. Tool…

Read More

HCP Terraform Cloud CLI — Master Command Summary Table

Organization: motoshare | Project: proezi | Workspaces: proezi_dev, proezi_stage, proezi_prod 1. Terraform CLI — Complete Command Reference Category Command Purpose / Usage Installation terraform version Check installed…

Read More

HCP Terraform Cloud CLI — Complete Master Tutorial

HCP Terraform Cloud — CLI Command Summary Organization: motoshare | Project: proezi # Cloud operation Command / Example Usage 1 Login to Terraform Cloud terraform login Authenticate…

Read More

Terraform Lab – Azure Hands-On – Terraform Functions

Beginner to Intermediate · 8 modules · 22 progressively designed labs · 20 local-only labs + 2 optional cloud labs · Updated 14 October 2026 Purpose: learn Terraform…

Read More

Terraform Lab – Azure Hands-On – Terraform Template

Level: Beginner to IntermediateFormat: Tutorial + concept guide + practical lab workbookModules: 6 | Labs: 12 | Cloud emphasis: Azure Linux VM, AWS EC2 comparisonBaseline: Terraform 1.15.9+ (within 1.x); AzureRM 5.9.x; AWS provider 6.xUpdated: 14 October…

Read More

Terraform Lab – Azure Hands-On – Terraform Modules

Course level: Beginner → IntermediateCourse type: Tutorial + runnable hands-on exercises + instructor checkpointsUpdated: 14 October 2026Terraform: >= 1.15.0, < 2.0.0 (lab baseline: 1.15.9)AzureRM provider: ~> 5.9.0 (root; child modules declare >= 5.9.0)Azure baseline: eastus, Ubuntu…

Read More