Introduction: Automating Windows Server 2019 Provisioning

Combining PowerShell and HashiCorp Terraform creates a powerful two-stage infrastructure-as-code pipeline for Windows Server 2019. Terraform handles the infrastructure layer—creating VMs in Hyper-V, VMware vSphere, or Azure, configuring networking and storage, and producing consistent, idempotent resource definitions in version-controlled .tf files. PowerShell handles the OS configuration layer—domain joining, feature installation, service configuration, and application deployment—invoked as Terraform provisioners or as standalone scripts triggered post-deployment. Together they replace manual server builds with a fully automated, repeatable pipeline.

Prerequisites


# Install Terraform on Windows Server 2019 (management node)
# Download from https://developer.hashicorp.com/terraform/downloads
# Or use Chocolatey:
choco install terraform -y
terraform version

# Install required Terraform providers
# Providers are declared in the .tf file and downloaded by terraform init

# Install PowerShell 7 (for cross-platform compatible automation scripts)
# Download MSI from https://github.com/PowerShell/PowerShell/releases
# Or:
iex "& { $(irm https://aka.ms/install-powershell.ps1) } -UseMSI"

# Install required PowerShell modules
Install-Module -Name AzureRM, Az -Force -AllowClobber  # if targeting Azure
Install-Module -Name VMware.PowerCLI -Force             # if targeting vSphere

Project Structure


# Recommended project directory layout
# windows-server-provisioning/
# ├── main.tf                  - Main resource definitions
# ├── variables.tf             - Input variable declarations
# ├── outputs.tf               - Output value definitions
# ├── provider.tf              - Provider configuration
# ├── terraform.tfvars         - Variable values (gitignored for secrets)
# ├── scripts/
# │   ├── Configure-Server.ps1 - Post-provision PowerShell configuration
# │   ├── Join-Domain.ps1      - Domain join script
# │   ├── Install-Features.ps1 - Windows feature installation
# │   └── Harden-Server.ps1    - Security hardening
# └── modules/
#     ├── windows-vm/          - Reusable VM module
#     └── network/             - Network configuration module

Terraform Configuration for Azure


# provider.tf
terraform {
  required_version = ">= 1.5"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.75"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.5"
    }
  }
  backend "azurerm" {
    resource_group_name  = "rg-terraform-state"
    storage_account_name = "tfstatestorage001"
    container_name       = "tfstate"
    key                  = "windows-servers.tfstate"
  }
}

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

Variables and Locals


# variables.tf
variable "subscription_id" { type = string }
variable "tenant_id"        { type = string }
variable "location"         { type = string; default = "East US" }
variable "environment"      { type = string; default = "prod" }
variable "server_count"     { type = number; default = 2 }

variable "vm_size" {
  type    = string
  default = "Standard_D4s_v3"
  validation {
    condition     = can(regex("^Standard_", var.vm_size))
    error_message = "VM size must be a Standard SKU."
  }
}

variable "admin_password" {
  type      = string
  sensitive = true
}

variable "domain_name"     { type = string; default = "corp.local" }
variable "domain_join_user" { type = string }
variable "domain_join_pass" { type = string; sensitive = true }

# locals.tf
locals {
  common_tags = {
    Environment = var.environment
    ManagedBy   = "Terraform"
    Project     = "WindowsServerProvisioning"
    CreatedDate = formatdate("YYYY-MM-DD", timestamp())
  }
  server_names = [for i in range(var.server_count) : format("WINSRV%02d", i + 1)]
}

Main Terraform Resource Definitions


# main.tf
resource "azurerm_resource_group" "servers" {
  name     = "rg-windows-servers-${var.environment}"
  location = var.location
  tags     = local.common_tags
}

resource "azurerm_virtual_network" "server_vnet" {
  name                = "vnet-servers-${var.environment}"
  address_space       = ["10.10.0.0/16"]
  location            = azurerm_resource_group.servers.location
  resource_group_name = azurerm_resource_group.servers.name
  tags                = local.common_tags
}

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

resource "azurerm_network_interface" "server_nic" {
  count               = var.server_count
  name                = "nic-${local.server_names[count.index]}"
  location            = azurerm_resource_group.servers.location
  resource_group_name = azurerm_resource_group.servers.name

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

  tags = local.common_tags
}

resource "azurerm_windows_virtual_machine" "server" {
  count               = var.server_count
  name                = local.server_names[count.index]
  resource_group_name = azurerm_resource_group.servers.name
  location            = azurerm_resource_group.servers.location
  size                = var.vm_size
  admin_username      = "svc-terraform-admin"
  admin_password      = var.admin_password

  network_interface_ids = [azurerm_network_interface.server_nic[count.index].id]

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

  source_image_reference {
    publisher = "MicrosoftWindowsServer"
    offer     = "WindowsServer"
    sku       = "2019-datacenter-core"
    version   = "latest"
  }

  identity {
    type = "SystemAssigned"
  }

  tags = local.common_tags
}

PowerShell Post-Provisioning via Terraform


