Introduction to Advanced PowerShell Scripting for Server Administration
PowerShell is the backbone of modern Windows Server administration. While basic scripts get the job done for simple tasks, managing Windows Server 2022 at scale demands a deeper understanding of PowerShell’s advanced capabilities. This guide covers professional scripting practices used in production environments, from writing well-structured functions to deploying scripts remotely across dozens of servers.
PowerShell Best Practices: Approved Verbs and Comment-Based Help
Every PowerShell function should use an approved verb-noun naming convention. Microsoft maintains a list of approved verbs to ensure consistency. Using unapproved verbs generates warnings when importing modules. Check the approved list with:
Get-Verb | Sort-Object Verb
Common approved verbs include Get, Set, New, Remove, Start, Stop, Enable, Disable, Import, Export, Invoke, Test, and Register. Always prefix function names like Get-ServerDiskReport or Set-FirewallRuleState.
Comment-based help is embedded directly in your function using a special block comment. This enables Get-Help to return structured documentation for your function, identical to built-in cmdlets:
function Get-DiskUsage {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[string[]]$ComputerName,
[int]$DriveType = 3
)
}
Advanced Functions with CmdletBinding and Parameter Validation
The [CmdletBinding()] attribute transforms a simple function into an advanced function, unlocking common parameters like -Verbose, -Debug, -WhatIf, and -Confirm. Parameter validation attributes catch bad input before your code runs, providing clean error messages:
function Set-ServerMaintenanceMode {
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
param(
[Parameter(Mandatory, ValueFromPipelineByPropertyName)]
[ValidateNotNullOrEmpty()]
[string[]]$ComputerName,
[Parameter(Mandatory)]
[ValidateSet('Enable','Disable')]
[string]$Action,
[Parameter()]
[ValidateRange(1, 1440)]
[int]$DurationMinutes = 60,
[Parameter()]
[ValidatePattern('^[A-Za-z0-9s-]+$')]
[string]$Reason = 'Scheduled Maintenance'
)
process {
foreach ($computer in $ComputerName) {
if ($PSCmdlet.ShouldProcess($computer, "$Action maintenance mode")) {
Write-Verbose "Setting $Action maintenance on $computer for $DurationMinutes minutes"
# implementation here
}
}
}
}
Key validation attributes include [ValidateSet()] for enumerated values, [ValidateRange()] for numeric bounds, [ValidatePattern()] for regex matching, [ValidateScript()] for custom logic, [ValidateNotNullOrEmpty()] for required strings, and [ValidateLength()] for string length constraints.
Pipeline Support with ValueFromPipeline
Making functions pipeline-compatible is essential for scripting efficiency. Use the begin, process, and end blocks to handle pipeline input correctly. The begin block runs once before pipeline input, process runs for each pipeline object, and end runs once after all input is processed:
function Get-ServiceStatus {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
[string[]]$ComputerName,
[string[]]$ServiceName = @('wuauserv','spooler','W32Time')
)
begin {
$results = [System.Collections.Generic.List[PSObject]]::new()
Write-Verbose "Starting service status query"
}
process {
foreach ($computer in $ComputerName) {
try {
$services = Get-Service -ComputerName $computer -Name $ServiceName -ErrorAction Stop
foreach ($svc in $services) {
$results.Add([PSCustomObject]@{
ComputerName = $computer
ServiceName = $svc.Name
Status = $svc.Status
StartType = $svc.StartType
})
}
}
catch {
Write-Warning "Failed to query $computer`: $_"
}
}
}
end {
$results
}
}
Error Handling with Try/Catch/Finally
Robust scripts handle errors gracefully. The try/catch/finally pattern catches terminating errors. Use -ErrorAction Stop to convert non-terminating errors (warnings) into terminating errors that can be caught:
function Restart-RemoteService {
[CmdletBinding()]
param(
[string]$ComputerName,
[string]$ServiceName
)
try {
Write-Verbose "Connecting to $ComputerName"
$svc = Get-Service -ComputerName $ComputerName -Name $ServiceName -ErrorAction Stop
Write-Verbose "Stopping service $ServiceName"
$svc.Stop()
$svc.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(30))
Write-Verbose "Starting service $ServiceName"
$svc.Start()
$svc.WaitForStatus('Running', [TimeSpan]::FromSeconds(30))
Write-Output "Successfully restarted $ServiceName on $ComputerName"
}
catch [System.ServiceProcess.TimeoutException] {
Write-Error "Timeout waiting for service state change on $ComputerName"
}
catch [Microsoft.PowerShell.Commands.ServiceCommandException] {
Write-Error "Service command failed on $ComputerName`: $($_.Exception.Message)"
}
catch {
Write-Error "Unexpected error on $ComputerName`: $($_.Exception.Message)"
Write-Debug "Stack trace: $($_.ScriptStackTrace)"
}
finally {
Write-Verbose "Cleanup complete for $ComputerName"
}
}
Creating PowerShell Modules (.psm1 and .psd1)
Modules package related functions for reuse across scripts and systems. A module consists of a script module file (.psm1) and optionally a module manifest (.psd1). Create the module directory structure first:
$modulePath = "$env:ProgramFilesWindowsPowerShellModulesServerAdmin"
New-Item -Path $modulePath -ItemType Directory -Force
# Create the module manifest
New-ModuleManifest -Path "$modulePathServerAdmin.psd1" `
-RootModule "ServerAdmin.psm1" `
-ModuleVersion "1.0.0" `
-Author "Your Name" `
-Description "Windows Server 2022 Administration Functions" `
-PowerShellVersion "5.1" `
-FunctionsToExport @('Get-DiskUsage','Get-ServiceStatus','Restart-RemoteService') `
-Tags @('Server','Administration','Windows') `
-ProjectUri "https://yourrepo.example.com"
In the .psm1 file, dot-source individual script files or define functions directly. Use Export-ModuleMember to control which functions are public:
# ServerAdmin.psm1
. "$PSScriptRootFunctionsGet-DiskUsage.ps1"
. "$PSScriptRootFunctionsGet-ServiceStatus.ps1"
. "$PSScriptRootFunctionsRestart-RemoteService.ps1"
Export-ModuleMember -Function 'Get-DiskUsage','Get-ServiceStatus','Restart-RemoteService'
Logging with Start-Transcript and Write-EventLog
Production scripts must produce auditable logs. Start-Transcript captures all console output to a file, while Write-EventLog writes structured entries to the Windows Event Log:
# Transcript logging
$logPath = "C:LogsServerAdmin"
$logFile = Join-Path $logPath "maintenance_$(Get-Date -Format 'yyyyMMdd_HHmmss').log"
New-Item -Path $logPath -ItemType Directory -Force -ErrorAction SilentlyContinue
Start-Transcript -Path $logFile -Append
# Windows Event Log logging
$logName = 'Application'
$source = 'ServerAdminScript'
# Register source if it doesn't exist (requires elevation)
if (-not [System.Diagnostics.EventLog]::SourceExists($source)) {
New-EventLog -LogName $logName -Source $source
}
function Write-AdminEvent {
param([string]$Message, [string]$EntryType = 'Information', [int]$EventId = 1000)
Write-EventLog -LogName $logName -Source $source `
-EntryType $EntryType -EventId $EventId -Message $Message
}
Write-AdminEvent "Maintenance script started by $env:USERNAME on $env:COMPUTERNAME"
# ... script work ...
Stop-Transcript
Remote Execution with Invoke-Command and PSSessions
PowerShell Remoting enables running commands across many servers simultaneously. Use persistent PSSessions for multiple operations against the same servers to avoid the overhead of reconnecting:
# One-off remote command
Invoke-Command -ComputerName SRV01,SRV02,SRV03 -ScriptBlock {
Get-Service -Name 'wuauserv' | Select-Object Name, Status, StartType
}
# Persistent sessions for efficiency
$servers = Get-ADComputer -Filter {OperatingSystem -like '*Server 2022*'} |
Select-Object -ExpandProperty Name
$sessions = New-PSSession -ComputerName $servers -ThrottleLimit 20
# Run multiple commands using the same sessions
Invoke-Command -Session $sessions -ScriptBlock {
$disk = Get-PSDrive C
[PSCustomObject]@{
ComputerName = $env:COMPUTERNAME
FreeGB = [math]::Round($disk.Free / 1GB, 2)
UsedGB = [math]::Round($disk.Used / 1GB, 2)
}
}
# Always clean up sessions
$sessions | Remove-PSSession
Scheduled Task Automation with Register-ScheduledTask
Replace the GUI-based Task Scheduler with PowerShell for reproducible, scriptable task creation:
$action = New-ScheduledTaskAction `
-Execute 'pwsh.exe' `
-Argument '-NonInteractive -ExecutionPolicy Bypass -File "C:ScriptsDailyReport.ps1"'
$trigger = New-ScheduledTaskTrigger -Daily -At '06:00AM'
$settings = New-ScheduledTaskSettingsSet `
-ExecutionTimeLimit (New-TimeSpan -Hours 2) `
-RestartCount 3 `
-RestartInterval (New-TimeSpan -Minutes 5) `
-MultipleInstances IgnoreNew
$principal = New-ScheduledTaskPrincipal `
-UserId 'DOMAINSvcAccount' `
-LogonType Password `
-RunLevel Highest
Register-ScheduledTask `
-TaskName 'Daily Server Report' `
-TaskPath 'ServerAdmin' `
-Action $action `
-Trigger $trigger `
-Settings $settings `
-Principal $principal `
-Description 'Generates daily disk and service status report'
Using Classes in PowerShell 5+
PowerShell 5+ introduced native class support, enabling object-oriented script design. Classes are useful for representing complex data structures and encapsulating logic:
class ServerHealth {
[string]$ComputerName
[double]$CpuPercent
[double]$MemoryUsedGB
[double]$DiskFreeGB
[datetime]$Timestamp
ServerHealth([string]$name) {
$this.ComputerName = $name
$this.Timestamp = Get-Date
}
[void] Collect() {
$cpu = (Get-CimInstance Win32_Processor -ComputerName $this.ComputerName |
Measure-Object -Property LoadPercentage -Average).Average
$mem = Get-CimInstance Win32_OperatingSystem -ComputerName $this.ComputerName
$disk = Get-PSDrive C
$this.CpuPercent = [math]::Round($cpu, 2)
$this.MemoryUsedGB = [math]::Round(($mem.TotalVisibleMemorySize - $mem.FreePhysicalMemory) / 1MB, 2)
$this.DiskFreeGB = [math]::Round($disk.Free / 1GB, 2)
}
[string] ToString() {
return "$($this.ComputerName): CPU=$($this.CpuPercent)%, Mem=$($this.MemoryUsedGB)GB used"
}
}
$health = [ServerHealth]::new($env:COMPUTERNAME)
$health.Collect()
Write-Output $health
Parallel Execution with ForEach-Object -Parallel (PowerShell 7+)
PowerShell 7 introduced true parallel execution in the pipeline using ForEach-Object -Parallel. This dramatically reduces execution time for I/O-bound tasks like querying multiple servers. The $using: scope modifier passes variables from the parent scope into the parallel runspaces:
# Install PowerShell 7 on Server 2022
# msiexec.exe /i PowerShell-7.4.0-win-x64.msi /quiet
$servers = @('SRV01','SRV02','SRV03','SRV04','SRV05','SRV06','SRV07','SRV08')
$serviceName = 'wuauserv'
$timeoutSec = 10
$results = $servers | ForEach-Object -Parallel {
$s = $using:serviceName
$tmo = $using:timeoutSec
try {
$svc = Get-Service -Name $s -ComputerName $_ -ErrorAction Stop
[PSCustomObject]@{
Server = $_
Service = $s
Status = $svc.Status
Error = $null
}
}
catch {
[PSCustomObject]@{
Server = $_
Service = $s
Status = 'Unknown'
Error = $_.Exception.Message
}
}
} -ThrottleLimit 8 -TimeoutSeconds $timeoutSec
$results | Format-Table -AutoSize
Script Signing for Security
In enterprise environments, PowerShell execution policies often require scripts to be digitally signed. Use a code signing certificate from your enterprise CA or a self-signed cert for internal use:
# Request code signing cert from enterprise CA
$cert = Get-ChildItem Cert:CurrentUserMy -CodeSigningCert | Select-Object -First 1
# Or create a self-signed cert for testing
$cert = New-SelfSignedCertificate `
-Subject "CN=PowerShell Script Signing" `
-Type CodeSigning `
-CertStoreLocation Cert:CurrentUserMy `
-KeyUsage DigitalSignature
# Sign a script
Set-AuthenticodeSignature -FilePath "C:ScriptsDailyReport.ps1" -Certificate $cert
# Verify the signature
Get-AuthenticodeSignature -FilePath "C:ScriptsDailyReport.ps1" | Select-Object Status, SignerCertificate
# Set execution policy to require signing
Set-ExecutionPolicy AllSigned -Scope LocalMachine
# For trusted scripts from your CA without requiring signing of each file:
# Add the CA cert to the Trusted Publishers store
Import-Certificate -FilePath "C:CertsCodeSigningCA.cer" `
-CertStoreLocation Cert:LocalMachineTrustedPublisher
With AllSigned policy, every script must be signed. The RemoteSigned policy only requires signing for scripts downloaded from the internet, which is often the practical choice for internal tooling. Always combine script signing with NTFS permissions so only authorized administrators can modify the script files after signing, since modifying a signed script invalidates the signature and prevents execution.
Putting It All Together: A Production-Ready Script Template
Here is a complete template that combines all the best practices covered in this guide: comment-based help, CmdletBinding, parameter validation, error handling, transcript logging, and pipeline support:
#Requires -Version 5.1
#Requires -RunAsAdministrator
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[ValidateNotNullOrEmpty()]
[string[]]$ComputerName,
[Parameter()]
[ValidateScript({ Test-Path (Split-Path $_) -PathType Container })]
[string]$ExportPath
)
begin {
Start-Transcript -Path "C:LogsHealthCheck_$(Get-Date -Format 'yyyyMMdd_HHmmss').log"
$allResults = [System.Collections.Generic.List[PSObject]]::new()
Write-Verbose "Health check starting at $(Get-Date)"
}
process {
$ComputerName | ForEach-Object -Parallel {
$list = $using:allResults
$comp = $_
try {
$os = Get-CimInstance Win32_OperatingSystem -ComputerName $comp -ErrorAction Stop
$cpu = (Get-CimInstance Win32_Processor -ComputerName $comp |
Measure-Object LoadPercentage -Average).Average
$disk = Invoke-Command -ComputerName $comp -ScriptBlock { Get-PSDrive C }
$obj = [PSCustomObject]@{
ComputerName = $comp
CpuPct = [math]::Round($cpu, 1)
MemFreeMB = [math]::Round($os.FreePhysicalMemory / 1KB, 0)
DiskFreeGB = [math]::Round($disk.Free / 1GB, 2)
LastBoot = $os.LastBootUpTime
Status = 'OK'
}
$list.Add($obj)
}
catch {
$list.Add([PSCustomObject]@{
ComputerName = $comp
Status = "ERROR: $($_.Exception.Message)"
})
}
} -ThrottleLimit 10
}
end {
if ($ExportPath) {
$allResults | Export-Csv -Path $ExportPath -NoTypeInformation
Write-Verbose "Results exported to $ExportPath"
}
$allResults
Stop-Transcript
}
Mastering these techniques puts you firmly in the realm of professional PowerShell development. These patterns are used in production environments managing thousands of Windows Server 2022 systems and form the foundation for larger automation projects, DSC configurations, and enterprise tooling. Practice each concept individually before combining them, and always test scripts in a non-production environment using -WhatIf support before running against live systems.