Introduction to PowerShell Automated Deployments
Automating deployments with PowerShell on Windows Server 2022 eliminates manual steps, reduces human error, and creates repeatable, auditable release processes. Whether you are deploying web applications to IIS, installing Windows Services, or pushing changes to remote servers, PowerShell provides a consistent scripting surface that integrates with virtually every CI/CD pipeline. This guide walks through practical deployment patterns that work in real production environments.
Deploying Web Applications to IIS
The WebAdministration module, built into Windows Server 2022, provides full control over IIS sites, application pools, bindings, and configuration. Before writing deployment scripts, ensure the IIS role and management tools are installed:
Install-WindowsFeature -Name Web-Server, Web-Mgmt-Tools -IncludeManagementTools
Import the module at the top of any IIS deployment script:
Import-Module WebAdministration
A complete IIS deployment function that creates or updates a site looks like this:
function Deploy-WebApplication {
param(
[string]$SiteName,
[string]$PhysicalPath,
[string]$AppPoolName,
[int]$Port = 80,
[string]$ArtifactPath
)
# Create destination directory if needed
if (-not (Test-Path $PhysicalPath)) {
New-Item -ItemType Directory -Path $PhysicalPath -Force | Out-Null
}
# Sync files from artifact drop
robocopy $ArtifactPath $PhysicalPath /MIR /NJH /NJS /R:2 /W:5
if ($LASTEXITCODE -ge 8) {
throw "Robocopy failed with exit code $LASTEXITCODE"
}
# Create or reconfigure application pool
if (-not (Test-Path "IIS:AppPools$AppPoolName")) {
New-WebAppPool -Name $AppPoolName
}
Set-WebConfigurationProperty -Filter "/system.applicationHost/applicationPools/add[@name='$AppPoolName']" `
-Name "managedRuntimeVersion" -Value "v4.0"
Set-WebConfigurationProperty -Filter "/system.applicationHost/applicationPools/add[@name='$AppPoolName']" `
-Name "startMode" -Value "AlwaysRunning"
# Create or update the IIS site
if (-not (Test-Path "IIS:Sites$SiteName")) {
New-WebSite -Name $SiteName -PhysicalPath $PhysicalPath `
-ApplicationPool $AppPoolName -Port $Port
} else {
Set-ItemProperty "IIS:Sites$SiteName" -Name physicalPath -Value $PhysicalPath
Set-ItemProperty "IIS:Sites$SiteName" -Name applicationPool -Value $AppPoolName
}
# Restart the app pool to pick up new binaries
Restart-WebAppPool -Name $AppPoolName
Write-Host "Deployment of '$SiteName' complete."
}
Call this function from your release script with environment-specific parameters. The robocopy /MIR flag mirrors the source to the destination, removing files that no longer exist in the artifact, which ensures a clean deployment state.
Deploying and Managing Windows Services
Windows Services are commonly used to host background workers, schedulers, and APIs. A proper service deployment script must handle the case where the service already exists and may be running:
function Deploy-WindowsService {
param(
[string]$ServiceName,
[string]$DisplayName,
[string]$BinaryPath,
[string]$ArtifactPath,
[string]$InstallPath
)
$service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
# Stop and wait if running
if ($service -and $service.Status -eq 'Running') {
Stop-Service -Name $ServiceName -Force
$service.WaitForStatus('Stopped', '00:00:30')
Write-Host "Service stopped."
}
# Copy new binaries
if (-not (Test-Path $InstallPath)) {
New-Item -ItemType Directory -Path $InstallPath -Force | Out-Null
}
Copy-Item -Path "$ArtifactPath*" -Destination $InstallPath -Recurse -Force
# Register service if it does not yet exist
if (-not $service) {
New-Service -Name $ServiceName `
-DisplayName $DisplayName `
-BinaryPathName "$InstallPath$BinaryPath" `
-StartupType Automatic `
-Description "Deployed by CI/CD pipeline"
Write-Host "Service registered."
} else {
# Update binary path in case it changed
Set-Service -Name $ServiceName -StartupType Automatic
sc.exe config $ServiceName binPath= "$InstallPath$BinaryPath"
}
Start-Service -Name $ServiceName
Write-Host "Service '$ServiceName' started successfully."
}
# Example usage
Deploy-WindowsService -ServiceName "MyWorker" `
-DisplayName "My Background Worker" `
-BinaryPath "MyWorker.exe" `
-ArtifactPath "C:DropsMyWorkerlatest" `
-InstallPath "C:ServicesMyWorker"
Deploying to Remote Servers with Invoke-Command
PowerShell Remoting (WinRM) enables deployment scripts to execute commands on remote servers without any agent installation. Enable remoting on target servers first:
# Run on each target server once (as Administrator)
Enable-PSRemoting -Force
Set-Item WSMan:localhostClientTrustedHosts -Value "*" -Force
A remote deployment using Invoke-Command passes a script block to one or more servers simultaneously:
$targetServers = @("WEB01", "WEB02", "WEB03")
$cred = Get-Credential # Use a service account in production; store creds in vault
$deployBlock = {
param($ArtifactUNC, $LocalPath, $SiteName)
Import-Module WebAdministration
Stop-WebSite -Name $SiteName
robocopy $ArtifactUNC $LocalPath /MIR /NJH /NJS /R:2 /W:5
Start-WebSite -Name $SiteName
Write-Host "$env:COMPUTERNAME: Deployment complete."
}
Invoke-Command -ComputerName $targetServers `
-Credential $cred `
-ScriptBlock $deployBlock `
-ArgumentList "\fileserverdropswebapplatest", "C:inetpubwebapp", "MyWebApp"
By passing an array to -ComputerName, PowerShell fans out the execution in parallel across all servers. Each server reports its hostname and status when complete.
Using PS Remoting in CI/CD Pipelines
CI/CD systems like Azure DevOps, Jenkins, and GitHub Actions can invoke PowerShell remoting as a deployment step. In Azure DevOps, a PowerShell task can call a deployment script stored in source control:
# azure-pipelines.yml snippet
- task: PowerShell@2
displayName: 'Deploy to WEB01'
inputs:
targetType: 'filePath'
filePath: '$(Build.SourcesDirectory)deployDeploy-WebApp.ps1'
arguments: >
-Server "WEB01"
-ArtifactPath "$(Pipeline.Workspace)drop"
-SiteName "MyWebApp"
-AppPoolName "MyWebAppPool"
env:
DEPLOY_PASSWORD: $(DeployServiceAccountPassword)
Store credentials as pipeline secrets and retrieve them in the script via environment variables rather than hard-coding them. In Jenkins, use the withCredentials block to inject secrets securely before calling Invoke-Command.
Deploying from Artifacts Using Robocopy
Build pipelines typically produce a ZIP archive or a file-drop folder as the deployment artifact. A robust deployment script unpacks and syncs these files reliably:
function Expand-AndDeploy {
param(
[string]$ZipPath,
[string]$StagingPath,
[string]$DestinationPath
)
# Clean and re-create staging area
if (Test-Path $StagingPath) { Remove-Item $StagingPath -Recurse -Force }
New-Item -ItemType Directory -Path $StagingPath -Force | Out-Null
# Extract artifact
Expand-Archive -Path $ZipPath -DestinationPath $StagingPath -Force
Write-Host "Artifact extracted to $StagingPath"
# Mirror to destination
$result = robocopy $StagingPath $DestinationPath /MIR /NJH /NJS /NDL /R:3 /W:5
if ($LASTEXITCODE -ge 8) {
throw "Robocopy failed. Exit code: $LASTEXITCODE"
}
Write-Host "Files synced to $DestinationPath"
}
Environment-Specific Configuration Transformations
Applications typically have environment-specific settings: database connection strings, API endpoints, and feature flags differ between staging and production. PowerShell can apply transformations at deploy time instead of baking them into the artifact:
function Apply-ConfigTransform {
param(
[string]$ConfigPath,
[hashtable]$Replacements
)
$content = Get-Content -Path $ConfigPath -Raw
foreach ($key in $Replacements.Keys) {
$content = $content -replace [regex]::Escape($key), $Replacements[$key]
}
Set-Content -Path $ConfigPath -Value $content -Encoding UTF8
Write-Host "Configuration transformed: $ConfigPath"
}
# Usage - replace tokens written into config at build time
$env = "Production"
$replacements = @{
"__DB_SERVER__" = "sql-prod-01.internal"
"__DB_NAME__" = "AppDB_Prod"
"__API_BASE__" = "https://api.example.com"
"__LOG_LEVEL__" = "Warning"
}
Apply-ConfigTransform -ConfigPath "C:inetpubmyappappsettings.json" `
-Replacements $replacements
Rollback Scripts
Every deployment process needs a rollback path. The simplest pattern keeps the previous deployment in a versioned folder and switches a symlink or updates the IIS physical path:
function Rollback-WebDeployment {
param(
[string]$SiteName,
[string]$AppPoolName,
[string]$RollbackPath
)
if (-not (Test-Path $RollbackPath)) {
throw "Rollback path does not exist: $RollbackPath"
}
Import-Module WebAdministration
Stop-WebAppPool -Name $AppPoolName
Start-Sleep -Seconds 2
Set-ItemProperty "IIS:Sites$SiteName" -Name physicalPath -Value $RollbackPath
Start-WebAppPool -Name $AppPoolName
Write-Host "Rolled back '$SiteName' to $RollbackPath"
}
# Keep versioned directories: C:Deployswebappv20240501, v20240502, etc.
# Before each deploy, record current path to a state file
$currentPath = (Get-ItemProperty "IIS:SitesMyWebApp").physicalPath
$currentPath | Out-File "C:Deployswebapplast_good_path.txt"
Idempotent Deployment Patterns
Idempotent scripts can be run multiple times with the same result, making them safe to re-run after failures or to use as drift correction. The key principle is always checking state before taking action:
function Ensure-AppPool {
param([string]$Name, [string]$RuntimeVersion = "v4.0")
if (Test-Path "IIS:AppPools$Name") {
Write-Host "AppPool '$Name' already exists. Verifying configuration."
$pool = Get-Item "IIS:AppPools$Name"
if ($pool.managedRuntimeVersion -ne $RuntimeVersion) {
Set-WebConfigurationProperty `
-Filter "/system.applicationHost/applicationPools/add[@name='$Name']" `
-Name "managedRuntimeVersion" -Value $RuntimeVersion
Write-Host "Updated runtime version to $RuntimeVersion"
}
} else {
New-WebAppPool -Name $Name
Set-WebConfigurationProperty `
-Filter "/system.applicationHost/applicationPools/add[@name='$Name']" `
-Name "managedRuntimeVersion" -Value $RuntimeVersion
Write-Host "Created AppPool '$Name'"
}
}
function Ensure-FirewallRule {
param([string]$RuleName, [int]$Port, [string]$Protocol = "TCP")
$existing = Get-NetFirewallRule -DisplayName $RuleName -ErrorAction SilentlyContinue
if (-not $existing) {
New-NetFirewallRule -DisplayName $RuleName `
-Direction Inbound -Protocol $Protocol `
-LocalPort $Port -Action Allow
Write-Host "Firewall rule '$RuleName' created."
} else {
Write-Host "Firewall rule '$RuleName' already exists. Skipping."
}
}
By wrapping every configuration change in existence checks, the deployment script becomes both idempotent and self-documenting. Each function declares the desired state, not just an imperative action.
Structuring a Complete Deployment Pipeline Script
A production deployment script typically chains these functions with structured error handling and logging:
#Requires -Version 5.1
#Requires -Modules WebAdministration
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$ArtifactZip,
[Parameter(Mandatory)][string]$Environment,
[string]$SiteName = "MyWebApp",
[string]$AppPoolName = "MyWebAppPool",
[string]$DeployRoot = "C:Deployswebapp"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$deployPath = "$DeployRoot$timestamp"
$logFile = "$DeployRootdeploy_$timestamp.log"
function Write-Log {
param([string]$Msg)
$line = "[$(Get-Date -Format 'HH:mm:ss')] $Msg"
Write-Host $line
Add-Content -Path $logFile -Value $line
}
try {
Write-Log "=== Deploy started: $Environment ==="
Expand-AndDeploy -ZipPath $ArtifactZip `
-StagingPath "$DeployRoot_staging" `
-DestinationPath $deployPath
$configMap = Import-PowerShellDataFile ".configs$Environment.psd1"
Apply-ConfigTransform -ConfigPath "$deployPathappsettings.json" `
-Replacements $configMap
# Save rollback pointer
(Get-ItemProperty "IIS:Sites$SiteName").physicalPath |
Out-File "$DeployRootlast_good_path.txt" -Force
Ensure-AppPool -Name $AppPoolName
Set-ItemProperty "IIS:Sites$SiteName" -Name physicalPath -Value $deployPath
Restart-WebAppPool -Name $AppPoolName
Write-Log "=== Deploy complete: $deployPath ==="
}
catch {
Write-Log "ERROR: $_"
Write-Log "Initiating rollback..."
$rollback = Get-Content "$DeployRootlast_good_path.txt" -ErrorAction SilentlyContinue
if ($rollback) {
Rollback-WebDeployment -SiteName $SiteName -AppPoolName $AppPoolName -RollbackPath $rollback
Write-Log "Rollback successful: $rollback"
}
exit 1
}
This structure provides a complete, production-ready deployment script: it extracts artifacts, transforms configuration, saves a rollback pointer, updates IIS, and catches any failure by automatically rolling back to the last known good deployment. Adapt the Ensure-* helpers and parameter names to match your application and naming conventions.