Skip to content

Terraform Azure Windows Server Git

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:

terraform import "azurerm_public_ip.vm[\"vm01\"]" "/subscriptions/..."

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


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.