Terraform Lab – Azure –  Local Values and Outputs

Continuation of Labs 1–4 and Part 2 (All Six Input Variable Types)
Level: Beginner → Early Intermediate
Terraform: 1.15.9+ within version 1.x
AzureRM provider: 5.9.x (same provider constraint as Part 2)
Date: 12 October 2026

Lab objective

Extend the exact final Part 2 Terraform configuration by introducing only two new Terraform block types:

  1. locals { ... } — give names to internal, reusable expressions.
  2. output "..." { ... } — expose useful infrastructure information after an apply.

No new Azure infrastructure is introduced. The existing 12 input variables and 13 Terraform-managed Azure resources remain, and their Terraform resource addresses are unchanged. We do not introduce data sources, modules, count, for_each, provisioners, new providers, or any other resource blocks.

Lab progression

Labs 1–4:  Resource Group -> Network -> Windows VM -> Ubuntu VM
Part 2:     All six input variable types
Part 3:     Local Values + Output Values   <-- THIS LESSON

Critical: Work in the same working directory, same Azure subscription, and same Terraform state as Part 2. Replace the existing main.tf with the full code below, but keep your working terraform.tfvars values unchanged. Do not run terraform destroy between these lessons.

1. What are Local Values?

Local values are named expressions defined inside a locals block. They help reduce repeated logic and make configuration easier to read. Unlike input variables, local values are not assigned through terraform.tfvars or -var; Terraform calculates them from expressions in the configuration.

Syntax:

locals {
  my_location = var.azure_region
  my_message  = "Deploying to ${var.azure_region}"
}

Reference syntax: local.my_location — singular local, even though the declaration is locals.

Input variables vs. local values vs. output values

ConceptBlockReferencePurposeSet using terraform.tfvars?
Input variablevariable "name"var.nameAccept configurable inputsYes
Local valuelocalslocal.nameReuse or transform values inside configurationNo
Output valueoutput "name"terraform output name (root module CLI)Expose results such as assigned public IPsNo

Our single new locals block contains:

Local valueDerived fromUsed byResult with Part 2 values
local.resource_group_namevar.resource_names["resource_group"]Resource Grouprg-terraform-lab-eastus
local.azure_locationvar.azure_regionResource Groupeastus
local.vm_sizevar.vm_sizeBoth VMsStandard_B2s_v2
local.vm_namesTwo existing map entriesBoth VMs and an outputWindows and Ubuntu VM names
local.admin_source_cidrssort(tolist(var.admin_allowed_cidrs))Both NSG rules and an outputStable list of trusted CIDRs
local.access_portsThe existing RDP/SSH port numbersNSG rules and RDP connection outputrdp = "3389", ssh = "22"

The key teaching example is local.admin_source_cidrs. In Part 2, both NSG rules independently repeated sort(tolist(var.admin_allowed_cidrs)). In Part 3, the expression appears once in locals and both rules refer to the same result. The effective Azure configuration is unchanged.

2. What are Output Values?

Outputs expose information from resources and expressions, such as the actual public IP Azure assigned to a VM. Terraform displays new or changed root-module output values after terraform apply. Later, students can retrieve them with terraform output instead of searching the Azure Portal.

Example:

output "windows_public_ip" {
  description = "Public IPv4 address of the Windows Server VM."
  value       = azurerm_public_ip.windows.ip_address
}

Output references are not data sources. This output reads an attribute of the azurerm_public_ip.windows resource already created by Terraform.

Outputs included in this lab

OutputData typePurpose
resource_group_nameStringExisting Resource Group name
virtual_network_nameStringExisting VNet name
subnet_nameStringExisting subnet name
vm_namesMap/objectBoth VM names in one output
windows_public_ipStringPublic IP assigned to Windows
ubuntu_public_ipStringPublic IP assigned to Ubuntu
windows_rdp_targetStringWindows IP and RDP port (IP:3389)
ubuntu_ssh_commandStringReady-to-copy SSH command
allowed_admin_cidrsList of stringsCIDRs permitted by the inbound NSG rules

No passwords are output. Output values are saved in Terraform state. Avoid exporting passwords, tokens, private keys, or other secrets. Even sensitive = true outputs can be revealed via certain Terraform CLI flags or the state file.

3. File layout

azure-terraform-labs/
├── main.tf             # Full Part 3: provider + variables + locals + 13 resources + outputs
├── terraform.tfvars    # UNCHANGED input values from Part 2
├── terraform.tfstate   # Existing state (when using local state)
└── .terraform.lock.hcl # Existing provider dependency lock

