{"id":1231,"date":"2026-10-13T08:16:37","date_gmt":"2026-10-13T08:16:37","guid":{"rendered":"https:\/\/www.devopsschool.com\/tutorials\/?p=1231"},"modified":"2026-10-13T08:16:39","modified_gmt":"2026-10-13T08:16:39","slug":"terraform-lab-azure-hands-on-with-meta-arguments","status":"publish","type":"post","link":"https:\/\/www.devopsschool.com\/tutorials\/terraform-lab-azure-hands-on-with-meta-arguments\/","title":{"rendered":"Terraform Lab \u2013 Azure \u2013\u00a0Hands-On with Meta-Arguments"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\"><strong>Continuation of Labs 1\u20134 \u2192 Part 2 (Input Variables) \u2192 Part 3 (Local Values &amp; Outputs)<\/strong><br><strong>Audience:<\/strong>&nbsp;Beginners moving toward intermediate Terraform<br><strong>Terraform CLI:<\/strong>&nbsp;1.15.9+ (Terraform 1.x), as in the previous lab<br><strong>AzureRM provider:<\/strong>&nbsp;<code>~&gt; 5.9.0<\/code>, as in the previous lab<br><strong>Lab region:<\/strong>&nbsp;<code>eastus<\/code><br><strong>Updated:<\/strong>&nbsp;13 October 2026<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">1. Objective and non-negotiable rules<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Take the&nbsp;<strong>complete Part 3 code<\/strong>&nbsp;and introduce all seven requested concepts&nbsp;<strong>without creating any additional Azure resources<\/strong>. Keep the&nbsp;<strong>same resource group, VNet, subnet, NSG, two NSG rules, two public IPs, two network interfaces, Windows Server 2025 VM, and Ubuntu 26.04 VM<\/strong>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What stays unchanged:<\/strong>&nbsp;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).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What changes in this lesson:<\/strong>&nbsp;The two separate public-IP blocks become&nbsp;<strong>one resource block with&nbsp;<code>count = 2<\/code><\/strong>; the two NSG-rule blocks become&nbsp;<strong>one resource block with&nbsp;<code>for_each<\/code><\/strong>. Four&nbsp;<code>moved<\/code>&nbsp;blocks instruct Terraform to&nbsp;<strong>relabel existing state addresses<\/strong>, 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&nbsp;<code>providers<\/code>, we add a&nbsp;<strong>tiny child module that declares no Azure resources<\/strong>.<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\"><strong>Critical:<\/strong>&nbsp;Continue in the&nbsp;<strong>same Terraform root working directory and state<\/strong>&nbsp;from Part 3; do not create a fresh state for already-running infrastructure. Do not change your working&nbsp;<code>terraform.tfvars<\/code>.&nbsp;<strong>Never apply a plan with unexpected resource creation, replacement, or destruction.<\/strong><\/p>\n<\/blockquote>\n\n\n\n<h3 class=\"wp-block-heading\">What you will learn<\/h3>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th class=\"has-text-align-left\" data-align=\"left\">Concept<\/th><th class=\"has-text-align-left\" data-align=\"left\">Purpose<\/th><th class=\"has-text-align-left\" data-align=\"left\">Working example in this lab<\/th><th class=\"has-text-align-right\" data-align=\"right\">Azure objects added<\/th><\/tr><\/thead><tbody><tr><td><code>count<\/code><\/td><td>Repeat a resource by an integer index<\/td><td>Existing Windows\/Ubuntu Public IPs:&nbsp;<code>count = 2<\/code><\/td><td class=\"has-text-align-right\" data-align=\"right\">0<\/td><\/tr><tr><td><code>for_each<\/code><\/td><td>Repeat a resource by stable map\/set keys<\/td><td>Existing RDP\/SSH NSG rules: keys&nbsp;<code>\"rdp\"<\/code>,&nbsp;<code>\"ssh\"<\/code><\/td><td class=\"has-text-align-right\" data-align=\"right\">0<\/td><\/tr><tr><td><code>depends_on<\/code><\/td><td>Add ordering that references do not express<\/td><td>VMs wait for NSG association and correct security rule<\/td><td class=\"has-text-align-right\" data-align=\"right\">0<\/td><\/tr><tr><td><code>provider<\/code><\/td><td>Select an individual provider configuration<\/td><td>Existing NSG uses&nbsp;<code>provider = azurerm.lab<\/code><\/td><td class=\"has-text-align-right\" data-align=\"right\">0<\/td><\/tr><tr><td><code>lifecycle<\/code><\/td><td>Alter how Terraform handles a resource<\/td><td>Protect the existing resource group using&nbsp;<code>prevent_destroy<\/code><\/td><td class=\"has-text-align-right\" data-align=\"right\">0<\/td><\/tr><tr><td><code>provisioner<\/code><\/td><td>Run a command in a creation\/destroy lifecycle phase<\/td><td>Ubuntu VM&nbsp;<code>local-exec<\/code>&nbsp;prints a message on&nbsp;<strong>new VM creation only<\/strong><\/td><td class=\"has-text-align-right\" data-align=\"right\">0<\/td><\/tr><tr><td><code>providers<\/code><\/td><td>Pass provider configurations to a child module<\/td><td><code>module \"provider_demo\"<\/code>&nbsp;receives&nbsp;<code>azurerm.lab<\/code><\/td><td class=\"has-text-align-right\" data-align=\"right\">0<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Terminology:<\/strong>&nbsp;<code>provisioner<\/code>&nbsp;is actually a nested&nbsp;<strong>block<\/strong>, not one of the core meta-arguments in HashiCorp&#8217;s classification. It is taught here because it appears on your requested syllabus.&nbsp;<strong><code>providers<\/code>&nbsp;works only inside a&nbsp;<code>module<\/code>&nbsp;block<\/strong>&nbsp;\u2014 placing it inside a&nbsp;<code>resource<\/code>&nbsp;block is invalid. The no-resource module in this lesson is the smallest standalone, syntactically valid illustration of it.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Part 3 \u2192 Part 4 resource inventory<\/h3>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th class=\"has-text-align-left\" data-align=\"left\">Resource<\/th><th class=\"has-text-align-left\" data-align=\"left\">Part 3<\/th><th class=\"has-text-align-left\" data-align=\"left\">Part 4<\/th><\/tr><\/thead><tbody><tr><td>Resource group<\/td><td>1 resource block<\/td><td>1 resource block<\/td><\/tr><tr><td>Virtual network<\/td><td>1<\/td><td>1<\/td><\/tr><tr><td>Subnet<\/td><td>1<\/td><td>1<\/td><\/tr><tr><td>Network security group<\/td><td>1<\/td><td>1<\/td><\/tr><tr><td>NSG\/subnet association<\/td><td>1<\/td><td>1<\/td><\/tr><tr><td>RDP + SSH rules<\/td><td>2 separate blocks<\/td><td>1&nbsp;<code>for_each<\/code>&nbsp;block \u2192 2 instances<\/td><\/tr><tr><td>Windows + Ubuntu public IPs<\/td><td>2 separate blocks<\/td><td>1&nbsp;<code>count<\/code>&nbsp;block \u2192 2 instances<\/td><\/tr><tr><td>Windows + Ubuntu NICs<\/td><td>2 blocks<\/td><td>2 blocks<\/td><\/tr><tr><td>Windows + Ubuntu VMs<\/td><td>2 blocks<\/td><td>2 blocks<\/td><\/tr><tr><td><strong>Total Azure objects managed<\/strong><\/td><td><strong>13<\/strong><\/td><td><strong>13<\/strong><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The Terraform root file contains&nbsp;<strong>11&nbsp;<code>resource<\/code>&nbsp;blocks<\/strong>&nbsp;in Part 4, but they create\/manage&nbsp;<strong>13 instances<\/strong>.&nbsp;<code>count<\/code>&nbsp;and&nbsp;<code>for_each<\/code>&nbsp;are why block count and instance count are not the same.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">2. Folder layout<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Keep the Part 3 root folder and state. The only new folder is a tiny Terraform child-module example, which&nbsp;<strong>does not contain resources<\/strong>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>azure-terraform-labs\/\n\u251c\u2500\u2500 main.tf                            # FULL consolidated Part 4 root configuration\n\u251c\u2500\u2500 terraform.tfvars                   # SAME values from Part 3 (not a new set)\n\u251c\u2500\u2500 modules\/\n\u2502   \u2514\u2500\u2500 provider-demo\/\n\u2502       \u2514\u2500\u2500 main.tf                    # Child module: required_providers + one output, ZERO resources\n\u251c\u2500\u2500 .terraform.lock.hcl                # Existing lock file\n\u2514\u2500\u2500 terraform.tfstate                  # Existing local state, if you use local state\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Do not put a second copy of Part 3&nbsp;<code>main.tf<\/code>&nbsp;into this directory with a&nbsp;<code>.tf<\/code>&nbsp;extension; Terraform automatically loads every&nbsp;<code>.tf<\/code>&nbsp;file in the root directory, and duplicate declarations fail validation. Backups should use&nbsp;<code>.txt<\/code>&nbsp;or a separate private location.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">3. Understand the safety-critical&nbsp;<code>moved<\/code>&nbsp;blocks&nbsp;<strong>before<\/strong>&nbsp;replacing&nbsp;<code>main.tf<\/code><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">In Part 3 the state contained these four addresses:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th class=\"has-text-align-left\" data-align=\"left\">Part 3 address<\/th><th class=\"has-text-align-left\" data-align=\"left\">Part 4 address<\/th><th class=\"has-text-align-left\" data-align=\"left\">Azure object retained<\/th><\/tr><\/thead><tbody><tr><td><code>azurerm_public_ip.windows<\/code><\/td><td><code>azurerm_public_ip.vm[0]<\/code><\/td><td><code>pip-win2025-lab<\/code><\/td><\/tr><tr><td><code>azurerm_public_ip.ubuntu<\/code><\/td><td><code>azurerm_public_ip.vm[1]<\/code><\/td><td><code>pip-ubuntu2604-lab<\/code><\/td><\/tr><tr><td><code>azurerm_network_security_rule.rdp<\/code><\/td><td><code>azurerm_network_security_rule.admin[\"rdp\"]<\/code><\/td><td><code>Allow-RDP<\/code><\/td><\/tr><tr><td><code>azurerm_network_security_rule.ssh<\/code><\/td><td><code>azurerm_network_security_rule.admin[\"ssh\"]<\/code><\/td><td><code>Allow-SSH<\/code><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">A&nbsp;<code>moved<\/code>&nbsp;block is Terraform&#8217;s native way to teach it that&nbsp;<strong>the address changed but the remote Azure resource did not<\/strong>. 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&nbsp;<code>moved<\/code>&nbsp;blocks when you first upgrade the configuration.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This lab fixes the index order as&nbsp;<strong>Windows = 0, Ubuntu = 1<\/strong>; it must stay stable.&nbsp;<code>for_each<\/code>&nbsp;avoids that index sensitivity by using meaningful keys.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">4. FULL&nbsp;<code>main.tf<\/code>&nbsp;\u2014 replace the Part 3 root configuration with this file<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This is a&nbsp;<strong>complete root configuration<\/strong>, 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.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># ============================================\n# PART 4 - TERRAFORM META-ARGUMENTS (SAME 13 AZURE RESOURCES)\n# Keeps the same 13 resources and images from Labs 1-4\n# ============================================\n\nterraform {\n  required_version = \"&gt;= 1.15.0, &lt; 2.0.0\"\n\n  required_providers {\n    azurerm = {\n      source  = \"hashicorp\/azurerm\"\n      version = \"~&gt; 5.9.0\"\n    }\n  }\n}\n\n# ============================================================\n# INPUT VARIABLES FROM PART 2 (ALL SIX TYPES)\n# ============================================================\n\n# STRING - Azure region and VM settings\nvariable \"azure_region\" {\n  description = \"Azure region for every resource in this lab.\"\n  type        = string\n}\n\nvariable \"vm_size\" {\n  description = \"Size used by both virtual machines.\"\n  type        = string\n}\n\nvariable \"admin_username\" {\n  description = \"Local administrator username on both VMs.\"\n  type        = string\n}\n\nvariable \"windows_admin_password\" {\n  description = \"Administrator password for the Windows Server VM.\"\n  type        = string\n  sensitive   = true\n}\n\nvariable \"ubuntu_admin_password\" {\n  description = \"Administrator password for the Ubuntu VM.\"\n  type        = string\n  sensitive   = true\n}\n\n# NUMBER - NSG rule priorities\nvariable \"rdp_priority\" {\n  description = \"Inbound RDP rule priority, between 100 and 4096.\"\n  type        = number\n}\n\nvariable \"ssh_priority\" {\n  description = \"Inbound SSH rule priority, between 100 and 4096.\"\n  type        = number\n}\n\n# BOOL - keep Linux password authentication enabled for this lab\nvariable \"disable_linux_password_authentication\" {\n  description = \"Must be false while ubuntu_admin_password is configured.\"\n  type        = bool\n}\n\n# LIST(STRING) - ordered Azure network address prefixes\nvariable \"vnet_address_spaces\" {\n  description = \"Address spaces configured on the Azure virtual network.\"\n  type        = list(string)\n}\n\nvariable \"subnet_address_prefixes\" {\n  description = \"Address prefixes configured on the VM subnet.\"\n  type        = list(string)\n}\n\n# SET(STRING) - unique source CIDRs permitted to use SSH\/RDP\nvariable \"admin_allowed_cidrs\" {\n  description = \"Unique public IPv4 CIDRs allowed to reach SSH and RDP.\"\n  type        = set(string)\n}\n\n# MAP(STRING) - stable names of the existing Azure resources\nvariable \"resource_names\" {\n  description = \"Resource names from Labs 1-4. Keep unchanged to retain state.\"\n  type        = map(string)\n}\n\n# ============================================================\n# LOCAL VALUES - REUSE PART 3 AND PREPARE META-ARGUMENT INPUTS\n# ============================================================\nlocals {\n  # Name and region from the existing input variables.\n  resource_group_name = var.resource_names&#91;\"resource_group\"]\n  azure_location      = var.azure_region\n  vm_size             = var.vm_size\n\n  # Reusable map of the names of the two existing VMs.\n  vm_names = {\n    windows = var.resource_names&#91;\"windows_vm\"]\n    ubuntu  = var.resource_names&#91;\"ubuntu_vm\"]\n  }\n\n  # Turn the input set into a consistently ordered list for both NSG rules.\n  admin_source_cidrs = sort(tolist(var.admin_allowed_cidrs))\n\n  # Reuse the same port definitions in security rules and outputs.\n  access_ports = {\n    rdp = \"3389\"\n    ssh = \"22\"\n  }\n\n  # IMPORTANT: Keep this order stable to preserve the previous IP objects.\n  # count.index 0 = Windows, count.index 1 = Ubuntu.\n  public_ip_names = &#91;\n    var.resource_names&#91;\"windows_public_ip\"],\n    var.resource_names&#91;\"ubuntu_public_ip\"]\n  ]\n\n  # for_each is keyed by name, not by an integer position.\n  # Both rules already existed in Part 3; the moved blocks below\n  # preserve their current Azure objects.\n  admin_rules = {\n    rdp = {\n      name     = var.resource_names&#91;\"rdp_rule\"]\n      port     = local.access_ports.rdp\n      priority = var.rdp_priority\n    }\n    ssh = {\n      name     = var.resource_names&#91;\"ssh_rule\"]\n      port     = local.access_ports.ssh\n      priority = var.ssh_priority\n    }\n  }\n}\n\n# Default provider from Parts 1-3.\nprovider \"azurerm\" {\n  features {}\n}\n\n# META-ARGUMENT: provider (alias configuration).\n# Same subscription\/credentials as default: no second deployment target.\nprovider \"azurerm\" {\n  alias = \"lab\"\n  features {}\n}\n\n# --------------------------------------------\n# 1. Resource Group\n# --------------------------------------------\nresource \"azurerm_resource_group\" \"lab\" {\n  name     = local.resource_group_name\n  location = local.azure_location\n\n  # META-ARGUMENT: lifecycle - protect this resource group from destroy.\n  # Remove this rule before the final end-of-course cleanup.\n  lifecycle {\n    prevent_destroy = true\n  }\n}\n\n# --------------------------------------------\n# 2. Virtual Network\n# --------------------------------------------\nresource \"azurerm_virtual_network\" \"lab\" {\n  name                = var.resource_names&#91;\"virtual_network\"]\n  location            = azurerm_resource_group.lab.location\n  resource_group_name = azurerm_resource_group.lab.name\n\n  address_space = var.vnet_address_spaces\n}\n\n# --------------------------------------------\n# 3. Subnet\n# --------------------------------------------\nresource \"azurerm_subnet\" \"vms\" {\n  name                 = var.resource_names&#91;\"subnet\"]\n  resource_group_name  = azurerm_resource_group.lab.name\n  virtual_network_name = azurerm_virtual_network.lab.name\n\n  address_prefixes = var.subnet_address_prefixes\n}\n\n# --------------------------------------------\n# 4. Network Security Group\n# --------------------------------------------\nresource \"azurerm_network_security_group\" \"lab\" {\n  # META-ARGUMENT: provider - deliberately select the alias.\n  provider = azurerm.lab\n\n  name                = var.resource_names&#91;\"nsg\"]\n  location            = azurerm_resource_group.lab.location\n  resource_group_name = azurerm_resource_group.lab.name\n}\n\n# --------------------------------------------\n# 5. Associate NSG with Subnet\n# --------------------------------------------\nresource \"azurerm_subnet_network_security_group_association\" \"lab\" {\n  subnet_id                 = azurerm_subnet.vms.id\n  network_security_group_id = azurerm_network_security_group.lab.id\n}\n\n# --------------------------------------------\n# 6-7. Two EXISTING NSG rules using for_each\n# --------------------------------------------\nresource \"azurerm_network_security_rule\" \"admin\" {\n  # META-ARGUMENT: for_each - two keyed instances: \"rdp\" and \"ssh\".\n  for_each = local.admin_rules\n\n  name                        = each.value.name\n  priority                    = each.value.priority\n  direction                   = \"Inbound\"\n  access                      = \"Allow\"\n  protocol                    = \"Tcp\"\n  source_port_range           = \"*\"\n  destination_port_range      = each.value.port\n  source_address_prefixes     = local.admin_source_cidrs\n  destination_address_prefix  = \"*\"\n  resource_group_name         = azurerm_resource_group.lab.name\n  network_security_group_name = azurerm_network_security_group.lab.name\n}\n\n# --------------------------------------------\n# 8 and 11. Two EXISTING public IPs using count\n# --------------------------------------------\nresource \"azurerm_public_ip\" \"vm\" {\n  # META-ARGUMENT: count - exactly two existing IP instances.\n  count = length(local.public_ip_names)\n\n  name                = local.public_ip_names&#91;count.index]\n  location            = azurerm_resource_group.lab.location\n  resource_group_name = azurerm_resource_group.lab.name\n\n  allocation_method = \"Static\"\n  sku               = \"Standard\"\n}\n\n# --------------------------------------------\n# 9. Windows VM Network Interface\n# --------------------------------------------\nresource \"azurerm_network_interface\" \"windows\" {\n  name                = var.resource_names&#91;\"windows_nic\"]\n  location            = azurerm_resource_group.lab.location\n  resource_group_name = azurerm_resource_group.lab.name\n\n  ip_configuration {\n    name                          = \"internal\"\n    subnet_id                     = azurerm_subnet.vms.id\n    private_ip_address_allocation = \"Dynamic\"\n    public_ip_address_id          = azurerm_public_ip.vm&#91;0].id\n  }\n}\n\n# --------------------------------------------\n# 10. Windows Server 2025 VM\n# --------------------------------------------\nresource \"azurerm_windows_virtual_machine\" \"windows\" {\n  name                = local.vm_names.windows\n  location            = azurerm_resource_group.lab.location\n  resource_group_name = azurerm_resource_group.lab.name\n\n  size           = local.vm_size\n  admin_username = var.admin_username\n\n  # Keep the same Windows password used when the VM was created\n  admin_password = var.windows_admin_password\n\n  network_interface_ids = &#91;\n    azurerm_network_interface.windows.id\n  ]\n\n  # META-ARGUMENT: depends_on - configure network security first.\n  depends_on = &#91;\n    azurerm_subnet_network_security_group_association.lab,\n    azurerm_network_security_rule.admin&#91;\"rdp\"]\n  ]\n\n  os_disk {\n    caching              = \"ReadWrite\"\n    storage_account_type = \"Standard_LRS\"\n  }\n\n  source_image_reference {\n    publisher = \"MicrosoftWindowsServer\"\n    offer     = \"WindowsServer\"\n    sku       = \"2025-datacenter\"\n    version   = \"latest\"\n  }\n}\n\n# --------------------------------------------\n# 12. Ubuntu VM Network Interface\n# --------------------------------------------\nresource \"azurerm_network_interface\" \"ubuntu\" {\n  name                = var.resource_names&#91;\"ubuntu_nic\"]\n  location            = azurerm_resource_group.lab.location\n  resource_group_name = azurerm_resource_group.lab.name\n\n  ip_configuration {\n    name                          = \"internal\"\n    subnet_id                     = azurerm_subnet.vms.id\n    private_ip_address_allocation = \"Dynamic\"\n    public_ip_address_id          = azurerm_public_ip.vm&#91;1].id\n  }\n}\n\n# --------------------------------------------\n# 13. Ubuntu Server 26.04 LTS VM\n# --------------------------------------------\nresource \"azurerm_linux_virtual_machine\" \"ubuntu\" {\n  name                = local.vm_names.ubuntu\n  location            = azurerm_resource_group.lab.location\n  resource_group_name = azurerm_resource_group.lab.name\n\n  size           = local.vm_size\n  admin_username = var.admin_username\n\n  # Training only - supplied through terraform.tfvars; keep secret\n  admin_password = var.ubuntu_admin_password\n\n  # Enable SSH password authentication\n  disable_password_authentication = var.disable_linux_password_authentication\n\n  network_interface_ids = &#91;\n    azurerm_network_interface.ubuntu.id\n  ]\n\n  # META-ARGUMENT: depends_on - explicitly wait for SSH\/network security.\n  depends_on = &#91;\n    azurerm_subnet_network_security_group_association.lab,\n    azurerm_network_security_rule.admin&#91;\"ssh\"]\n  ]\n\n  # OS Disk\n  os_disk {\n    caching              = \"ReadWrite\"\n    storage_account_type = \"Standard_LRS\"\n  }\n\n  # Ubuntu Server 26.04 LTS Gen2 Image\n  source_image_reference {\n    publisher = \"Canonical\"\n    offer     = \"ubuntu-26_04-lts\"\n    sku       = \"server\"\n    version   = \"latest\"\n  }\n\n  # PROVISIONER: local-exec runs on the local Terraform execution machine,\n  # ONLY if this VM is newly created (not for a pre-existing Part 3 VM).\n  # This demo only echoes a message; it never touches the VM or secrets.\n  provisioner \"local-exec\" {\n    command = \"echo 'Terraform created the Ubuntu lab VM.'\"\n  }\n}\n\n# ============================================================\n# MOVED BLOCKS - STATE ADDRESSES ONLY; NO AZURE OBJECT CREATION\n# ============================================================\n# Migrate prior Lab 3\/Part 3 addresses into the new count and for_each.\nmoved {\n  from = azurerm_public_ip.windows\n  to   = azurerm_public_ip.vm&#91;0]\n}\n\nmoved {\n  from = azurerm_public_ip.ubuntu\n  to   = azurerm_public_ip.vm&#91;1]\n}\n\nmoved {\n  from = azurerm_network_security_rule.rdp\n  to   = azurerm_network_security_rule.admin&#91;\"rdp\"]\n}\n\nmoved {\n  from = azurerm_network_security_rule.ssh\n  to   = azurerm_network_security_rule.admin&#91;\"ssh\"]\n}\n\n# ============================================================\n# PROVIDERS META-ARGUMENT - PASS ALIAS INTO A NO-RESOURCE MODULE\n# ============================================================\nmodule \"provider_demo\" {\n  source = \".\/modules\/provider-demo\"\n\n  # META-ARGUMENT: providers is valid on module blocks, not resources.\n  providers = {\n    azurerm = azurerm.lab\n  }\n}\n\n# ============================================================\n# PART 3 OUTPUT VALUES (PRESERVED) + PART 4 TEACHING OUTPUTS\n# ============================================================\n\n# Single string output: Resource Group name\noutput \"resource_group_name\" {\n  description = \"Name of the existing Azure resource group.\"\n  value       = azurerm_resource_group.lab.name\n}\n\n# Single string output: VNet name\noutput \"virtual_network_name\" {\n  description = \"Name of the lab virtual network.\"\n  value       = azurerm_virtual_network.lab.name\n}\n\n# Single string output: Subnet name\noutput \"subnet_name\" {\n  description = \"Name of the VM subnet.\"\n  value       = azurerm_subnet.vms.name\n}\n\n# Map\/object output: both VM names\noutput \"vm_names\" {\n  description = \"Windows and Ubuntu VM names, grouped by operating system.\"\n  value       = local.vm_names\n}\n\n# Computed Azure IP addresses (available once resources exist)\noutput \"windows_public_ip\" {\n  description = \"Public IPv4 address of the Windows Server VM.\"\n  value       = azurerm_public_ip.vm&#91;0].ip_address\n}\n\noutput \"ubuntu_public_ip\" {\n  description = \"Public IPv4 address of the Ubuntu Server VM.\"\n  value       = azurerm_public_ip.vm&#91;1].ip_address\n}\n\n# Computed text outputs: connection hints (no passwords)\noutput \"windows_rdp_target\" {\n  description = \"RDP target for Windows Remote Desktop.\"\n  value       = \"${azurerm_public_ip.vm&#91;0].ip_address}:${local.access_ports.rdp}\"\n}\n\noutput \"ubuntu_ssh_command\" {\n  description = \"SSH command to access Ubuntu from an authorized IP address.\"\n  value       = \"ssh ${var.admin_username}@${azurerm_public_ip.vm&#91;1].ip_address}\"\n}\n\n# List output: stable, sorted CIDRs used by both NSG rules\noutput \"allowed_admin_cidrs\" {\n  description = \"Sorted source CIDRs authorized for SSH and RDP by the lab NSG.\"\n  value       = local.admin_source_cidrs\n}\n\n\n# Extra outputs show the indexed and keyed meta-argument results.\noutput \"count_public_ip_names\" {\n  description = \"Names of the two existing public IPs managed by count.\"\n  value       = &#91;for ip in azurerm_public_ip.vm : ip.name]\n}\n\noutput \"for_each_admin_rule_names\" {\n  description = \"Names of the two existing admin rules managed by for_each.\"\n  value       = { for key, rule in azurerm_network_security_rule.admin : key =&gt; rule.name }\n}\n\noutput \"provider_demo_note\" {\n  description = \"Message from a child module receiving azurerm.lab.\"\n  value       = module.provider_demo.note\n}\n\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">5. FULL&nbsp;<code>terraform.tfvars<\/code>&nbsp;\u2014&nbsp;<strong>keep your actual Part 3 values<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Existing deployment:<\/strong>&nbsp;Do&nbsp;<strong>not<\/strong>&nbsp;overwrite your real&nbsp;<code>terraform.tfvars<\/code>&nbsp;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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Fresh training subscription:<\/strong>&nbsp;The example below is a template; before creating resources, replace&nbsp;<code>203.0.113.10\/32<\/code>&nbsp;with&nbsp;<strong>your<\/strong>&nbsp;public IPv4&nbsp;<code>\/32<\/code>, and replace both demonstration passwords with unique strong passwords.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># ============================================================\n# PART 2 - TERRAFORM VARIABLE VALUES\n# Save as terraform.tfvars in the SAME directory as main.tf.\n# IMPORTANT: Preserve the actual values used in Labs 1-4.\n# ============================================================\n\n# STRING\nazure_region  = \"eastus\"\nvm_size       = \"Standard_B2s_v2\"\nadmin_username = \"labadmin\"\n\n# Replace these EXAMPLE passwords with the ones used in Lab 3 and Lab 4.\n# If the VMs already exist, changing the passwords can replace the VMs.\nwindows_admin_password = \"ChangeMe!2026WinLab#\"\nubuntu_admin_password  = \"ChangeMe!2026LinuxLab#\"\n\n# NUMBER: unique NSG priorities (100-4096)\nrdp_priority = 100\nssh_priority = 110\n\n# BOOL: must remain false for Ubuntu password-based login\ndisable_linux_password_authentication = false\n\n# LIST(STRING): ordered collections\nvnet_address_spaces      = &#91;\"10.20.0.0\/16\"]\nsubnet_address_prefixes  = &#91;\"10.20.1.0\/24\"]\n\n# SET(STRING): Terraform converts this list-like literal to a set.\n# REPLACE 203.0.113.10\/32 with YOUR public IPv4 address\/32.\n# The example address is reserved for documentation, not your real IP.\nadmin_allowed_cidrs = &#91;\"203.0.113.10\/32\"]\n\n# MAP(STRING): resource names must match the previous labs exactly.\nresource_names = {\n  resource_group    = \"rg-terraform-lab-eastus\"\n  virtual_network   = \"vnet-terraform-lab\"\n  subnet            = \"snet-vms\"\n  nsg               = \"nsg-terraform-lab\"\n  rdp_rule          = \"Allow-RDP\"\n  ssh_rule          = \"Allow-SSH\"\n  windows_public_ip = \"pip-win2025-lab\"\n  windows_nic       = \"nic-win2025-lab\"\n  windows_vm        = \"vm-win2025-01\"\n  ubuntu_public_ip  = \"pip-ubuntu2604-lab\"\n  ubuntu_nic        = \"nic-ubuntu2604-lab\"\n  ubuntu_vm         = \"vm-ubuntu2604-01\"\n}\n\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">6. FULL&nbsp;<code>modules\/provider-demo\/main.tf<\/code>&nbsp;\u2014 no Azure resources<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Create the directory&nbsp;<code>modules\/provider-demo<\/code>&nbsp;and put&nbsp;<strong>exactly the following code<\/strong>&nbsp;in its&nbsp;<code>main.tf<\/code>. It declares the AzureRM provider requirement and an informational output only. The calling module uses&nbsp;<code>providers = { azurerm = azurerm.lab }<\/code>; this is the only correct place for the&nbsp;<code>providers<\/code>&nbsp;meta-argument in this exercise.<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\"><strong>Teaching note:<\/strong>&nbsp;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&nbsp;<em>Modules<\/em>&nbsp;lesson can show a full practical provider mapping. Here we introduce the syntax without violating the no-new-Azure-resources requirement.<\/p>\n<\/blockquote>\n\n\n\n<pre class=\"wp-block-code\"><code># ============================================\n# PART 4 - PROVIDERS MAPPING DEMONSTRATION\n# ZERO AZURE RESOURCES IN THIS CHILD MODULE\n# ============================================\nterraform {\n  required_providers {\n    azurerm = {\n      source  = \"hashicorp\/azurerm\"\n      version = \"~&gt; 5.9.0\"\n    }\n  }\n}\n\n# This module deliberately declares NO resource or data blocks.\n# It exists only to demonstrate the 'providers' meta-argument.\noutput \"note\" {\n  description = \"Proof that the zero-resource child module was evaluated.\"\n  value       = \"The root module passes azurerm.lab as this module's azurerm provider.\"\n}\n\n<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">7. Explanation of each concept using your actual Terraform code<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">7.1&nbsp;<code>count<\/code>&nbsp;\u2014 two existing Public IP addresses<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>resource \"azurerm_public_ip\" \"vm\" {\n  count = length(local.public_ip_names)\n  name  = local.public_ip_names&#91;count.index]\n  # ...same location, resource group, SKU and allocation method...\n}\n<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>local.public_ip_names<\/code>\u00a0is an ordered list of\u00a0<strong>two existing names<\/strong>, so\u00a0<code>count<\/code>\u00a0evaluates to\u00a0<code>2<\/code>.<\/li>\n\n\n\n<li>Terraform creates (or, when migrating state,\u00a0<strong>retains<\/strong>)\u00a0<code>azurerm_public_ip.vm[0]<\/code>\u00a0for Windows and\u00a0<code>azurerm_public_ip.vm[1]<\/code>\u00a0for Ubuntu.<\/li>\n\n\n\n<li>The Windows NIC points to\u00a0<code>azurerm_public_ip.vm[0].id<\/code>; Ubuntu&#8217;s NIC points to\u00a0<code>[1].id<\/code>.<\/li>\n\n\n\n<li><strong>Pitfall:<\/strong>\u00a0Reordering list elements can change what an index means, potentially forcing replacements. Do not reorder this lab&#8217;s names.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">7.2&nbsp;<code>for_each<\/code>&nbsp;\u2014 the two existing NSG inbound rules<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>resource \"azurerm_network_security_rule\" \"admin\" {\n  for_each = local.admin_rules\n  name     = each.value.name\n  priority = each.value.priority\n  # destination_port_range = each.value.port\n  # ...same other fields as Part 3...\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The map is calculated in the existing&nbsp;<code>locals<\/code>&nbsp;block:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>admin_rules = {\n  rdp = {\n    name     = var.resource_names&#91;\"rdp_rule\"]\n    port     = local.access_ports.rdp\n    priority = var.rdp_priority\n  }\n  ssh = {\n    name     = var.resource_names&#91;\"ssh_rule\"]\n    port     = local.access_ports.ssh\n    priority = var.ssh_priority\n  }\n}\n<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>each.key<\/code>\u00a0is\u00a0<code>\"rdp\"<\/code>\u00a0or\u00a0<code>\"ssh\"<\/code>;\u00a0<code>each.value<\/code>\u00a0contains the rule&#8217;s name, port and priority.<\/li>\n\n\n\n<li>Addresses become\u00a0<code>azurerm_network_security_rule.admin[\"rdp\"]<\/code>\u00a0and\u00a0<code>azurerm_network_security_rule.admin[\"ssh\"]<\/code>.<\/li>\n\n\n\n<li><strong>Difference:<\/strong>\u00a0<code>count<\/code>\u00a0uses numeric indexes;\u00a0<code>for_each<\/code>\u00a0uses stable keys. You cannot put both on the\u00a0<strong>same resource block<\/strong>.<\/li>\n\n\n\n<li>The NSG is associated with the\u00a0<strong>entire VM subnet<\/strong>. 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\u00a0<code>\/32<\/code>\u00a0addresses.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">7.3&nbsp;<code>depends_on<\/code>&nbsp;\u2014 wait until access configuration exists<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code># Inside the Windows VM resource:\ndepends_on = &#91;\n  azurerm_subnet_network_security_group_association.lab,\n  azurerm_network_security_rule.admin&#91;\"rdp\"]\n]\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Terraform already detects most relationships from references such as&nbsp;<code>subnet_id = azurerm_subnet.vms.id<\/code>. Here the Windows VM doesn&#8217;t directly reference the&nbsp;<strong>NSG association<\/strong>&nbsp;or&nbsp;<strong>inbound RDP rule<\/strong>&nbsp;in its VM attributes, but we want them configured first.&nbsp;<code>depends_on<\/code>&nbsp;makes that order explicit. Ubuntu similarly depends on the association and SSH rule.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Avoid excessive&nbsp;<code>depends_on<\/code>:<\/strong>&nbsp;Use it when the implicit dependency graph does not capture a genuine requirement.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">7.4&nbsp;<code>provider<\/code>&nbsp;\u2014 explicitly choose an aliased AzureRM configuration<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>provider \"azurerm\" {\n  features {}\n}\n\nprovider \"azurerm\" {\n  alias = \"lab\"\n  features {}\n}\n\nresource \"azurerm_network_security_group\" \"lab\" {\n  provider = azurerm.lab\n  # ...unchanged NSG arguments...\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The original default provider remains available. The&nbsp;<code>azurerm.lab<\/code>&nbsp;alias authenticates to&nbsp;<strong>the same Azure subscription<\/strong>&nbsp;via the same environment-based credentials in this lab; it is not a second region, account, or deployment. We select it explicitly on the&nbsp;<strong>existing<\/strong>&nbsp;NSG resource. The provider alias is a Terraform configuration, not a newly created Azure resource.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A real multi-subscription example would need explicit per-provider subscription and authentication configuration and is intentionally&nbsp;<strong>out of scope<\/strong>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">7.5&nbsp;<code>lifecycle<\/code>&nbsp;\u2014 stop accidental Resource Group destruction<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>resource \"azurerm_resource_group\" \"lab\" {\n  # ...same name and location...\n  lifecycle {\n    prevent_destroy = true\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The&nbsp;<code>prevent_destroy<\/code>&nbsp;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.&nbsp;<strong><code>terraform destroy<\/code>&nbsp;will fail until you intentionally remove or disable this rule.<\/strong>&nbsp;Deleting a resource block altogether is&nbsp;<strong>not<\/strong>&nbsp;protected by&nbsp;<code>prevent_destroy<\/code>; it only works while the block with the rule remains in the configuration.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Why not&nbsp;<code>create_before_destroy = true<\/code>&nbsp;here? Azure objects often have fixed, unique names, and some VM\/NIC resources cannot coexist as replacements with the same names. Teaching&nbsp;<code>create_before_destroy<\/code>&nbsp;on a fixed-name VM would risk an Azure error. We therefore demonstrate the safer&nbsp;<code>prevent_destroy<\/code>&nbsp;rule.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">7.6&nbsp;<code>provisioner \"local-exec\"<\/code>&nbsp;\u2014 run a local command on VM creation<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code># Inside the existing Ubuntu VM block:\nprovisioner \"local-exec\" {\n  command = \"echo 'Terraform created the Ubuntu lab VM.'\"\n}\n<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Runs on the\u00a0<strong>machine executing Terraform<\/strong>\u00a0(your Mac\/CI runner),\u00a0<strong>not inside the Ubuntu VM<\/strong>.<\/li>\n\n\n\n<li>Normally runs\u00a0<strong>only after newly creating<\/strong>\u00a0this VM. Merely adding the provisioner to an\u00a0<strong>already existing<\/strong>\u00a0Ubuntu VM from Part 3\u00a0<strong>does not run it<\/strong>. Do not replace an existing VM merely to see the message.<\/li>\n\n\n\n<li>The example writes only a harmless message. It doesn&#8217;t install software, log in remotely, or access credentials.<\/li>\n\n\n\n<li>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.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">7.7&nbsp;<code>providers<\/code>&nbsp;\u2014 pass a configuration into a child module<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>module \"provider_demo\" {\n  source = \".\/modules\/provider-demo\"\n  providers = {\n    azurerm = azurerm.lab\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Unlike singular&nbsp;<code>provider<\/code>&nbsp;on an individual resource,&nbsp;<strong>plural&nbsp;<code>providers<\/code>&nbsp;belongs in a&nbsp;<code>module<\/code>&nbsp;block<\/strong>. The key&nbsp;<code>azurerm<\/code>&nbsp;represents the name expected inside the child module; the value&nbsp;<code>azurerm.lab<\/code>&nbsp;is the provider configuration from the root. The child declares its own&nbsp;<code>required_providers<\/code>, but&nbsp;<strong>does not define any&nbsp;<code>provider<\/code>&nbsp;block or Azure&nbsp;<code>resource<\/code>&nbsp;block<\/strong>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">7.8&nbsp;<code>moved<\/code>&nbsp;\u2014 the extra state-refactoring concept required for safety<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>moved {\n  from = azurerm_public_ip.windows\n  to   = azurerm_public_ip.vm&#91;0]\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The other three&nbsp;<code>moved<\/code>&nbsp;blocks follow the same pattern. They change Terraform&#8217;s&nbsp;<strong>state addresses<\/strong>, not Azure&#8217;s resource IDs. Terraform plans may display&nbsp;<strong>&#8220;has moved to&#8221;<\/strong>&nbsp;messages; those are&nbsp;<strong>not<\/strong>&nbsp;resource deletion\/re-creation. This is critical when retrofitting&nbsp;<code>count<\/code>&nbsp;and&nbsp;<code>for_each<\/code>&nbsp;onto live infrastructure.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">8. Run the full Part 4 lab (existing Part 3 deployment)<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Step 1 \u2014 Confirm Azure login and state<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>az login\naz account set --subscription \"YOUR_SUBSCRIPTION_ID\"\nexport ARM_SUBSCRIPTION_ID=\"$(az account show --query id -o tsv)\"\n\n<em># Must be in the existing Part 3 working directory.<\/em>\nterraform version\nterraform state list\nterraform output vm_names\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Expect&nbsp;<strong>13 managed resource addresses<\/strong>&nbsp;from Part 3 before editing any code. If&nbsp;<code>terraform state list<\/code>&nbsp;is empty or you get an initialization\/backend error,&nbsp;<strong>stop<\/strong>&nbsp;and locate the correct Part 3 state \u2014 do not apply this lab as though it is new.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 2 \u2014 Take private backups without introducing duplicate&nbsp;<code>.tf<\/code>&nbsp;files<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>cp main.tf main.part3.backup.txt\n<em># Local-state users only (remote-state users follow their backend backup process):<\/em>\ncp terraform.tfstate terraform.tfstate.part3.backup\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 3 \u2014 Replace and add the lesson files<\/h3>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Replace your root\u00a0<code>main.tf<\/code>\u00a0with\u00a0<strong>Section 4<\/strong>\u00a0in this guide.<\/li>\n\n\n\n<li>Keep the already-working\u00a0<code>terraform.tfvars<\/code>. The code in Section 5 is a full\u00a0<em>reference<\/em>, not a request to reset existing passwords or IP allowlists.<\/li>\n\n\n\n<li>Create\u00a0<code>modules\/provider-demo\/main.tf<\/code>\u00a0from Section 6. This child module creates\u00a0<strong>zero<\/strong>\u00a0Azure resources.<\/li>\n\n\n\n<li>Do not modify the four\u00a0<code>moved<\/code>\u00a0blocks, the Windows\/Ubuntu index order, or the original resource names.<\/li>\n<\/ol>\n\n\n\n<h3 class=\"wp-block-heading\">Step 4 \u2014 Initialize, format, validate, and review<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform init\nterraform fmt -recursive\nterraform validate\nterraform plan -out=lab-part4.tfplan\nterraform show lab-part4.tfplan\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Expected for a successful Part 3 \u2192 Part 4 conversion with unchanged&nbsp;<code>.tfvars<\/code>:<\/strong>&nbsp;Four resource-address&nbsp;<strong>moves<\/strong>&nbsp;in state, the same 13 Azure objects, and three new informational root outputs. Terraform may show the alias assignment in state. There should be&nbsp;<strong>no intentional Azure resource creation, deletion or replacement<\/strong>. A real&nbsp;<code>plan<\/code>&nbsp;is authoritative:&nbsp;<em>if any Azure object shows<\/em>&nbsp;<code>+ create<\/code>,&nbsp;<code>- destroy<\/code>, or&nbsp;<code>-\/+ replace<\/code>,&nbsp;<strong>do not apply<\/strong>. Investigate the changed names, provider settings, secrets, resource arguments and move mapping first. Even unexpected in-place updates should be reviewed.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 5 \u2014 Apply only the reviewed plan<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform apply lab-part4.tfplan\nterraform state list\nterraform output\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 6 \u2014 Check the changed Terraform addresses<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">After applying,&nbsp;<code>terraform state list<\/code>&nbsp;should contain the following&nbsp;<strong>13 resource addresses<\/strong>, possibly in a different display order:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>azurerm_resource_group.lab\nazurerm_virtual_network.lab\nazurerm_subnet.vms\nazurerm_network_security_group.lab\nazurerm_subnet_network_security_group_association.lab\nazurerm_network_security_rule.admin&#91;\"rdp\"]\nazurerm_network_security_rule.admin&#91;\"ssh\"]\nazurerm_public_ip.vm&#91;0]\nazurerm_public_ip.vm&#91;1]\nazurerm_network_interface.windows\nazurerm_network_interface.ubuntu\nazurerm_windows_virtual_machine.windows\nazurerm_linux_virtual_machine.ubuntu\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Verify the output values:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform output count_public_ip_names\nterraform output for_each_admin_rule_names\nterraform output provider_demo_note\nterraform output -raw windows_public_ip\nterraform output -raw ubuntu_ssh_command\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Expected&nbsp;<strong>names<\/strong>&nbsp;(public IP values are retrieved from Azure and will differ):<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>count_public_ip_names = &#91;\"pip-win2025-lab\", \"pip-ubuntu2604-lab\"]\nfor_each_admin_rule_names = {rdp = \"Allow-RDP\", ssh = \"Allow-SSH\"}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The&nbsp;<code>provider_demo_note<\/code>&nbsp;output is a static explanatory message, not proof of an Azure API call. Students can inspect resource and module provider requirements with:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform providers\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">9. If students start with an empty state instead<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The same three files can be used for a&nbsp;<strong>fresh classroom deployment<\/strong>&nbsp;into an empty training subscription, after preparing&nbsp;<code>terraform.tfvars<\/code>, quota, and access. Terraform simply creates the&nbsp;<strong>same 13 Azure objects<\/strong>;&nbsp;<code>moved<\/code>&nbsp;blocks do nothing when their old addresses are absent from state. The&nbsp;<code>local-exec<\/code>&nbsp;echo runs after the&nbsp;<strong>new Ubuntu VM<\/strong>&nbsp;is created.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">However, if resources with the lab names&nbsp;<strong>already exist in Azure but are not in your Terraform state<\/strong>, 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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">10. Practice exercises<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Run\u00a0<code>terraform state list<\/code>\u00a0and identify the two indexed\u00a0<code>count<\/code>\u00a0instances versus the two keyed\u00a0<code>for_each<\/code>\u00a0instances.<\/li>\n\n\n\n<li>Inspect\u00a0<code>local.public_ip_names<\/code>\u00a0and explain why Windows is index\u00a0<code>0<\/code>\u00a0and Ubuntu is index\u00a0<code>1<\/code>.<\/li>\n\n\n\n<li>Explain why the RDP and SSH rules use different\u00a0<code>for_each<\/code>\u00a0keys even though they share the same subnet-level NSG.<\/li>\n\n\n\n<li>Identify what\u00a0<code>depends_on<\/code>\u00a0guarantees before the Windows VM is created.<\/li>\n\n\n\n<li>Explain which provider configuration manages the NSG and why using an alias does not add a new Azure subscription.<\/li>\n\n\n\n<li>Explain why\u00a0<code>provisioner \"local-exec\"<\/code>\u00a0did not print anything when converting a previously deployed Part 3 VM.<\/li>\n\n\n\n<li>Explain why\u00a0<code>terraform destroy<\/code>\u00a0is blocked by the Resource Group&#8217;s\u00a0<code>prevent_destroy = true<\/code>\u00a0rule.<\/li>\n\n\n\n<li>Explain why the\u00a0<code>providers<\/code>\u00a0meta-argument cannot be placed on the\u00a0<code>azurerm_linux_virtual_machine<\/code>\u00a0resource.<\/li>\n<\/ol>\n\n\n\n<h2 class=\"wp-block-heading\">11. Common errors and fixes<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th class=\"has-text-align-left\" data-align=\"left\">Symptom<\/th><th class=\"has-text-align-left\" data-align=\"left\">Cause<\/th><th class=\"has-text-align-left\" data-align=\"left\">Fix<\/th><\/tr><\/thead><tbody><tr><td><code>Module not installed<\/code><\/td><td><code>modules\/provider-demo\/main.tf<\/code>&nbsp;missing or&nbsp;<code>terraform init<\/code>&nbsp;not run<\/td><td>Create the child module file and run&nbsp;<code>terraform init<\/code>&nbsp;again<\/td><\/tr><tr><td><code>Reference to undeclared resource<\/code>&nbsp;for&nbsp;<code>azurerm_public_ip.windows<\/code><\/td><td>Old reference left behind after changing to&nbsp;<code>count<\/code><\/td><td>Refer to&nbsp;<code>azurerm_public_ip.vm[0]<\/code>&nbsp;or&nbsp;<code>[1]<\/code><\/td><\/tr><tr><td><code>Missing resource instance key<\/code><\/td><td>Referring to a&nbsp;<code>count<\/code>\/<code>for_each<\/code>&nbsp;resource without&nbsp;<code>[index]<\/code>&nbsp;or&nbsp;<code>[\"key\"]<\/code><\/td><td>Use&nbsp;<code>azurerm_public_ip.vm[0]<\/code>&nbsp;or&nbsp;<code>azurerm_network_security_rule.admin[\"rdp\"]<\/code><\/td><\/tr><tr><td>Unexpected destroy\/create of NSG rules or public IPs<\/td><td><code>moved<\/code>&nbsp;blocks missing or edited, wrong root state or reordered list<\/td><td>Restore correct&nbsp;<code>moved<\/code>&nbsp;blocks, correct state, and original name order; do not apply<\/td><\/tr><tr><td>Provider configuration not present<\/td><td>Removed or mistyped&nbsp;<code>azurerm.lab<\/code>&nbsp;provider alias<\/td><td>Retain both provider blocks and&nbsp;<code>provider = azurerm.lab<\/code><\/td><\/tr><tr><td><code>Invalid provider configuration reference<\/code>&nbsp;for&nbsp;<code>providers<\/code><\/td><td>Used&nbsp;<code>providers<\/code>&nbsp;on a resource or mapped alias incorrectly<\/td><td>Keep the&nbsp;<code>providers<\/code>&nbsp;map in the&nbsp;<code>module<\/code>&nbsp;block only<\/td><\/tr><tr><td><code>Instance cannot be destroyed<\/code>&nbsp;during end-of-course cleanup<\/td><td><code>lifecycle.prevent_destroy = true<\/code><\/td><td>Deliberately disable the rule before destroying the full training environment<\/td><\/tr><tr><td><code>local-exec<\/code>&nbsp;message did not appear<\/td><td>Ubuntu VM was already in state<\/td><td>Expected; provisioners do not run retroactively. Do not force VM replacement for this<\/td><\/tr><tr><td>Can&#8217;t reach RDP\/SSH<\/td><td><code>203.0.113.10\/32<\/code>&nbsp;placeholder, wrong admin IP or network restriction<\/td><td>Use your own trusted IPv4&nbsp;<code>\/32<\/code>, verify NSG\/host rules, then connect<\/td><\/tr><tr><td>Password\/state exposure<\/td><td>Plaintext values committed to a repository<\/td><td>Protect&nbsp;<code>terraform.tfvars<\/code>, state, plan files and backups; keep secrets out of public repos<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">12. Safe cleanup at the&nbsp;<strong>end<\/strong>&nbsp;of the entire course<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Because this lab teaches&nbsp;<code>prevent_destroy = true<\/code>, a direct&nbsp;<code>terraform destroy<\/code>&nbsp;is&nbsp;<strong>intentionally blocked<\/strong>. When the whole course is finished and you truly intend to delete every managed Azure object:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Edit only\u00a0<code>azurerm_resource_group.lab<\/code>\u00a0in\u00a0<code>main.tf<\/code>: change\u00a0<code>prevent_destroy = true<\/code>\u00a0to\u00a0<code>prevent_destroy = false<\/code>\u00a0(or remove that lifecycle block).<\/li>\n\n\n\n<li>Keep all resource blocks and moved blocks in place so Terraform still knows about each managed resource.<\/li>\n\n\n\n<li>Review destruction, then explicitly confirm:<\/li>\n<\/ol>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform plan -destroy\nterraform destroy\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Destroying removes both VMs, both Public IPs, network objects, and the Resource Group. The child module and&nbsp;<code>moved<\/code>\/<code>locals<\/code>\/<code>output<\/code>&nbsp;blocks do not correspond to separately billable Azure resources. VM compute and public IP addresses may cost money until destroyed.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">13. Final success checklist<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] Correct existing Azure subscription, working directory and Terraform state.<\/li>\n\n\n\n<li>[ ] All\u00a0<strong>12 input variables<\/strong>\u00a0still declared, and the same working\u00a0<code>terraform.tfvars<\/code>\u00a0used.<\/li>\n\n\n\n<li>[ ] All\u00a0<strong>13 previously managed Azure objects<\/strong>\u00a0retained; no extra Azure resources.<\/li>\n\n\n\n<li>[ ]\u00a0<code>count<\/code>\u00a0produces two indexed public-IP resource instances.<\/li>\n\n\n\n<li>[ ]\u00a0<code>for_each<\/code>\u00a0produces two keyed security-rule instances.<\/li>\n\n\n\n<li>[ ] Four\u00a0<code>moved<\/code>\u00a0blocks preserve pre-existing Terraform state bindings.<\/li>\n\n\n\n<li>[ ]\u00a0<code>depends_on<\/code>\u00a0explicitly models the network-security ordering for each VM.<\/li>\n\n\n\n<li>[ ]\u00a0<code>provider = azurerm.lab<\/code>\u00a0works with an aliased provider configured for the same subscription.<\/li>\n\n\n\n<li>[ ]\u00a0<code>lifecycle.prevent_destroy<\/code>\u00a0protects the Resource Group while present.<\/li>\n\n\n\n<li>[ ]\u00a0<code>provisioner \"local-exec\"<\/code>\u00a0is harmless and correctly documented as creation-time only.<\/li>\n\n\n\n<li>[ ] The\u00a0<code>providers<\/code>\u00a0map is in a child-module call; the child module creates zero Azure resources.<\/li>\n\n\n\n<li>[ ] All nine old outputs still exist; three additional teaching outputs work.<\/li>\n\n\n\n<li>[ ]\u00a0<code>terraform init<\/code>,\u00a0<code>terraform fmt -recursive<\/code>,\u00a0<code>terraform validate<\/code>\u00a0succeed on the student&#8217;s machine; the plan contains no unintended Azure operations.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">Official documentation<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/developer.hashicorp.com\/terraform\/language\/meta-arguments\">Terraform meta-arguments overview<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/developer.hashicorp.com\/terraform\/language\/meta-arguments\/count\">Terraform\u00a0<code>count<\/code>\u00a0reference<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/developer.hashicorp.com\/terraform\/language\/meta-arguments\/for_each\">Terraform\u00a0<code>for_each<\/code>\u00a0reference<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/developer.hashicorp.com\/terraform\/language\/meta-arguments\/depends_on\">Terraform\u00a0<code>depends_on<\/code>\u00a0reference<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/developer.hashicorp.com\/terraform\/language\/meta-arguments\/provider\">Terraform\u00a0<code>provider<\/code>\u00a0reference<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/developer.hashicorp.com\/terraform\/language\/meta-arguments\/providers\">Terraform\u00a0<code>providers<\/code>\u00a0reference<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/developer.hashicorp.com\/terraform\/language\/meta-arguments\/lifecycle\">Terraform\u00a0<code>lifecycle<\/code>\u00a0reference<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/developer.hashicorp.com\/terraform\/language\/provisioners\">Terraform provisioners reference<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/developer.hashicorp.com\/terraform\/language\/block\/moved\">Terraform moved blocks<\/a><\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Validation transparency:<\/strong>&nbsp;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&nbsp;<code>terraform validate<\/code>, and Azure&nbsp;<code>terraform plan<\/code>\/<code>apply<\/code>&nbsp;have&nbsp;<strong>not<\/strong>&nbsp;been performed here. Consequently, a universal \u201c100% successful deployment\u201d cannot be guaranteed for every subscription and existing state; the included validation-and-review steps are mandatory.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Continuation of Labs 1\u20134 \u2192 Part 2 (Input Variables) \u2192 Part 3 (Local Values &amp; Outputs)Audience:&nbsp;Beginners moving toward intermediate TerraformTerraform CLI:&nbsp;1.15.9+ (Terraform 1.x), as in the previous&#8230; <\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1],"tags":[],"class_list":["post-1231","post","type-post","status-publish","format-standard","hentry","category-uncategorized"],"_links":{"self":[{"href":"https:\/\/www.devopsschool.com\/tutorials\/wp-json\/wp\/v2\/posts\/1231","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.devopsschool.com\/tutorials\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.devopsschool.com\/tutorials\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.devopsschool.com\/tutorials\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.devopsschool.com\/tutorials\/wp-json\/wp\/v2\/comments?post=1231"}],"version-history":[{"count":1,"href":"https:\/\/www.devopsschool.com\/tutorials\/wp-json\/wp\/v2\/posts\/1231\/revisions"}],"predecessor-version":[{"id":1232,"href":"https:\/\/www.devopsschool.com\/tutorials\/wp-json\/wp\/v2\/posts\/1231\/revisions\/1232"}],"wp:attachment":[{"href":"https:\/\/www.devopsschool.com\/tutorials\/wp-json\/wp\/v2\/media?parent=1231"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.devopsschool.com\/tutorials\/wp-json\/wp\/v2\/categories?post=1231"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.devopsschool.com\/tutorials\/wp-json\/wp\/v2\/tags?post=1231"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}