# In main.tf - Azure VM Extension to run PowerShell after provisioning
resource "azurerm_virtual_machine_extension" "configure" {
  count                = var.server_count
  name                 = "configure-server"
  virtual_machine_id   = azurerm_windows_virtual_machine.server[count.index].id
  publisher            = "Microsoft.Compute"
  type                 = "CustomScriptExtension"
  type_handler_version = "1.10"

  protected_settings = jsonencode({
    commandToExecute = "powershell -ExecutionPolicy Unrestricted -File Configure-Server.ps1 -DomainName ${var.domain_name} -JoinUser ${var.domain_join_user} -JoinPass ${var.domain_join_pass}"
    fileUris         = ["https://stgprovision001.blob.core.windows.net/scripts/Configure-Server.ps1"]
    storageAccountName = "stgprovision001"
    storageAccountKey  = var.storage_key
  })

  tags = local.common_tags
}

PowerShell Configuration Script


# scripts/Configure-Server.ps1
[CmdletBinding()]
param(
    [string]$DomainName = 'corp.local',
    [string]$JoinUser,
    [string]$JoinPass,
    [string[]]$Features = @('Web-Server','Web-Asp-Net45','RSAT-AD-PowerShell')
)

$ErrorActionPreference = 'Stop'
Start-Transcript -Path 'C:ProvisioningConfigure-Server.log' -Append

# Set timezone
Set-TimeZone -Name 'UTC'

# Set NTP
w32tm /config /manualpeerlist:"time.windows.com,0x9" /syncfromflags:manual /reliable:YES /update
Restart-Service w32tm

# Disable SMBv1
Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force

# Enable Windows Firewall
Set-NetFirewallProfile -Profile Domain,Private,Public -Enabled True

# Install specified Windows features
foreach ($feature in $Features) {
    Write-Output "Installing feature: $feature"
    Install-WindowsFeature -Name $feature -IncludeManagementTools
}

# Configure WinRM for remote management
Enable-PSRemoting -Force
Set-Item WSMan:localhostServiceAuthBasic $false

# Domain join
if ($DomainName -and $JoinUser -and $JoinPass) {
    $cred = New-Object PSCredential($JoinUser, (ConvertTo-SecureString $JoinPass -AsPlainText -Force))
    Add-Computer -DomainName $DomainName -Credential $cred -OUPath 'OU=AutoProvisioned,OU=Servers,DC=corp,DC=local' -Restart -Force
}

Stop-Transcript

Running Terraform


# Initialize Terraform and download providers
terraform init

# Validate configuration syntax
terraform validate

# Format code
terraform fmt -recursive

# Plan the deployment (review changes before applying)
terraform plan -var-file="terraform.tfvars" -out="deployment.tfplan"

# Apply the plan
terraform apply "deployment.tfplan"

# Apply with specific variable overrides (e.g., scale to 5 servers)
terraform apply -var="server_count=5" -auto-approve

# Destroy all resources (with confirmation)
terraform destroy -var-file="terraform.tfvars"

# Destroy only specific resources
terraform destroy -target="azurerm_windows_virtual_machine.server[0]" -var-file="terraform.tfvars"

# Refresh state (sync Terraform's view with actual cloud state)
terraform refresh -var-file="terraform.tfvars"

Terraform State Management


# List all resources in state
terraform state list

# Show details of a specific resource
terraform state show 'azurerm_windows_virtual_machine.server[0]'

# Move resource in state (for refactoring)
terraform state mv 'azurerm_windows_virtual_machine.server[0]' 'module.servers.azurerm_windows_virtual_machine.server[0]'

# Import an existing resource into Terraform management
terraform import 'azurerm_windows_virtual_machine.server[0]' '/subscriptions/xxx/resourceGroups/rg-servers/providers/Microsoft.Compute/virtualMachines/WINSRV01'

# Taint a resource to force recreation on next apply
terraform taint 'azurerm_windows_virtual_machine.server[0]'

# Unlock a stuck state
terraform force-unlock 

Outputs for Integration


# outputs.tf
output "server_private_ips" {
  description = "Private IP addresses of all provisioned servers"
  value       = azurerm_network_interface.server_nic[*].ip_configuration[0].private_ip_address
}

output "server_names" {
  description = "Names of all provisioned servers"
  value       = azurerm_windows_virtual_machine.server[*].name
}

output "server_ids" {
  description = "Azure resource IDs of all provisioned VMs"
  value       = azurerm_windows_virtual_machine.server[*].id
  sensitive   = false
}

# After apply, display outputs
# terraform output server_private_ips
# ["10.10.1.4","10.10.1.5"]

# Use output in a PowerShell post-provisioning step
$serverIPs = (terraform output -json server_private_ips | ConvertFrom-Json)
foreach ($ip in $serverIPs) {
    Invoke-Command -ComputerName $ip -ScriptBlock {
        Install-WindowsFeature -Name AD-Domain-Services -IncludeManagementTools
    }
}

Conclusion

Combining Terraform and PowerShell for Windows Server 2019 provisioning creates a complete infrastructure-as-code pipeline that is version-controlled, peer-reviewable, and reproducible across environments. Terraform’s declarative syntax manages cloud or on-premises infrastructure state, while PowerShell handles the Windows-specific configuration that Terraform cannot natively drive. Together they eliminate manual server builds, ensure consistent baseline configurations across hundreds of servers, and provide a documented audit trail of every infrastructure change through the commit history of your Terraform repository.