Use only the two authored files, main.tf and terraform.tfvars, for this lesson. Do not add other .tf files or redeclare resource blocks.

4. Complete main.tf — copy everything

The following is a complete replacement file, not a fragment. It retains every Part 2 variable and resource and adds the locals and output blocks.

# ============================================
# PART 3 - INPUT VARIABLES + LOCAL VALUES + OUTPUTS
# 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)
}

# ============================================================
# PART 3 - LOCAL VALUES (DERIVED FROM PART 2 INPUT VARIABLES)
# ============================================================
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"
  }
}

provider "azurerm" {
  features {}
}

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

# --------------------------------------------
# 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" {
  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. Allow RDP from approved public IPv4 CIDRs
# --------------------------------------------
resource "azurerm_network_security_rule" "rdp" {
  name                        = var.resource_names["rdp_rule"]
  priority                    = var.rdp_priority
  direction                   = "Inbound"
  access                      = "Allow"
  protocol                    = "Tcp"
  source_port_range           = "*"
  destination_port_range      = local.access_ports.rdp
  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
}

# --------------------------------------------
# 7. Allow SSH from approved public IPv4 CIDRs
# --------------------------------------------
resource "azurerm_network_security_rule" "ssh" {
  name                        = var.resource_names["ssh_rule"]
  priority                    = var.ssh_priority
  direction                   = "Inbound"
  access                      = "Allow"
  protocol                    = "Tcp"
  source_port_range           = "*"
  destination_port_range      = local.access_ports.ssh
  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. Windows VM Public IP
# --------------------------------------------
resource "azurerm_public_ip" "windows" {
  name                = var.resource_names["windows_public_ip"]
  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.windows.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
  ]

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

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

# --------------------------------------------
# 11. Ubuntu VM Public IP
# --------------------------------------------
resource "azurerm_public_ip" "ubuntu" {
  name                = var.resource_names["ubuntu_public_ip"]
  location            = azurerm_resource_group.lab.location
  resource_group_name = azurerm_resource_group.lab.name

  allocation_method = "Static"
  sku               = "Standard"
}

# --------------------------------------------
# 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.ubuntu.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
  ]

  # 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"
  }
}

# ============================================================
# PART 3 - OUTPUT VALUES (NO NEW AZURE RESOURCES)
# ============================================================

# 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.windows.ip_address
}

output "ubuntu_public_ip" {
  description = "Public IPv4 address of the Ubuntu Server VM."
  value       = azurerm_public_ip.ubuntu.ip_address
}

# Computed text outputs: connection hints (no passwords)
output "windows_rdp_target" {
  description = "RDP target for Windows Remote Desktop."
  value       = "${azurerm_public_ip.windows.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.ubuntu.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
}

5. Complete terraform.tfvars — keep the same values as Part 2

If your Part 2 lab is already deployed, leave the actual values unchanged. In particular, preserve the two existing VM passwords. Replacing them with the illustrative passwords below when your real deployment uses different passwords can cause a VM replacement.

The placeholder 203.0.113.10/32 is not a usable remote-access address. If you already replaced it with your current public IPv4 /32 in Part 2, keep that working address.

# ============================================================
# 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. What exactly changed compared with Part 2?

6.1 The new locals block

locals {
  resource_group_name = var.resource_names["resource_group"]
  azure_location      = var.azure_region
  vm_size             = var.vm_size

  vm_names = {
    windows = var.resource_names["windows_vm"]
    ubuntu  = var.resource_names["ubuntu_vm"]
  }

  admin_source_cidrs = sort(tolist(var.admin_allowed_cidrs))

  access_ports = {
    rdp = "3389"
    ssh = "22"
  }
}

Terraform evaluates these expressions; there is no separate Azure object called a local value.

6.2 Changes to existing resources — values remain the same

In Part 2In Part 3Why
name = var.resource_names["resource_group"]name = local.resource_group_nameReadability
location = var.azure_regionlocation = local.azure_locationDemonstrate local references
size = var.vm_sizesize = local.vm_sizeReuse VM size in both VMs
name = var.resource_names["windows_vm"]name = local.vm_names.windowsUse a local map
name = var.resource_names["ubuntu_vm"]name = local.vm_names.ubuntuUse a local map
source_address_prefixes = sort(tolist(var.admin_allowed_cidrs))source_address_prefixes = local.admin_source_cidrsAvoid duplicate conversion/sorting
destination_port_range = "3389"destination_port_range = local.access_ports.rdpName RDP port
destination_port_range = "22"destination_port_range = local.access_ports.sshName SSH port

