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:
locals { ... }— give names to internal, reusable expressions.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.tfwith the full code below, but keep your workingterraform.tfvarsvalues unchanged. Do not runterraform destroybetween 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
| Concept | Block | Reference | Purpose | Set using terraform.tfvars? |
|---|---|---|---|---|
| Input variable | variable "name" | var.name | Accept configurable inputs | Yes |
| Local value | locals | local.name | Reuse or transform values inside configuration | No |
| Output value | output "name" | terraform output name (root module CLI) | Expose results such as assigned public IPs | No |
Our single new locals block contains:
| Local value | Derived from | Used by | Result with Part 2 values |
|---|---|---|---|
local.resource_group_name | var.resource_names["resource_group"] | Resource Group | rg-terraform-lab-eastus |
local.azure_location | var.azure_region | Resource Group | eastus |
local.vm_size | var.vm_size | Both VMs | Standard_B2s_v2 |
local.vm_names | Two existing map entries | Both VMs and an output | Windows and Ubuntu VM names |
local.admin_source_cidrs | sort(tolist(var.admin_allowed_cidrs)) | Both NSG rules and an output | Stable list of trusted CIDRs |
local.access_ports | The existing RDP/SSH port numbers | NSG rules and RDP connection output | rdp = "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
| Output | Data type | Purpose |
|---|---|---|
resource_group_name | String | Existing Resource Group name |
virtual_network_name | String | Existing VNet name |
subnet_name | String | Existing subnet name |
vm_names | Map/object | Both VM names in one output |
windows_public_ip | String | Public IP assigned to Windows |
ubuntu_public_ip | String | Public IP assigned to Ubuntu |
windows_rdp_target | String | Windows IP and RDP port (IP:3389) |
ubuntu_ssh_command | String | Ready-to-copy SSH command |
allowed_admin_cidrs | List of strings | CIDRs 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 2 | In Part 3 | Why |
|---|---|---|
name = var.resource_names["resource_group"] | name = local.resource_group_name | Readability |
location = var.azure_region | location = local.azure_location | Demonstrate local references |
size = var.vm_size | size = local.vm_size | Reuse VM size in both VMs |
name = var.resource_names["windows_vm"] | name = local.vm_names.windows | Use a local map |
name = var.resource_names["ubuntu_vm"] | name = local.vm_names.ubuntu | Use a local map |
source_address_prefixes = sort(tolist(var.admin_allowed_cidrs)) | source_address_prefixes = local.admin_source_cidrs | Avoid duplicate conversion/sorting |
destination_port_range = "3389" | destination_port_range = local.access_ports.rdp | Name RDP port |
destination_port_range = "22" | destination_port_range = local.access_ports.ssh | Name 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
- Replace
main.tfwith the complete Part 3 file in Section 4. - Keep your existing Part 2
terraform.tfvars(Section 5 is a full reference copy). - 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
- Identify: Which expressions begin with
var.and which begin withlocal.inmain.tf? - Inspect: Run
terraform output -raw windows_public_ipand locate the same IP in the Azure Portal. - Compare: Run
terraform output -json vm_namesand explain why-raw vm_namesis not suitable for a map/object. - Understand reuse: Find both NSG security rules that reference
local.admin_source_cidrs. - Safety check: Explain why
windows_admin_passwordshould not be exported as a plain output value. - 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
localsblock exists and its values are actually referenced. - [ ] Exactly nine new output blocks exist.
- [ ] No extra
resource,data,module,count, orfor_eachblocks were added. - [ ]
terraform fmtandterraform validatesucceed on the student’s machine. - [ ]
terraform plancontains no unexpected Azure changes or replacements. - [ ]
terraform applycompletes andterraform outputdisplays the expected values. - [ ] No administrator passwords are exposed in output blocks.
11. Common problems
| Problem | Likely cause | Solution |
|---|---|---|
Reference to undeclared local value | Typo in local.some_name | Match the local name declared inside locals {} exactly. |
Duplicate output definition | Old output blocks or other .tf files have duplicate names | Use the full replacement file, not multiple copies. |
No outputs found | Output blocks were not applied or wrong Terraform state | Apply the reviewed plan in the original Part 2 directory and state. |
Unsupported attribute on resource output | Mistyped provider resource attribute | Use azurerm_public_ip.windows.ip_address (not .public_ip_address). |
-raw is not supported for complex types | Attempt to use -raw on a map/list | Use terraform output -json vm_names instead. |
| Terraform proposes replacement | Password/name/image/subnet/provider drift, or wrong state | Stop; compare against the actual applied Part 2 configuration. |
| RDP or SSH cannot connect | Public IP allowlist, credentials, host readiness or Azure networking | Use 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.tfvarsvalues, 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/applywas 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
- HashiCorp — Use locals to reuse expressions
- HashiCorp — Use outputs to expose module data
- HashiCorp —
terraform outputcommand - AzureRM provider —
azurerm_public_ipresource - AzureRM provider —
azurerm_windows_virtual_machineresource - AzureRM provider —
azurerm_linux_virtual_machineresource