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.