Infrastructure as Code for Windows Server Environments

Infrastructure as Code (IaC) treats server provisioning configuration the same way software engineers treat application code: version-controlled, peer-reviewed, and automatically tested. For Windows Server 2022 environments, combining Terraform for VM lifecycle management with PowerShell DSC for OS configuration creates a complete, repeatable provisioning pipeline. Changes to infrastructure are proposed as code diffs, reviewed before merge, and applied by automation rather than by hand — eliminating configuration drift and enabling disaster recovery by re-applying the same code to rebuilt infrastructure.

This guide covers Terraform for both on-premises Hyper-V and Azure VM provisioning, PowerShell DSC for configuration management, WinRM-based remote provisioners, Terraform modules for reusable Windows server templates, and Terragrunt for managing multiple environments with a single configuration source.

Installing Terraform and Configuring the Hyper-V Provider

Terraform is a cross-platform binary distributed by HashiCorp. Install it on your management workstation or CI/CD pipeline agent:

# On Windows management workstation — using winget
winget install --id HashiCorp.Terraform

# Verify installation
terraform version

# On Linux CI/CD agent
wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform

The community Hyper-V Terraform provider by taliesins enables Terraform to manage Hyper-V VMs on Windows Server 2022 hosts. Create your project directory and configure the provider:

mkdir C:IaCwindows-infra && cd C:IaCwindows-infra

Create the provider configuration file (providers.tf):

terraform {
  required_providers {
    hyperv = {
      source  = "taliesins/hyperv"
      version = "~> 1.2"
    }
  }
  required_version = ">= 1.5.0"
}

provider "hyperv" {
  user     = var.hyperv_user
  password = var.hyperv_password
  host     = var.hyperv_host
  port     = 5986
  https    = true
  insecure = false
  use_ntlm = true
  timeout  = "30s"
}

The provider communicates with the Hyper-V host over WinRM (port 5986 HTTPS). Ensure WinRM is configured on the Hyper-V host to accept remote PowerShell connections:

# Run on the Hyper-V host to enable HTTPS WinRM
winrm quickconfig -transport:https
winrm set winrm/config/listener?Address=*+Transport=HTTPS '@{CertificateThumbprint="YOUR_CERT_THUMBPRINT"}'
Set-Item WSMan:localhostServiceAuthBasic -Value $true
Set-Item WSMan:localhostMaxConcurrentOperationsPerUser -Value 1500

Defining Hyper-V VMs as Terraform Resources

Create a variables.tf file to define configurable inputs:

variable "hyperv_host" {
  description = "Hyper-V host FQDN or IP"
  type        = string
  default     = "hvhost01.corp.example.com"
}

variable "hyperv_user" {
  description = "Hyper-V admin username"
  type        = string
}

variable "hyperv_password" {
  description = "Hyper-V admin password"
  type        = string
  sensitive   = true
}

variable "vm_name" {
  description = "Name of the Windows Server VM"
  type        = string
  default     = "WS2022-Prod-01"
}

variable "vm_cpu_count" {
  type    = number
  default = 4
}

variable "vm_memory_gb" {
  type    = number
  default = 8
}

variable "template_vhdx_path" {
  description = "Path to sysprep'd Windows Server 2022 template VHDX on Hyper-V host"
  type        = string
  default     = "C:\Templates\WS2022-Template.vhdx"
}

Create main.tf to define the VM resource:

resource "hyperv_vhd" "os_disk" {
  path   = "C:\VMs\${var.vm_name}\OSDisk.vhdx"
  source = var.template_vhdx_path
  size   = 80 * 1024 * 1024 * 1024  # 80 GB
}

resource "hyperv_machine_instance" "windows_server" {
  name               = var.vm_name
  generation         = 2
  processor_count    = var.vm_cpu_count
  dynamic_memory     = false
  memory_startup_bytes = var.vm_memory_gb * 1024 * 1024 * 1024
  
  vm_firmware {
    enable_secure_boot   = "On"
    secure_boot_template = "MicrosoftWindows"
    boot_order {
      boot_type           = "HardDiskDrive"
      controller_number   = 0
      controller_location = 0
    }
  }

  network_adaptors {
    name        = "LAN"
    switch_name = "Production"
  }

  hard_disk_drives {
    controller_type     = "Scsi"
    controller_number   = 0
    controller_location = 0
    path                = hyperv_vhd.os_disk.path
  }

  depends_on = [hyperv_vhd.os_disk]
}

Running Terraform to Provision the VM

With providers.tf, variables.tf, and main.tf in place, initialize and apply:

# Initialize — downloads the Hyper-V provider
terraform init

# Plan — shows what will be created without making changes
terraform plan -var="hyperv_user=Administrator" -var="hyperv_password=MyPassword"

# Apply — creates the VM
terraform apply -var="hyperv_user=Administrator" -var="hyperv_password=MyPassword" -auto-approve

Sensitive variables should never be passed on the command line in production. Use a terraform.tfvars file (excluded from version control via .gitignore) or environment variables:

# terraform.tfvars (do NOT commit to git)
hyperv_user     = "Administrator"
hyperv_password = "YourSecurePassword"
vm_name         = "WebServer01"
vm_cpu_count    = 8
vm_memory_gb    = 16
# Alternatively use environment variables (TF_VAR_ prefix)
$env:TF_VAR_hyperv_user     = "Administrator"
$env:TF_VAR_hyperv_password = "YourSecurePassword"
terraform apply

Combining Terraform with PowerShell DSC for Configuration

Terraform provisions the VM; PowerShell Desired State Configuration (DSC) configures its software state. DSC defines the server’s configuration declaratively — what Windows features should be installed, what services should run, what registry keys should exist — and enforces that state on every run.

Create a DSC configuration script for a Windows web server:

# WebServerConfig.ps1
Configuration WebServerConfig {
    param (
        [string]$ComputerName = "localhost"
    )

    Import-DscResource -ModuleName PSDesiredStateConfiguration
    Import-DscResource -ModuleName xWebAdministration

    Node $ComputerName {
        WindowsFeature IIS {
            Name   = "Web-Server"
            Ensure = "Present"
        }

        WindowsFeature ASP_NET45 {
            Name      = "Web-Asp-Net45"
            Ensure    = "Present"
            DependsOn = "[WindowsFeature]IIS"
        }

        Service W3SVC {
            Name        = "W3SVC"
            State       = "Running"
            StartupType = "Automatic"
            DependsOn   = "[WindowsFeature]IIS"
        }

        Registry DisableNetBIOS {
            Key       = "HKLM:SYSTEMCurrentControlSetServicesNetBTParameters"
            ValueName = "NetbiosOptions"
            ValueData = "2"
            ValueType = "Dword"
            Ensure    = "Present"
        }
    }
}

# Compile to MOF
WebServerConfig -ComputerName "WebServer01" -OutputPath "C:DSCWebServerConfig"

Apply the DSC configuration to the provisioned VM using a Terraform remote-exec provisioner (via WinRM):

resource "null_resource" "configure_vm" {
  triggers = {
    vm_id = hyperv_machine_instance.windows_server.id
  }

  connection {
    type     = "winrm"
    host     = var.vm_ip_address
    user     = "Administrator"
    password = var.vm_admin_password
    port     = 5986
    https    = true
    insecure = true
    timeout  = "10m"
  }

  provisioner "file" {
    source      = "DSC/WebServerConfig.ps1"
    destination = "C:\Temp\WebServerConfig.ps1"
  }

  provisioner "remote-exec" {
    inline = [
      "powershell -ExecutionPolicy Bypass -File C:\Temp\WebServerConfig.ps1",
      "powershell Start-DscConfiguration -Path C:\DSC\WebServerConfig -Wait -Verbose -Force"
    ]
  }

  depends_on = [hyperv_machine_instance.windows_server]
}

Provisioning Azure Windows VMs with the azurerm Provider

For Azure-hosted Windows Server 2022 VMs, use the official HashiCorp azurerm provider. The configuration pattern is similar but uses Azure-specific resource types:

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.100"
    }
  }
}

provider "azurerm" {
  features {}
  subscription_id = var.azure_subscription_id
}

resource "azurerm_resource_group" "rg" {
  name     = "rg-windows-servers-prod"
  location = "East US"
}

resource "azurerm_virtual_network" "vnet" {
  name                = "vnet-prod"
  address_space       = ["10.10.0.0/16"]
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
}

resource "azurerm_subnet" "subnet" {
  name                 = "snet-servers"
  resource_group_name  = azurerm_resource_group.rg.name
  virtual_network_name = azurerm_virtual_network.vnet.name
  address_prefixes     = ["10.10.1.0/24"]
}

resource "azurerm_network_interface" "nic" {
  name                = "nic-webserver01"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name

  ip_configuration {
    name                          = "internal"
    subnet_id                     = azurerm_subnet.subnet.id
    private_ip_address_allocation = "Dynamic"
  }
}

resource "azurerm_windows_virtual_machine" "webserver" {
  name                = "webserver01"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
  size                = "Standard_D4s_v5"
  admin_username      = "winadmin"
  admin_password      = var.vm_admin_password

  network_interface_ids = [azurerm_network_interface.nic.id]

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Premium_LRS"
    disk_size_gb         = 128
  }

  source_image_reference {
    publisher = "MicrosoftWindowsServer"
    offer     = "WindowsServer"
    sku       = "2022-Datacenter"
    version   = "latest"
  }

  winrm_listener {
    protocol = "Http"
  }

  tags = {
    environment = "production"
    managed_by  = "terraform"
    team        = "infrastructure"
  }
}

Terraform Modules for Reusable Windows Server Templates

Terraform modules allow you to package a complete Windows server configuration as a reusable unit. Create a module structure for a standard Windows web server:

modules/
  windows-webserver/
    main.tf        # VM + NIC + disk resources
    variables.tf   # Inputs: name, size, location, subnet_id
    outputs.tf     # Outputs: vm_id, private_ip
    README.md

Reference the module from your environment-specific root configuration:

