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 Terraform input variables, introducing all six requested types in one exercise without creating any additional Azure resource or introducing new Terraform features such as data sources, modules, locals, outputs, count, or for_each.
The infrastructure remains the same 13 Terraform-managed resources: resource group, VNet, subnet, NSG, subnet/NSG association, two inbound security rules, two public IPs, two NICs, one Windows Server 2025 VM and one Ubuntu Server 26.04 LTS VM.
Important for existing students: Continue from the same Lab 4 folder and Terraform state. Do not start with an empty state. Keep the original resource names, region, CIDRs, VM size and passwords unchanged in
terraform.tfvarsunless you intend to modify Azure resources. The only intentional representation change in this lesson is that the RDP/SSH rules use the pluralsource_address_prefixesattribute to demonstrate aset(string)allowlist; Azure may plan in-place updates to those two rules.
1. Variable types — the six required concepts
| Terraform type | What it represents | Simple example | Where it is used in this lab |
|---|---|---|---|
string | A text value | "production" | Region, VM size, administrator usernames/passwords |
number | Integer or decimal | 3, 2.5 | NSG rule priorities (100, 110) |
bool | True or false | true, false | Linux password authentication setting |
list(type) | Ordered collection of values | ["dev", "prod"] | VNet and subnet CIDRs (list(string)) |
set(type) | Unique collection; order is not guaranteed | toset(["a", "b"]) | Allowed source CIDRs for RDP/SSH (set(string)) |
map(type) | Named keys with values of a single type | { env = "prod" } | Existing resource names (map(string)) |
A variable is declared using variable "name" { type = ... }. Its value is referenced using var.name or, for a map value, var.resource_names["key"].
Special note on sets: toset(["a", "b"]) is a valid Terraform expression. In terraform.tfvars, write a list-like literal such as ["203.0.113.10/32"]; the declared set(string) type converts it to a set automatically. Duplicate set values are removed; use sort(tolist(...)) when an Azure argument expects a stable list.
2. File structure
Use only these two files for the new lesson (alongside your existing Terraform state and provider lock file):
azure-terraform-labs/
├── main.tf # Provider + 12 variable blocks + same 13 Azure resources
├── terraform.tfvars # Values for all six variable types
├── terraform.tfstate # Existing state from Lab 4; protect this file
└── .terraform.lock.hcl
terraform.tfvars is automatically loaded by Terraform. It is not another block of infrastructure or another Azure resource. Do not commit terraform.tfvars or state files to a public repository because the example uses passwords.
3. Complete main.tf — same infrastructure, now with variables
This file is a complete replacement for the Lab 4 main.tf. It contains no new resource types or resources. source_image_reference and os_disk remain the same existing nested resource settings; no new Terraform blocks are introduced beyond variable declarations.
# ============================================
# PART 2 - ALL SIX VARIABLE TYPES (WINDOWS + UBUNTU)
# 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"
}
}
}
# ============================================================
# PART 2 - TERRAFORM INPUT VARIABLES (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)
}
provider "azurerm" {
features {}
}
# --------------------------------------------
# 1. Resource Group
# --------------------------------------------
resource "azurerm_resource_group" "lab" {
name = var.resource_names["resource_group"]
location = var.azure_region
}
# --------------------------------------------
# 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 = "3389"
source_address_prefixes = sort(tolist(var.admin_allowed_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 = "22"
source_address_prefixes = sort(tolist(var.admin_allowed_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 = var.resource_names["windows_vm"]
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
size = var.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 = var.resource_names["ubuntu_vm"]
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
size = var.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"
}
}
4. Complete terraform.tfvars — values for every variable
Save the following as terraform.tfvars beside main.tf. Before applying, replace the documentation-only public IP with your real IPv4 /32 and replace the sample passwords. If continuing from a previously applied Lab 4, use the same passwords already configured on the VMs: changing them can cause VM replacement.
# ============================================================
# 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"
}
5. How the six variable types work in the actual resources
5.1 string: a single text value
resource "azurerm_resource_group" "lab" {
name = var.resource_names["resource_group"]
location = var.azure_region
}
var.azure_region becomes "eastus". The same string var.vm_size becomes "Standard_B2s_v2" on both VMs. admin_username and the two password values are also strings.
5.2 number: numeric NSG priority
priority = var.rdp_priority
var.rdp_priority becomes the number 100, not the quoted string "100". The second rule uses 110. Azure NSG rule priorities must be unique within the NSG, in the range 100–4096; lower values are evaluated first.
5.3 bool: on/off setting on Ubuntu VM
disable_password_authentication = var.disable_linux_password_authentication
false means password authentication is enabled, which is necessary because the Ubuntu VM has admin_password. Do not change this setting to true while supplying admin_password; AzureRM rejects that combination, and changing the setting on an existing VM can force replacement.
5.4 list(string): ordered network CIDRs
address_space = var.vnet_address_spaces
address_prefixes = var.subnet_address_prefixes
The VNet gets ["10.20.0.0/16"] and the subnet gets ["10.20.1.0/24"]. Although there is just one entry in each list, the underlying Azure arguments expect a collection.
5.5 set(string): unique allowed administrator CIDRs
source_address_prefixes = sort(tolist(var.admin_allowed_cidrs))
For example, a student’s allowlist may later contain two distinct, trusted /32 IP addresses. A set automatically eliminates duplicates. Because a set has no guaranteed order, tolist converts it and sort gives the Azure rule a stable list. The subnet-level NSG applies these RDP/SSH rules across the subnet, not only to a single VM.
5.6 map(string): resource-name lookup
name = var.resource_names["windows_vm"]
This resolves to "vm-win2025-01". All existing names are grouped in one map with string keys and string values. Do not rename those entries if you want Terraform to continue managing the exact same deployed resources; Azure resource-name changes often force replacement.
6. Run the lesson, in the same state directory
First ensure you are in the same folder used for Labs 1–4 and that the correct Azure subscription is selected:
az login
az account set --subscription "YOUR_SUBSCRIPTION_ID"
export ARM_SUBSCRIPTION_ID="$(az account show --query id -o tsv)"
# Check your existing resources and confirm the same state is in use.
terraform state list
# Find your public IPv4 and update terraform.tfvars to use it with /32.
curl -4 https://api.ipify.org
Keep a private backup of your existing main.tf, then paste the complete files above into the same directory. Check that your terraform.tfvars passwords match the existing VMs.
terraform fmt
terraform validate
terraform plan -out=lab-part2.tfplan
Review the plan carefully before applying. Because the Azure infrastructure should remain the same, do not approve an unexpected destroy or -/+ replacement. The two NSG rules may show in-place changes due to changing from source_address_prefix to source_address_prefixes. If you changed your own IP allowlist, rule updates are also expected. If the plan contains unexpected VM replacement, reconcile the variable values with those used in Lab 4 before proceeding.
Once the plan is acceptable:
terraform apply lab-part2.tfplan
terraform state list
The final managed resource count is still 13; declaring variables does not create Azure resources. A no-change plan is possible when AzureRM normalizes equivalent rule settings, but a zero-change result is not guaranteed until you inspect the actual plan.
7. Student verification checklist
- [ ]
terraform validatepasses. - [ ]
terraform planreferences only the expected Azure subscription and existing state. - [ ] No new Azure resource blocks were added.
- [ ] No
data,module,locals,output,count, orfor_eachwas added. - [ ] All six types are declared and used:
string,number,bool,list(string),set(string),map(string). - [ ] RDP (3389) and SSH (22) source CIDRs are your authorized public IPs only.
- [ ]
disable_linux_password_authenticationisfalse. - [ ] Resource names, images, VM sizes and passwords match the already-applied lab when the goal is to preserve VMs.
- [ ]
terraform state liststill shows 13 managed resource addresses.
8. Common errors and how to avoid them
| Symptom | Likely cause | Fix |
|---|---|---|
No value for required variable | An input variable was omitted | Ensure terraform.tfvars exists in the same directory and includes the variable |
Invalid value for input variable | Wrong type, for example a map passed where a list is expected | Match the variable’s declared type; set values in .tfvars use ["..."] |
Invalid combination of arguments on Ubuntu VM | disable_linux_password_authentication = true while supplying a password | Keep it false for this password-based lab |
| NSG rule priority error | Equal priorities or a value outside 100–4096 | Use distinct valid values such as 100 and 110 |
| Cannot reach RDP or SSH | Placeholder IP left unchanged, incorrect public IP, network filtering or guest firewall | Use the public IPv4 from curl -4 https://api.ipify.org followed by /32; verify rule and VM IP |
| Terraform proposes a VM replacement | Resource name, username, password, OS image or another ForceNew setting changed | Compare terraform.tfvars against your previous Lab 4 values; do not blindly apply |
Resource provider not registered or insufficient quota | Subscription setup | Register Microsoft.Compute / Microsoft.Network, check size availability and quotas |
9. Clean up (only when the entire lab series is finished)
Do not destroy the environment between Labs 4 and Part 2. When you are truly finished:
terraform plan -destroy
terraform destroy
This will destroy the full 13-resource training environment, including both VMs. VM and public IP resources can incur charges until destroyed.
10. Accuracy and security notes
- The resource configuration is based on the exact final Lab 4 code. All original resource types, image names and dependency references are retained.
- Windows image:
MicrosoftWindowsServer:WindowsServer:2025-datacenter:latest. - Ubuntu image:
Canonical:ubuntu-26_04-lts:server:latest. - Provider version constraint:
~> 5.9.0(AzureRM 5.9.x); Terraform:>= 1.15.0, < 2.0.0. - The source CIDR
203.0.113.10/32is a documentation address. It must be replaced for working remote access. - Passwords in
terraform.tfvarsare provided as learning examples.sensitive = trueredacts CLI display, but passwords can still be stored in Terraform state. Keep configuration, state, saved plans and credentials secure. For production, use safer credential handling and SSH keys. - No live Azure apply was performed for this guide. Exact VM SKU/image availability, policy and quotas depend on your subscription.
terraform validateand a realterraform planon your workstation are still necessary.
Reference documentation
- Terraform input variables
- Terraform variable block reference
- AzureRM network security rule attributes
- AzureRM Linux VM password settings
- Canonical Azure Ubuntu image catalog