How to Automate Windows Server Provisioning with PowerShell and Terraform on Windows Server 2025
Manual server provisioning — spinning up a VM, installing Windows, running through a checklist of post-install tasks, joining a domain, deploying an application — introduces inconsistency, takes hours per server, and is impossible to reproduce reliably at scale. Infrastructure-as-Code (IaC) solves this by encoding every provisioning decision in version-controlled files that can be executed repeatedly to produce identical, documented results. This tutorial builds a complete, end-to-end pipeline for provisioning Windows Server 2025 environments: Terraform to declare and provision the infrastructure layer (either a Hyper-V VM on-premises or an Azure VM), Packer to build a golden image with Windows Server 2025 pre-configured to your baseline, PowerShell DSC to handle in-guest configuration (domain join, role installation, application deployment), and Ansible for idempotent application-layer configuration. The result is a pipeline where a single terraform apply triggers the entire stack and produces a fully configured, production-ready Windows Server 2025 instance in minutes.
Prerequisites
- Terraform 1.8 or later installed on your build machine
- HashiCorp Packer 1.11 or later for golden image builds
- Ansible 9.x with the
ansible.windowscollection installed (ansible-galaxy collection install ansible.windows) - Windows Server 2025 ISO or an Azure Marketplace image reference
- A Windows Remote Management (WinRM) HTTPS endpoint reachable from your build machine
- PowerShell DSC resources:
PSDesiredStateConfiguration,ComputerManagementDsc,xWebAdministration - Azure CLI or Hyper-V with appropriate admin rights for the chosen infrastructure target
Step 1: Build a Golden Image with HashiCorp Packer
A golden image is a base VM image with Windows Server 2025 installed, licensed, and configured with your organisation’s hardening baseline. Building one with Packer ensures the image is reproducible and updated on a regular cadence.
# packer/windows2025-golden.pkr.hcl
packer {
required_plugins {
azure = {
source = "github.com/hashicorp/azure"
version = ">= 2.0.0"
}
}
}
variable "subscription_id" { type = string }
variable "client_id" { type = string }
variable "client_secret" { type = string
sensitive = true }
variable "tenant_id" { type = string }
source "azure-arm" "ws2025_golden" {
subscription_id = var.subscription_id
client_id = var.client_id
client_secret = var.client_secret
tenant_id = var.tenant_id
managed_image_name = "WS2025-Golden-${formatdate("YYYYMMDD", timestamp())}"
managed_image_resource_group_name = "rg-images-prod"
os_type = "Windows"
image_publisher = "MicrosoftWindowsServer"
image_offer = "WindowsServer"
image_sku = "2025-datacenter-azure-edition-core"
image_version = "latest"
location = "eastus"
vm_size = "Standard_D4s_v5"
communicator = "winrm"
winrm_use_ssl = true
winrm_insecure = false
winrm_timeout = "10m"
winrm_username = "packer"
winrm_password = var.winrm_password
}
build {
sources = ["source.azure-arm.ws2025_golden"]
# Apply Windows baseline hardening via PowerShell
provisioner "powershell" {
scripts = [
"scripts/install-base-features.ps1",
"scripts/cis-hardening.ps1",
"scripts/install-monitoring-agent.ps1"
]
}
# Run Windows Update and reboot until fully patched
provisioner "windows-update" {
search_criteria = "IsInstalled=0"
filters = ["exclude:$_.Title -like '*Preview*'"]
update_limit = 50
}
# Sysprep for generalisation
provisioner "powershell" {
inline = [
"& $env:SystemRoot\System32\Sysprep\sysprep.exe /oobe /generalize /quiet /quit /mode:vm",
"while ((Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Setup\State').ImageState -ne 'IMAGE_STATE_GENERALIZE_RESEAL_TO_OOBE') { Start-Sleep -Seconds 5 }"
]
}
}
# Build the golden image
cd packer/
packer init .
packer build -var-file="variables.pkrvars.hcl" windows2025-golden.pkr.hcl
Step 2: Provision Infrastructure with Terraform
With the golden image built, Terraform declares the infrastructure: the VM, networking, storage, and any supporting resources. Terraform state tracks what exists so subsequent runs are idempotent.
# terraform/main.tf
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
backend "azurerm" {
resource_group_name = "rg-tfstate"
storage_account_name = "tfstatecontoso001"
container_name = "tfstate"
key = "ws2025-webserver.tfstate"
}
}
provider "azurerm" {
features {}
}
data "azurerm_image" "ws2025_golden" {
name = "WS2025-Golden-20251101"
resource_group_name = "rg-images-prod"
}
resource "azurerm_resource_group" "webservers" {
name = "rg-webservers-prod"
location = "eastus"
}
resource "azurerm_windows_virtual_machine" "webserver" {
name = var.vm_name
resource_group_name = azurerm_resource_group.webservers.name
location = azurerm_resource_group.webservers.location
size = "Standard_D4s_v5"
admin_username = "localadmin"
admin_password = var.admin_password
source_image_id = data.azurerm_image.ws2025_golden.id
os_disk {
caching = "ReadWrite"
storage_account_type = "Premium_LRS"
disk_size_gb = 128
}
network_interface_ids = [azurerm_network_interface.webserver_nic.id]
# Bootstrap WinRM for Terraform remote-exec provisioner
custom_data = filebase64("${path.module}/scripts/winrm-bootstrap.ps1")
# Apply DSC configuration after the VM is provisioned
provisioner "remote-exec" {
connection {
type = "winrm"
user = "localadmin"
password = var.admin_password
host = self.public_ip_address
https = true
insecure = false
timeout = "10m"
}
inline = [
"powershell -ExecutionPolicy Bypass -File C:\Bootstrap\Apply-DSCConfig.ps1 -DomainName ${var.domain_name} -DomainJoinUser ${var.domain_join_user} -DomainJoinPassword ${var.domain_join_password}"
]
}
}
Step 3: Bootstrap WinRM for Remote Execution
# scripts/winrm-bootstrap.ps1 — runs via Azure Custom Script Extension / user-data
# Configures WinRM HTTPS with a self-signed cert for initial provisioning only
# A proper CA cert is deployed by DSC in the subsequent configuration pass
$ErrorActionPreference = "Stop"
# Create a self-signed cert for WinRM bootstrap
$cert = New-SelfSignedCertificate `
-Subject "CN=$env:COMPUTERNAME" `
-DnsName $env:COMPUTERNAME `
-CertStoreLocation Cert:LocalMachineMy `
-KeyAlgorithm RSA -KeyLength 2048 `
-HashAlgorithm SHA256 `
-NotAfter (Get-Date).AddDays(1) # Short-lived — Terraform provisioning only
# Configure WinRM HTTPS listener
$listenParams = @{
ResourceURI = "winrm/config/Listener"
SelectorSet = @{ Address = "*"; Transport = "HTTPS" }
ValueSet = @{ Hostname = $env:COMPUTERNAME; CertificateThumbprint = $cert.Thumbprint }
}
New-WSManInstance @listenParams
# Open firewall port 5986
New-NetFirewallRule -DisplayName "WinRM HTTPS" -Direction Inbound `
-LocalPort 5986 -Protocol TCP -Action Allow
# Ensure WinRM service is running
Set-Service -Name WinRM -StartupType Automatic
Start-Service -Name WinRM
Write-Host "WinRM HTTPS bootstrap complete on port 5986"
Step 4: Configure the Server with PowerShell DSC
# dsc/WebServerConfig.ps1 — DSC configuration for a Windows Server 2025 IIS web server
Configuration WebServerConfig {
param(
[string]$DomainName,
[PSCredential]$DomainJoinCredential,
[string]$SiteName = "ContosoWebApp"
)
Import-DscResource -ModuleName PSDesiredStateConfiguration
Import-DscResource -ModuleName ComputerManagementDsc
Import-DscResource -ModuleName xWebAdministration
Node "localhost" {
# Install IIS and management tools
WindowsFeature IIS {
Name = "Web-Server"
Ensure = "Present"
}
WindowsFeature IISManagement {
Name = "Web-Mgmt-Tools"
Ensure = "Present"
DependsOn = "[WindowsFeature]IIS"
}
WindowsFeature ASPNET45 {
Name = "Web-Asp-Net45"
Ensure = "Present"
DependsOn = "[WindowsFeature]IIS"
}
# Remove the default website
xWebsite DefaultSite {
Name = "Default Web Site"
Ensure = "Absent"
PhysicalPath = "C:inetpubwwwroot"
DependsOn = "[WindowsFeature]IIS"
}
# Create the application website
File WebContent {
DestinationPath = "C:inetpub$SiteName"
Type = "Directory"
Ensure = "Present"
}
xWebsite ContosoSite {
Name = $SiteName
Ensure = "Present"
PhysicalPath = "C:inetpub$SiteName"
State = "Started"
BindingInfo = MSFT_xWebBindingInformation { Protocol = "HTTP"; Port = 80 }
DependsOn = "[File]WebContent","[xWebsite]DefaultSite"
}
# Join the domain
Computer DomainJoin {
Name = $Node.NodeName
DomainName = $DomainName
Credential = $DomainJoinCredential
}
}
}
# Compile to MOF and apply
WebServerConfig -DomainName "contoso.com" `
-DomainJoinCredential (Get-Credential -Message "Domain join account") `
-OutputPath "C:DSCWebServerConfig"
Start-DscConfiguration -Path "C:DSCWebServerConfig" -Wait -Verbose -Force
Step 5: Application Deployment with Ansible
# ansible/playbooks/deploy-webapp.yml
---
- name: Deploy Contoso Web Application to Windows Server 2025
hosts: webservers
gather_facts: true
vars:
app_version: "2.5.1"
app_package_url: "https://artifacts.contoso.com/webapp/{{ app_version }}/webapp.msi"
app_install_path: "C:\Program Files\ContosoWebApp"
iis_site_name: "ContosoWebApp"
tasks:
- name: Download application installer
ansible.windows.win_get_url:
url: "{{ app_package_url }}"
dest: "C:\Temp\webapp-{{ app_version }}.msi"
checksum_algorithm: sha256
checksum: "{{ app_checksum }}"
- name: Install application via MSI (idempotent)
ansible.windows.win_package:
path: "C:\Temp\webapp-{{ app_version }}.msi"
state: present
arguments: "/qn INSTALLDIR="{{ app_install_path }}""
product_id: "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
- name: Deploy application configuration file
ansible.windows.win_template:
src: templates/appsettings.json.j2
dest: "{{ app_install_path }}\appsettings.json"
notify: restart iis site
- name: Ensure IIS application pool is running
community.windows.win_iis_webapppool:
name: "{{ iis_site_name }}"
state: started
attributes:
managedRuntimeVersion: "v4.0"
enable32BitAppOnWin64: false
handlers:
- name: restart iis site
ansible.windows.win_service:
name: W3SVC
state: restarted
# Run Ansible playbook against provisioned Windows servers
# Inventory file: ansible/inventories/production/hosts.ini
# [webservers]
# 10.0.1.20 ansible_user=localadmin ansible_password={{ vault_admin_password }} ansible_connection=winrm ansible_winrm_transport=ntlm ansible_winrm_scheme=https ansible_winrm_server_cert_validation=validate
ansible-playbook -i inventories/production/hosts.ini playbooks/deploy-webapp.yml
--vault-password-file ~/.ansible/vault-pass
--diff
Step 6: Full Pipeline Orchestration
# pipeline.ps1 — orchestrates the full Terraform → DSC → Ansible pipeline
param(
[string]$Environment = "production",
[string]$VmName = "webserver-prod-01",
[switch]$BuildImage
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# Step 1: Build golden image (weekly or on demand)
if ($BuildImage) {
Write-Host "[1/4] Building Packer golden image..." -ForegroundColor Cyan
Push-Location ./packer
packer build -var-file="variables.pkrvars.hcl" windows2025-golden.pkr.hcl
if ($LASTEXITCODE -ne 0) { throw "Packer build failed" }
Pop-Location
}
# Step 2: Provision infrastructure with Terraform
Write-Host "[2/4] Running Terraform apply..." -ForegroundColor Cyan
Push-Location ./terraform
terraform init -backend-config="env/$Environment/backend.conf"
terraform plan -var="vm_name=$VmName" -var-file="env/$Environment/terraform.tfvars" -out=tfplan
terraform apply -auto-approve tfplan
if ($LASTEXITCODE -ne 0) { throw "Terraform apply failed" }
$vmIp = terraform output -raw vm_public_ip
Pop-Location
# Step 3: Wait for WinRM to become available
Write-Host "[3/4] Waiting for WinRM on $vmIp..." -ForegroundColor Cyan
$maxWait = 300; $elapsed = 0
while ($elapsed -lt $maxWait) {
try {
$result = Test-NetConnection -ComputerName $vmIp -Port 5986 -WarningAction SilentlyContinue
if ($result.TcpTestSucceeded) { break }
} catch {}
Start-Sleep -Seconds 10; $elapsed += 10
}
# Step 4: Run Ansible application deployment
Write-Host "[4/4] Deploying application with Ansible..." -ForegroundColor Cyan
Push-Location ./ansible
ansible-playbook -i "inventories/$Environment/hosts.ini" playbooks/deploy-webapp.yml `
--extra-vars "target_host=$vmIp" `
--vault-password-file ~/.ansible/vault-pass
if ($LASTEXITCODE -ne 0) { throw "Ansible deployment failed" }
Pop-Location
Write-Host "`nProvisioning complete. Server $VmName is available at $vmIp" -ForegroundColor Green
Automating Windows Server 2025 provisioning with Terraform, Packer, PowerShell DSC, and Ansible transforms infrastructure delivery from a multi-hour manual process into a reliable, repeatable pipeline. Packer ensures your golden image is always patched and hardened before any deployment begins. Terraform manages the infrastructure lifecycle idempotently, so re-running the pipeline corrects any configuration drift at the infrastructure layer. DSC handles OS-level configuration — domain join, roles, services — in a declarative model that self-corrects if settings change between runs. Ansible covers the application layer with rich Windows module support. Together, these tools create a pipeline where every provisioned server is documented in code, auditable through version control, and reproducible from scratch in minutes — the foundation of a mature Infrastructure-as-Code practice.