Introduction to Advanced PowerShell Scripting for Windows Server 2019
Windows Server 2019 ships with PowerShell 5.1 by default, and PowerShell 7.x can be installed side-by-side. Advanced scripting on this platform goes well beyond one-liners. A professional administrator writes scripts that are modular, error-tolerant, self-documenting, and safe to run in production. This guide covers the patterns and techniques that separate production-grade scripts from quick hacks.
Script Structure and Comment-Based Help
Every script destined for shared use should begin with a comment-based help block. PowerShell reads this block and surfaces it through Get-Help, exactly like built-in cmdlets. Structure it as follows:
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[Parameter(Mandatory = $false)]
[ValidateRange(1, 365)]
[int]$DaysToKeep = 30
)
Using CmdletBinding and Advanced Parameters
The [CmdletBinding()] attribute turns a plain function into an advanced function, unlocking -Verbose, -Debug, -WhatIf, and -Confirm. Parameter validation attributes prevent invalid input before any code runs:
[ValidateNotNullOrEmpty()] # rejects null or empty string
[ValidateRange(1,100)] # numeric range
[ValidateSet('Dev','QA','Prod')] # enumerated values
[ValidatePattern('^d{3}-d{4}$')] # regex
[ValidateScript({ Test-Path $_ })] # arbitrary check
Robust Error Handling with Try/Catch/Finally
Set $ErrorActionPreference = ‘Stop’ at the top of a script so that non-terminating errors become terminating ones and are caught by Try/Catch. Always catch specific exception types before catching the base Exception:
$ErrorActionPreference = 'Stop'
function Remove-StaleFiles {
[CmdletBinding(SupportsShouldProcess)]
param(
[string]$Path,
[int]$AgeDays
)
try {
$cutoff = (Get-Date).AddDays(-$AgeDays)
$files = Get-ChildItem -Path $Path -Recurse -File |
Where-Object { $_.LastWriteTime -lt $cutoff }
foreach ($file in $files) {
if ($PSCmdlet.ShouldProcess($file.FullName, 'Delete')) {
Remove-Item -Path $file.FullName -Force
Write-Verbose "Deleted: $($file.FullName)"
}
}
}
catch [System.UnauthorizedAccessException] {
Write-Warning "Access denied: $_"
}
catch [System.IO.IOException] {
Write-Warning "IO error: $_"
}
catch {
Write-Error "Unexpected error: $_"
throw
}
finally {
Write-Verbose "Cleanup routine completed."
}
}
Logging to the Windows Event Log
Scripts that run as scheduled tasks should write structured log entries rather than text files. Use Write-EventLog after registering a custom source:
# Run once to register the source (requires elevation)
New-EventLog -LogName Application -Source 'ServerMaintenance'
# In the script body
function Write-ScriptEvent {
param([string]$Message, [string]$EntryType = 'Information', [int]$EventId = 1000)
Write-EventLog -LogName Application -Source 'ServerMaintenance' `
-EventId $EventId -EntryType $EntryType -Message $Message
}
Write-ScriptEvent -Message "IIS log cleanup started. Retention: $DaysToKeep days"
Write-ScriptEvent -Message "Deleted $count files totaling $totalMB MB" -EventId 1001
Write-ScriptEvent -Message "Cleanup failed: $($_.Exception.Message)" -EntryType Error -EventId 1002
Working with PowerShell Jobs and Runspaces
For parallelism within a script, Start-Job spawns child processes while runspaces are lighter threads within the same process. For bulk operations across many servers, runspaces provide far better throughput:
$servers = @('SRV01','SRV02','SRV03','SRV04','SRV05')
$throttleLimit = 5
$pool = [runspacefactory]::CreateRunspacePool(1, $throttleLimit)
$pool.Open()
$jobs = foreach ($server in $servers) {
$ps = [powershell]::Create()
$ps.RunspacePool = $pool
[void]$ps.AddScript({
param($srv)
$ping = Test-Connection -ComputerName $srv -Count 1 -Quiet
[PSCustomObject]@{ Server = $srv; Online = $ping }
})
[void]$ps.AddArgument($server)
@{ PS = $ps; Handle = $ps.BeginInvoke() }
}
$results = foreach ($job in $jobs) {
$job.PS.EndInvoke($job.Handle)
$job.PS.Dispose()
}
$pool.Close()
$pool.Dispose()
$results | Format-Table -AutoSize
Using PowerShell Classes
PowerShell 5.0+ supports class definitions, enabling object-oriented patterns in scripts and modules:
class ServerReport {
[string]$ServerName
[datetime]$ScanTime
[long]$FreeSpaceGB
[int]$CpuPercent
hidden [string]$_rawData
ServerReport([string]$name) {
$this.ServerName = $name
$this.ScanTime = Get-Date
}
[void] Collect() {
$disk = Get-PSDrive -Name C
$this.FreeSpaceGB = [math]::Round($disk.Free / 1GB, 2)
$cpu = Get-WmiObject Win32_Processor
$this.CpuPercent = ($cpu | Measure-Object -Property LoadPercentage -Average).Average
}
[string] ToCSVLine() {
return "$($this.ServerName),$($this.ScanTime),$($this.FreeSpaceGB),$($this.CpuPercent)"
}
}
$report = [ServerReport]::new($env:COMPUTERNAME)
$report.Collect()
$report.ToCSVLine()
Building Reusable Script Modules
Functions intended for reuse across scripts belong in a .psm1 module. Place the module in a directory under $env:PSModulePath so it is auto-discoverable:
# Directory: C:ScriptsModulesCorpAdminCorpAdmin.psm1
function Get-DiskHealthReport {
[CmdletBinding()]
param([string[]]$ComputerName = $env:COMPUTERNAME)
# implementation
}
function Set-ServerMaintenanceMode {
[CmdletBinding(SupportsShouldProcess)]
param([string]$ComputerName, [switch]$Enable)
# implementation
}
Export-ModuleMember -Function Get-DiskHealthReport, Set-ServerMaintenanceMode
# Create the manifest
New-ModuleManifest -Path C:ScriptsModulesCorpAdminCorpAdmin.psd1 `
-RootModule CorpAdmin.psm1 `
-ModuleVersion '1.0.0' `
-Author 'IT Operations' `
-Description 'Corporate server administration utilities' `
-FunctionsToExport @('Get-DiskHealthReport','Set-ServerMaintenanceMode')
Signing Scripts for Execution Policy Compliance
In an enterprise environment with AllSigned or RemoteSigned execution policy, scripts must be signed by a trusted publisher. Use the internal AD CS infrastructure or a self-signed cert for testing:
# Create a self-signed code signing cert (testing only)
$cert = New-SelfSignedCertificate -Type CodeSigning `
-Subject 'CN=AdminScriptsSelfSigned' `
-CertStoreLocation Cert:CurrentUserMy
# Sign the script
Set-AuthenticodeSignature -FilePath .Remove-OldIISLogs.ps1 -Certificate $cert
# Verify
Get-AuthenticodeSignature -FilePath .Remove-OldIISLogs.ps1 | Select-Object Status, SignerCertificate
# For production: request from internal CA
$certReq = Get-Certificate -Template 'CodeSigning' -CertStoreLocation Cert:CurrentUserMy `
-SubjectName 'CN=Admin Scripts Production' -DnsName "$env:USERDNSDOMAIN"
Set-AuthenticodeSignature -FilePath .Remove-OldIISLogs.ps1 -Certificate $certReq.Certificate
Scheduled Task Integration
Deploy a signed script as a scheduled task using PowerShell, avoiding the Task Scheduler GUI entirely:
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument '-NonInteractive -NoProfile -ExecutionPolicy AllSigned -File "C:ScriptsRemove-OldIISLogs.ps1" -DaysToKeep 30'
$trigger = New-ScheduledTaskTrigger -Daily -At '02:00AM'
$principal = New-ScheduledTaskPrincipal -UserId 'DOMAINsvc_maintenance' `
-LogonType Password -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 1) `
-MultipleInstances IgnoreNew -StartWhenAvailable
Register-ScheduledTask -TaskName 'IIS Log Cleanup' `
-TaskPath 'Maintenance' `
-Action $action `
-Trigger $trigger `
-Principal $principal `
-Settings $settings `
-Description 'Removes IIS logs older than 30 days'
Output Formatting and Reporting
Professional scripts produce structured output that can be piped, exported, or rendered as HTML reports:
# Output objects, not text — callers decide rendering
$results = Get-Service | Where-Object { $_.Status -eq 'Stopped' } |
Select-Object Name, DisplayName, StartType, Status
# Export CSV
$results | Export-Csv -Path "C:ReportsStoppedServices_$(Get-Date -Format yyyyMMdd).csv" -NoTypeInformation
# Generate HTML report
$head = 'table{border-collapse:collapse}td,th{border:1px solid #ccc;padding:4px}'
$results | ConvertTo-Html -Head $head -Title 'Stopped Services' `
-PreContent 'Stopped Services Report
' |
Out-File "C:ReportsStoppedServices_$(Get-Date -Format yyyyMMdd).html"
# Send by email
Send-MailMessage -From '[email protected]' -To '[email protected]' `
-Subject "Stopped Services Report $(Get-Date -Format 'yyyy-MM-dd')" `
-BodyAsHtml (Get-Content "C:ReportsStoppedServices_$(Get-Date -Format yyyyMMdd).html" -Raw) `
-SmtpServer 'smtp.corp.local'
Conclusion
Advanced PowerShell scripting on Windows Server 2019 requires disciplined use of comment-based help, parameter validation, structured error handling, and modular design. By combining runspaces for parallelism, classes for complex state, and signed deployment via scheduled tasks, administrators can build an automation library that is maintainable, auditable, and safe to run in the most demanding production environments.