# environments/prod/main.tf
module "webserver_01" {
  source = "../../modules/windows-webserver"

  vm_name              = "webserver01"
  vm_size              = "Standard_D4s_v5"
  location             = "East US"
  resource_group_name  = azurerm_resource_group.rg.name
  subnet_id            = azurerm_subnet.subnet.id
  admin_password       = var.vm_admin_password
  os_disk_size_gb      = 128
  environment_tag      = "production"
}

module "webserver_02" {
  source = "../../modules/windows-webserver"

  vm_name              = "webserver02"
  vm_size              = "Standard_D4s_v5"
  location             = "East US"
  resource_group_name  = azurerm_resource_group.rg.name
  subnet_id            = azurerm_subnet.subnet.id
  admin_password       = var.vm_admin_password
  os_disk_size_gb      = 128
  environment_tag      = "production"
}

Terraform State Management for Windows Infrastructure

Terraform state files track the current state of provisioned infrastructure. For team environments, state must be stored remotely — never on a local workstation — so that multiple engineers and CI/CD pipelines see the same state. Use Azure Blob Storage as a remote backend for Azure-hosted Windows infrastructure:

# Create the storage account and container for state (run once manually or via bootstrap script)
az group create --name rg-terraform-state --location eastus
az storage account create --name tfstatewininfra --resource-group rg-terraform-state --sku Standard_LRS --min-tls-version TLS1_2
az storage container create --name tfstate --account-name tfstatewininfra
# Configure remote backend in terraform block (backend.tf)
terraform {
  backend "azurerm" {
    resource_group_name  = "rg-terraform-state"
    storage_account_name = "tfstatewininfra"
    container_name       = "tfstate"
    key                  = "prod/windows-servers.tfstate"
  }
}

For S3-compatible storage with on-premises deployments (MinIO, NetApp StorageGRID), use the S3 backend:

terraform {
  backend "s3" {
    bucket                      = "terraform-state"
    key                         = "prod/hyperv-vms.tfstate"
    region                      = "us-east-1"
    endpoint                    = "https://minio.corp.example.com"
    access_key                  = var.minio_access_key
    secret_key                  = var.minio_secret_key
    skip_credentials_validation = true
    skip_metadata_api_check     = true
    skip_region_validation      = true
    force_path_style            = true
  }
}

Terragrunt for Multi-Environment Windows Deployments

Terragrunt is a thin wrapper around Terraform that adds DRY (Don’t Repeat Yourself) support for multi-environment deployments. Instead of maintaining separate copies of your Terraform configuration for dev, staging, and prod — each with slightly different variable values — Terragrunt inherits from a single parent configuration and overrides only what differs per environment.

Install Terragrunt:

winget install --id gruntwork-io.terragrunt
# or on Linux:
# wget https://github.com/gruntwork-io/terragrunt/releases/latest/download/terragrunt_linux_amd64
# chmod +x terragrunt_linux_amd64 && mv terragrunt_linux_amd64 /usr/local/bin/terragrunt

Structure your repository for multi-environment management:

infra/
  terragrunt.hcl          # Root config: remote state, common variables
  prod/
    terragrunt.hcl        # Prod overrides: prod subscription, larger VM sizes
    webservers/
      terragrunt.hcl      # Points to module, sets prod-specific inputs
  staging/
    terragrunt.hcl        # Staging overrides: staging subscription, smaller VMs
    webservers/
      terragrunt.hcl
  modules/
    windows-webserver/    # Shared module used by all environments

Root terragrunt.hcl configures the remote backend with environment-specific state paths using path_relative_to_include():

# infra/terragrunt.hcl
remote_state {
  backend = "azurerm"
  generate = {
    path      = "backend.tf"
    if_exists = "overwrite"
  }
  config = {
    resource_group_name  = "rg-terraform-state"
    storage_account_name = "tfstatewininfra"
    container_name       = "tfstate"
    key                  = "${path_relative_to_include()}/terraform.tfstate"
  }
}

inputs = {
  azure_location = "East US"
  common_tags = {
    managed_by = "terragrunt"
    repo       = "github.com/corp/windows-infra"
  }
}

Deploy all environments in parallel with Terragrunt’s run-all command:

# Plan all environments
cd infra
terragrunt run-all plan

# Apply all environments simultaneously (parallel execution)
terragrunt run-all apply --terragrunt-non-interactive

# Apply only a specific environment
cd infra/prod/webservers
terragrunt apply

Summary

Automating Windows Server 2022 provisioning with Terraform and PowerShell DSC creates a reproducible, auditable, and scalable infrastructure pipeline. Terraform manages VM lifecycle on Hyper-V or Azure, PowerShell DSC ensures consistent OS configuration, WinRM enables remote-exec provisioners without SSH, Terraform modules promote reuse across server roles, remote state backends prevent team conflicts, and Terragrunt eliminates configuration duplication across dev/staging/prod environments. Together these tools shift Windows infrastructure management from manual console work to version-controlled code that can be reviewed, tested, and applied consistently by any team member or CI/CD pipeline.