Important: local expressions produce the same values as the original Part 2 code; they don’t automatically create, destroy, or rename resources. These expression-only changes should not require changing your Azure resources, provided your prior state and .tfvars still match. Unexpected Azure resource changes must be investigated before applying.

6.3 Nine new outputs

Outputs reference the existing resources and computed locals. The most useful ones are:

output "windows_public_ip" {
  description = "Public IPv4 address of the Windows Server VM."
  value       = azurerm_public_ip.windows.ip_address
}

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

The username and IP are exposed, not the administrator password. RDP and SSH connections also depend on your real public IP being permitted by the NSG, the guest OS being ready, and any organizational network restrictions.

7. Execute Part 3 safely

Step 1 — Verify correct directory, identity, and existing state

# Run inside the SAME Part 2 directory.
az login
az account set --subscription "YOUR_SUBSCRIPTION_ID"
export ARM_SUBSCRIPTION_ID="$(az account show --query id -o tsv)"

terraform version
terraform state list

With an applied Part 2, terraform state list should show the same 13 Terraform resource addresses.

Step 2 — Back up your existing code and state securely

# Save a private copy of the existing main.tf before replacing it.
cp main.tf main.part2.backup.txt

# If and only if you are using local Terraform state:
cp terraform.tfstate terraform.tfstate.part2.backup

Note: The backups may contain or reference secrets; keep them outside public version control. Do not save the backup as another .tf file, because Terraform loads every .tf file in the directory, and duplicate blocks would break validation. Remote state users should follow their backend’s snapshot/backup procedure instead.

Step 3 — Replace files

  1. Replace main.tf with the complete Part 3 file in Section 4.
  2. Keep your existing Part 2 terraform.tfvars (Section 5 is a full reference copy).
  3. Don’t modify the 13 resource addresses, existing images, VM passwords, resource names, network settings, or state.

Step 4 — Format, validate, and inspect the plan

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

Expected for a previously applied, unchanged Part 2 environment: no Azure resource additions, updates, deletions, or replacements. Terraform should show output-value additions. The real plan is authoritative: if it proposes a VM replacement, resource deletion, or unexpected network change, do not apply until the difference is understood.

Step 5 — Apply the reviewed plan

terraform apply lab-part3.tfplan
terraform state list

Adding outputs changes Terraform’s recorded output values, even if Azure resources themselves require no updates.

8. Practice the terraform output command

# Display every root-module output.
terraform output

# Query a specific output.
terraform output windows_public_ip

# Get a raw string without quotes; helpful for terminal scripts.
terraform output -raw ubuntu_public_ip

# Display a single complex value such as vm_names as JSON.
terraform output -json vm_names

# Display all outputs in JSON (does not mask sensitive outputs).
terraform output -json

# Get your connection hint directly.
terraform output -raw ubuntu_ssh_command

A hypothetical result after terraform apply (the IPs below are illustrative; your actual IPs will differ):

allowed_admin_cidrs = tolist([
  "YOUR.PUBLIC.IP.ADDRESS/32",
])
resource_group_name = "rg-terraform-lab-eastus"
subnet_name = "snet-vms"
ubuntu_public_ip = "ACTUAL_UBUNTU_PUBLIC_IP"
ubuntu_ssh_command = "ssh labadmin@ACTUAL_UBUNTU_PUBLIC_IP"
virtual_network_name = "vnet-terraform-lab"
vm_names = {
  "ubuntu" = "vm-ubuntu2604-01"
  "windows" = "vm-win2025-01"
}
windows_public_ip = "ACTUAL_WINDOWS_PUBLIC_IP"
windows_rdp_target = "ACTUAL_WINDOWS_PUBLIC_IP:3389"

Use your actual output values; don’t attempt to connect to the illustrative text above.

Optional: inspect the locals in Terraform Console

terraform console

Then enter one expression per line:

local.vm_names
local.admin_source_cidrs
local.access_ports.rdp
local.vm_size

Type exit to return to your shell. This is only a read/evaluation exercise; it doesn’t add an Azure resource.

9. Student exercises

  1. Identify: Which expressions begin with var. and which begin with local. in main.tf?
  2. Inspect: Run terraform output -raw windows_public_ip and locate the same IP in the Azure Portal.
  3. Compare: Run terraform output -json vm_names and explain why -raw vm_names is not suitable for a map/object.
  4. Understand reuse: Find both NSG security rules that reference local.admin_source_cidrs.
  5. Safety check: Explain why windows_admin_password should not be exported as a plain output value.
  6. Review drift: After using locals, explain why an existing Azure VM should not need replacement when its configured values stay unchanged.

