Azure VM Deployment via Terraform
Project Type
Infrastructure as Code — multi-VM Windows Server deployment in Azure using Terraform. Covers for_each loops, multi-region support, NSG rules, state drift, and terraform import.
Project Overview
This project demonstrates how to deploy and manage Windows Server Virtual Machines in Microsoft Azure using Terraform Infrastructure as Code.
Instead of clicking through the Azure Portal every time, all infrastructure is defined in code — deployable, repeatable, and version controlled via GitHub. Built as a hands-on learning project covering real-world enterprise scenarios that map directly to IaC-focused infrastructure roles.
Goals
- Deploy Windows Server 2022 VMs in Azure using Terraform HCL — no portal clicks
- Use for_each to support multiple VMs from a single resource block without duplicating code
- Demonstrate multi-region deployment by changing a single variable
- Manage NSG rules entirely through code — attached to both subnet and NIC
- Handle state drift caused by manual portal changes using
terraform import - Version control all infrastructure code in GitHub with secrets excluded via
.gitignore
Architecture
Logical Topology
Azure Subscription
└── Resource Group (rg-eastus-prod-01)
├── Virtual Network (vnet-eastus-prod-01)
│ │ 10.0.0.0/16
│ │
│ ├── Subnet: snet-vm01-prod (10.0.1.0/24)
│ │ ├── NSG → Allow RDP 3389 (Inbound)
│ │ └── vm-vm01-prod
│ │ ├── NIC: nic-vm01-prod
│ │ ├── Public IP: pip-vm01-prod
│ │ └── OS Disk: osdisk-vm01-prod
│ │
│ └── Subnet: snet-vm02-prod (10.0.2.0/24)
│ ├── NSG → Allow RDP 3389 (Inbound)
│ └── vm-vm02-prod
│ ├── NIC: nic-vm02-prod
│ ├── Public IP: pip-vm02-prod
│ └── OS Disk: osdisk-vm02-prod
Resource Naming Convention
| Resource | Pattern | Example |
|---|---|---|
| Resource Group | rg-<region>-<env>-01 |
rg-eastus-prod-01 |
| Virtual Network | vnet-<region>-<env>-01 |
vnet-eastus-prod-01 |
| Subnet | snet-<vmkey>-<env> |
snet-vm01-prod |
| NIC | nic-<vmkey>-<env> |
nic-vm01-prod |
| Public IP | pip-<vmkey>-<env> |
pip-vm01-prod |
| VM | vm-<vmkey>-<env> |
vm-vm01-prod |
| NSG | nsg-<vmkey>-<env> |
nsg-vm01-prod |
| OS Disk | osdisk-<vmkey>-<env> |
osdisk-vm01-prod |
Environment
Compute
| Attribute | Value |
|---|---|
| VM SKU (East US) | Standard_D2lds_v7 (2 vCPU, 4 GiB) |
| VM SKU (Canada Central) | Standard_D2lds_v6 (2 vCPU, 4 GiB) |
| OS | Windows Server 2022 Datacenter Azure Edition |
| Image SKU | 2022-datacenter-azure-edition |
| Subscription | Azure Free Trial |
VM Inventory
| VM | Subnet | Private IP | Public IP | Role |
|---|---|---|---|---|
| vm-vm01-prod | snet-vm01-prod (10.0.1.0/24) | Dynamic | pip-vm01-prod | Test workload |
| vm-vm02-prod | snet-vm02-prod (10.0.2.0/24) | Dynamic | pip-vm02-prod | Test workload |
File Structure
| File | Purpose |
|---|---|
main.tf |
Azure provider configuration and Resource Group |
variables.tf |
All input variable definitions |
networking.tf |
Virtual Network and Subnets |
compute.tf |
VMs, NICs, and Public IPs |
nsg.tf |
Network Security Groups and NSG associations |
outputs.tf |
VM names and private IPs printed after deployment |
terraform.tfvars |
Actual values — excluded from Git via .gitignore |
.gitignore |
Prevents secrets and state files reaching GitHub |
Implementation
Core Design — for_each Over a VM Map
The entire deployment loops over a single variable map. Adding or removing a VM
requires only editing terraform.tfvars — no .tf files change.
# variables.tf
variable "virtual_machines" {
type = map(object({
size = string
sku = string
disk_size = number
subnet = string
}))
}
# terraform.tfvars — the only file edited day to day
virtual_machines = {
"vm01" = {
size = "Standard_D2lds_v7"
sku = "2022-datacenter-azure-edition"
disk_size = 128
subnet = "10.0.1.0/24"
}
"vm02" = {
size = "Standard_D2lds_v7"
sku = "2022-datacenter-azure-edition"
disk_size = 128
subnet = "10.0.2.0/24"
}
}
# compute.tf — one block creates all VMs
resource "azurerm_windows_virtual_machine" "vm" {
for_each = var.virtual_machines
name = "vm-${each.key}-${var.environment}"
size = each.value.size
network_interface_ids = [azurerm_network_interface.vm[each.key].id]
...
}
Multi-Region Deployment
Changing one variable in terraform.tfvars deploys to a completely different region.
# Deploy to East US
region_code = "eastus"
# Deploy to Canada Central — one line change
region_code = "canadacentral"
Real-World Lesson
VM sizes are not consistent across regions. Standard_D2lds_v7 is available
in East US but does not exist in Canada Central — Standard_D2lds_v6 must
be used instead. This is exactly why size lives in terraform.tfvars as a
per-VM variable rather than being hardcoded in the resource block.
NSG Rules via Code
NSG created, rules defined, and attached to both subnet and NIC — all via Terraform. No portal clicks required.
resource "azurerm_network_security_group" "vm" {
for_each = var.virtual_machines
name = "nsg-${each.key}-${var.environment}"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
security_rule {
name = "Allow-RDP"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "3389"
source_address_prefix = "*"
destination_address_prefix = "*"
}
}
# Attach to subnet
resource "azurerm_subnet_network_security_group_association" "vm" {
for_each = var.virtual_machines
subnet_id = azurerm_subnet.vm[each.key].id
network_security_group_id = azurerm_network_security_group.vm[each.key].id
}
# Attach to NIC
resource "azurerm_network_interface_security_group_association" "vm" {
for_each = var.virtual_machines
network_interface_id = azurerm_network_interface.vm[each.key].id
network_security_group_id = azurerm_network_security_group.vm[each.key].id
}
NSG on Both Subnet and NIC
Attaching to the subnet applies rules to all VMs in that subnet. Attaching to the NIC applies rules to that specific VM only. Both together provide subnet-level baseline rules plus VM-specific control.
State Drift and terraform import
When a Public IP was created manually in the Azure Portal, terraform plan
reported no changes — Terraform was completely blind to it.
What happens with manual portal changes:
| Action | Result |
|---|---|
| Create resource in portal | Terraform state has no record of it |
Run terraform plan |
Reports "No changes" — blind to the resource |
Run terraform destroy |
Orphaned resource left behind in Azure |
Add resource to .tf file without import |
Terraform tries to create a duplicate |
Fix — terraform import:
# Step 1 — Define the resource block in compute.tf first
# Step 2 — Run import using the Azure Resource ID (use CMD not PowerShell)
terraform import "azurerm_public_ip.vm[\"vm01\"]" /subscriptions/<sub-id>/resourceGroups/rg-eastus-prod-01/providers/Microsoft.Network/publicIPAddresses/pip-name
# Step 3 — Verify
terraform plan # should show no changes if code matches portal config
Import Order Matters
Always define the resource block in your .tf file before running
terraform import. Import only updates the state file — it does not
generate code for you. Also run import from CMD not PowerShell — PowerShell
strips inner quotes from bracket notation causing a syntax error.
Deployment Commands
# Login to Azure
az login
# Initialize Terraform — downloads AzureRM provider
terraform init
# Check available VM sizes in a region
az vm list-skus --location eastus --resource-type virtualMachines \
--query "[?restrictions[0].reasonCode!='NotAvailableForSubscription'].{Name:name}" \
--output table
# Preview what will be created
terraform plan
# Deploy
terraform apply
# Destroy when done to avoid charges
terraform destroy
Troubleshooting
VM Size Not Available — SkuNotAvailable
Symptom: The requested VM size Standard_DS1_v2 is currently not available in location eastus
Root Cause: Azure free trial subscriptions have capacity restrictions on common VM sizes in popular regions.
Resolution: Ran az vm list-skus to find unrestricted sizes. Switched to Standard_D2lds_v7 in East US and Standard_D2lds_v6 in Canada Central.
Computer Name Exceeds 15 Characters
Symptom: computer_name can be at most 15 characters, got 17
Root Cause: Windows enforces a 15-character hostname limit. Terraform inherits the name value as computer_name if not set separately.
Resolution: Added explicit computer_name argument:
name = "vm-${each.key}-${var.environment}" # Azure resource name
computer_name = "vm-${each.key}" # Windows hostname — max 15 chars
Platform Image Not Found
Symptom: PlatformImageNotFound: The platform image 2022-datacenter-x64-gen2 is not available
Root Cause: The explicit -x64-gen2 SKU string is not published for all subscription types.
Resolution: Switched to 2022-datacenter-azure-edition which is Gen2 compatible and available across subscription types. Confirmed via az vm image list.
Gen1/Gen2 Mismatch
Symptom: The selected VM size Standard_D2lds_v7 cannot boot Hypervisor Generation 1
Root Cause: All v7 series VM sizes are Gen2-only. The image SKU 2022-datacenter is Gen1.
Resolution: Used 2022-datacenter-azure-edition which is Gen2 native.
terraform import Failing in PowerShell
Symptom: Index brackets must contain either a literal number or a literal string
Root Cause: PowerShell strips inner quotes from ["vm01"] making it [vm01] which is invalid Terraform address syntax.
Resolution: Switched to CMD:
Validation
East US Deployment
| Test | Expected | Result |
|---|---|---|
terraform plan shows correct resources |
5 resources to add | ✅ Passed |
terraform apply completes without error |
Apply complete | ✅ Passed |
| VM visible in Azure Portal | vm-vm01-prod running | ✅ Passed |
| NSG attached to subnet and NIC | Both associations present | ✅ Passed |
| Second VM added via tfvars only | vm-vm02-prod created, vm01 untouched | ✅ Passed |
Canada Central Deployment
| Test | Expected | Result |
|---|---|---|
| Region change in tfvars only | All resources in canadacentral | ✅ Passed |
| v7 size unavailable — caught before apply | Error on plan | ✅ Caught early |
| Switched to v6 size — apply succeeds | Apply complete | ✅ Passed |
State Management
| Test | Expected | Result |
|---|---|---|
| Public IP created in portal | terraform plan shows no changes |
✅ Drift confirmed |
terraform import run |
Resource added to state | ✅ Passed |
terraform plan after import |
No changes — state matches portal | ✅ Passed |
Key Learnings
- Writing Terraform HCL to provision real Azure infrastructure from scratch
- Using for_each with a map variable to deploy multiple VMs without duplicating code
- Understanding Terraform state — what it tracks, what it misses, and how drift happens
- Using terraform import to bring manually created resources under IaC management
- Managing VM size availability differences across Azure regions
- Handling Gen1/Gen2 image compatibility with VM size families
- Separating code from values using variables and tfvars for clean multi-environment support
- Structuring a Terraform project across multiple .tf files by concern
- Understanding why portal access should be restricted in enterprise environments
Core principle: If you created it with Terraform — manage it with Terraform.
Manual portal changes create state drift that Terraform cannot detect without terraform import.
Enterprise Context
This project builds the foundation skills. In a real enterprise environment this would also include:
| Addition | Purpose |
|---|---|
| Remote state in Azure Storage Account | Shared state with locking — prevents concurrent apply conflicts |
| CI/CD pipeline via Azure DevOps | All changes via approved pipeline — no local terraform apply |
| Portal access restricted to read-only | Only Terraform service principal has Contributor rights |
| Azure Policy enforcement | Block non-compliant VM sizes, regions, and naming conventions |
| Key Vault for secrets | Passwords pulled at runtime — never stored in files |
| Scheduled drift detection | terraform plan runs nightly and alerts on any manual changes |
| Naming convention via locals.tf | All resource names generated from a single pattern file |
IaC Maps Directly to ITIL
Pull Request = Change Request. PR Approval = CAB Approval.
terraform apply = Controlled Implementation. Git commit history = Change Record.
The governance model is identical — just automated.
Tools and Technologies
| Category | Technology |
|---|---|
| IaC Tool | Terraform (HashiCorp) |
| Cloud Platform | Microsoft Azure |
| Provider | AzureRM ~3.0 |
| Networking | VNet, Subnets, NSG, Public IP |
| Compute | Azure VMs, Managed Disks |
| OS | Windows Server 2022 Datacenter Azure Edition |
| Version Control | Git + GitHub |
| CLI Tools | Terraform CLI, Azure CLI (az) |
| Shell | PowerShell, CMD |
Links
- GitHub Repository: azure-terraform-vm-deployment
- Related Project: Azure Hub-and-Spoke Landing Zone
Outcome
Successfully deployed Windows Server 2022 VMs across East US and Canada
Central using Terraform. Demonstrated multi-VM deployment with for_each,
NSG management via code, multi-region switching via a single variable,
Public IP state import after portal drift, and full version control on
GitHub. All infrastructure destroyed and redeployed multiple times to
validate repeatability.