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.windows collection 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.