10. Success checklist

  • [ ] Working directory and Terraform state are the same as in Part 2.
  • [ ] All 12 input variables remain declared and supplied.
  • [ ] All 13 original Azure resources retain the same Terraform addresses.
  • [ ] Exactly one locals block exists and its values are actually referenced.
  • [ ] Exactly nine new output blocks exist.
  • [ ] No extra resource, data, module, count, or for_each blocks were added.
  • [ ] terraform fmt and terraform validate succeed on the student’s machine.
  • [ ] terraform plan contains no unexpected Azure changes or replacements.
  • [ ] terraform apply completes and terraform output displays the expected values.
  • [ ] No administrator passwords are exposed in output blocks.

11. Common problems

ProblemLikely causeSolution
Reference to undeclared local valueTypo in local.some_nameMatch the local name declared inside locals {} exactly.
Duplicate output definitionOld output blocks or other .tf files have duplicate namesUse the full replacement file, not multiple copies.
No outputs foundOutput blocks were not applied or wrong Terraform stateApply the reviewed plan in the original Part 2 directory and state.
Unsupported attribute on resource outputMistyped provider resource attributeUse azurerm_public_ip.windows.ip_address (not .public_ip_address).
-raw is not supported for complex typesAttempt to use -raw on a map/listUse terraform output -json vm_names instead.
Terraform proposes replacementPassword/name/image/subnet/provider drift, or wrong stateStop; compare against the actual applied Part 2 configuration.
RDP or SSH cannot connectPublic IP allowlist, credentials, host readiness or Azure networkingUse current public IPv4 /32, correct VM IP, and inspect NSG rules.

12. Cleanup — only at the end of the entire course

Don’t destroy between Parts 2 and 3. When finished with the training environment:

terraform plan -destroy
terraform destroy

Terraform removes managed Azure infrastructure. locals and output are not separate Azure resources; they don’t have Azure cleanup operations of their own.

13. Accuracy, security and compatibility notes

  • The Part 2 uploaded lab is the source for the 12 variable declarations, terraform.tfvars values, 13 Azure resources, resource names, and image settings; this document extends it with locals and outputs only.
  • The example uses Windows Server 2025 Datacenter and Ubuntu Server 26.04 LTS, exactly as in Part 2. No image upgrade is attempted in this lesson.
  • A sensitive input variable can still be stored in Terraform state. Do not commit terraform.tfvars, state, saved plans, or password-bearing backups to a public repository.
  • Outputs are visible to anyone with access to the Terraform output/state. This lab outputs names, IDs/addresses and connectivity hints, not passwords.
  • The local values and outputs were checked for consistency with the uploaded Part 2 file. A live Azure terraform plan / apply was not run, and a successful Azure deployment depends on permissions, quota, resource availability, policy and current state. Do not treat any training example as a guarantee of a particular subscription’s deployment result.

Official references

Related Posts

Terraform Lab – Azure – Input Variables – VMS & Netowork Demo

Continuation of Labs 1–4 · Level: Beginner · Terraform: 1.15.9 · AzureRM: 5.9.x · Updated: 12 October 2026 Objective Terraform Lab – Azure – Resources – VMS & Netowork Demo Convert the final, consolidated Lab 4 infrastructure to…

Read More

Terraform Lab – Azure – Resources – VMS & Netowork Demo

Level: Absolute BeginnerTerraform version: 1.15.9AzureRM Provider: 5.9.0Approach: Each lab builds on the previous lab. These are four complete Terraform configurations designed for students to execute sequentially. Learning Path 1. Selected Azure…

Read More

Azure: Terraform Azure Authentication: Azure CLI

Latest Terraform Azure Authentication: Azure CLI The recommended approach in the HashiCorp AzureRM provider’s Azure CLI authentication guide is to authenticate through Azure CLI when running Terraform…

Read More

Azure: Authenticating to Azure using a Service Principal and a Client Secret

Azure Portal: Get all 4 Terraform authentication values You can do this entirely through the Azure Portal web interface. No terminal commands or Azure CLI are required….

Read More

Azure: How to Create an Azure User, Assign Permissions, and Log In

Objective Create a new user in Microsoft Azure, grant access to cloud resources, and verify that the user can log in independently without sharing your administrator credentials….

Read More

Falcosidekick on Ubuntu: Complete Installation, Configuration, Alert Forwarding, and Troubleshooting Tutorial

I read your Falco: Zero to Intermediate – Gold-Standard Hands-on Master Tutorial. It uses Ubuntu Server 24.04 LTS as its reference lab and covers Falco installation, runtime…

Read More