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
| Concept | Purpose | Working example in this lab | Azure objects added |
|---|---|---|---|
count | Repeat a resource by an integer index | Existing Windows/Ubuntu Public IPs: count = 2 | 0 |
for_each | Repeat a resource by stable map/set keys | Existing RDP/SSH NSG rules: keys "rdp", "ssh" | 0 |
depends_on | Add ordering that references do not express | VMs wait for NSG association and correct security rule | 0 |
provider | Select an individual provider configuration | Existing NSG uses provider = azurerm.lab | 0 |
lifecycle | Alter how Terraform handles a resource | Protect the existing resource group using prevent_destroy | 0 |
provisioner | Run a command in a creation/destroy lifecycle phase | Ubuntu VM local-exec prints a message on new VM creation only | 0 |
providers | Pass provider configurations to a child module | module "provider_demo" receives azurerm.lab | 0 |
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
| Resource | Part 3 | Part 4 |
|---|---|---|
| Resource group | 1 resource block | 1 resource block |
| Virtual network | 1 | 1 |
| Subnet | 1 | 1 |
| Network security group | 1 | 1 |
| NSG/subnet association | 1 | 1 |
| RDP + SSH rules | 2 separate blocks | 1 for_each block → 2 instances |
| Windows + Ubuntu public IPs | 2 separate blocks | 1 count block → 2 instances |
| Windows + Ubuntu NICs | 2 blocks | 2 blocks |
| Windows + Ubuntu VMs | 2 blocks | 2 blocks |
| Total Azure objects managed | 13 | 13 |
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 address | Part 4 address | Azure object retained |
|---|---|---|
azurerm_public_ip.windows | azurerm_public_ip.vm[0] | pip-win2025-lab |
azurerm_public_ip.ubuntu | azurerm_public_ip.vm[1] | pip-ubuntu2604-lab |
azurerm_network_security_rule.rdp | azurerm_network_security_rule.admin["rdp"] | Allow-RDP |
azurerm_network_security_rule.ssh | azurerm_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_namesis an ordered list of two existing names, socountevaluates to2.- Terraform creates (or, when migrating state, retains)
azurerm_public_ip.vm[0]for Windows andazurerm_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.keyis"rdp"or"ssh";each.valuecontains the rule’s name, port and priority.- Addresses become
azurerm_network_security_rule.admin["rdp"]andazurerm_network_security_rule.admin["ssh"]. - Difference:
countuses numeric indexes;for_eachuses 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
/32addresses.
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
- Replace your root
main.tfwith Section 4 in this guide. - 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. - Create
modules/provider-demo/main.tffrom Section 6. This child module creates zero Azure resources. - Do not modify the four
movedblocks, 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
- Run
terraform state listand identify the two indexedcountinstances versus the two keyedfor_eachinstances. - Inspect
local.public_ip_namesand explain why Windows is index0and Ubuntu is index1. - Explain why the RDP and SSH rules use different
for_eachkeys even though they share the same subnet-level NSG. - Identify what
depends_onguarantees before the Windows VM is created. - Explain which provider configuration manages the NSG and why using an alias does not add a new Azure subscription.
- Explain why
provisioner "local-exec"did not print anything when converting a previously deployed Part 3 VM. - Explain why
terraform destroyis blocked by the Resource Group’sprevent_destroy = truerule. - Explain why the
providersmeta-argument cannot be placed on theazurerm_linux_virtual_machineresource.
11. Common errors and fixes
| Symptom | Cause | Fix |
|---|---|---|
Module not installed | modules/provider-demo/main.tf missing or terraform init not run | Create the child module file and run terraform init again |
Reference to undeclared resource for azurerm_public_ip.windows | Old reference left behind after changing to count | Refer to azurerm_public_ip.vm[0] or [1] |
Missing resource instance key | Referring 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 IPs | moved blocks missing or edited, wrong root state or reordered list | Restore correct moved blocks, correct state, and original name order; do not apply |
| Provider configuration not present | Removed or mistyped azurerm.lab provider alias | Retain both provider blocks and provider = azurerm.lab |
Invalid provider configuration reference for providers | Used providers on a resource or mapped alias incorrectly | Keep the providers map in the module block only |
Instance cannot be destroyed during end-of-course cleanup | lifecycle.prevent_destroy = true | Deliberately disable the rule before destroying the full training environment |
local-exec message did not appear | Ubuntu VM was already in state | Expected; provisioners do not run retroactively. Do not force VM replacement for this |
| Can’t reach RDP/SSH | 203.0.113.10/32 placeholder, wrong admin IP or network restriction | Use your own trusted IPv4 /32, verify NSG/host rules, then connect |
| Password/state exposure | Plaintext values committed to a repository | Protect 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:
- Edit only
azurerm_resource_group.labinmain.tf: changeprevent_destroy = truetoprevent_destroy = false(or remove that lifecycle block). - Keep all resource blocks and moved blocks in place so Terraform still knows about each managed resource.
- 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.tfvarsused. - [ ] All 13 previously managed Azure objects retained; no extra Azure resources.
- [ ]
countproduces two indexed public-IP resource instances. - [ ]
for_eachproduces two keyed security-rule instances. - [ ] Four
movedblocks preserve pre-existing Terraform state bindings. - [ ]
depends_onexplicitly models the network-security ordering for each VM. - [ ]
provider = azurerm.labworks with an aliased provider configured for the same subscription. - [ ]
lifecycle.prevent_destroyprotects the Resource Group while present. - [ ]
provisioner "local-exec"is harmless and correctly documented as creation-time only. - [ ] The
providersmap 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 validatesucceed on the student’s machine; the plan contains no unintended Azure operations.
Official documentation
- Terraform meta-arguments overview
- Terraform
countreference - Terraform
for_eachreference - Terraform
depends_onreference - Terraform
providerreference - Terraform
providersreference - Terraform
lifecyclereference - Terraform provisioners reference
- Terraform moved blocks
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.