How to Write Advanced PowerShell Scripts for Windows Server 2012 R2 Administration
PowerShell is the cornerstone of modern Windows Server administration. On Windows Server 2012 R2, PowerShell 4.0 ships by default, bringing with it Desired State Configuration, enhanced remoting, workflow capabilities, and a mature scripting ecosystem. For sysadmins managing dozens or hundreds of servers, mastering advanced PowerShell scripting techniques is not optional — it is essential. This guide walks through writing production-quality scripts that are robust, reusable, and safe to run in enterprise environments.
Prerequisites
Before proceeding, confirm your environment meets these requirements:
– Windows Server 2012 R2 with PowerShell 4.0 (verify with $PSVersionTable.PSVersion)
– Execution policy set appropriately: RemoteSigned or AllSigned for production
– Administrative credentials on target systems
– WinRM enabled for remoting scenarios
– Familiarity with basic PowerShell syntax, cmdlets, and the pipeline
Step 1: Script Structure and Best Practices
Every production-grade script should follow a consistent structure. Begin with a comment-based help block, declare parameters using the [CmdletBinding()] decorator, handle errors explicitly, and produce structured output. This makes scripts self-documenting and compatible with Get-Help.
#Requires -Version 4.0
#Requires -RunAsAdministrator
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[string[]]$ComputerName,
[Parameter(Mandatory = $false)]
[string]$OutputPath = "C:TempAdminAudit.csv"
)
BEGIN {
$results = [System.Collections.Generic.List[PSCustomObject]]::new()
Write-Verbose "Starting admin audit at $(Get-Date)"
}
PROCESS {
foreach ($computer in $ComputerName) {
try {
$admins = Invoke-Command -ComputerName $computer -ScriptBlock {
$group = [ADSI]"WinNT://$env:COMPUTERNAME/Administrators,group"
$group.Invoke("Members") | ForEach-Object {
$_.GetType().InvokeMember("Name", 'GetProperty', $null, $_, $null)
}
} -ErrorAction Stop
foreach ($admin in $admins) {
$results.Add([PSCustomObject]@{
ComputerName = $computer
AdminMember = $admin
Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
})
}
Write-Verbose "Processed $computer successfully"
}
catch {
Write-Warning "Failed to query $computer`: $($_.Exception.Message)"
$results.Add([PSCustomObject]@{
ComputerName = $computer
AdminMember = "ERROR: $($_.Exception.Message)"
Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
})
}
}
}
END {
if ($PSCmdlet.ShouldProcess($OutputPath, "Export CSV")) {
$results | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
Write-Output "Audit complete. Results saved to $OutputPath"
}
Write-Verbose "Total records: $($results.Count)"
}
Step 2: Advanced Error Handling and Logging
Production scripts require comprehensive error handling. Use $ErrorActionPreference, try/catch/finally blocks, and custom logging functions to capture all failure modes. The following logging function writes timestamped entries to both the console and a log file:
function Write-Log {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Message,
[ValidateSet('INFO','WARN','ERROR')]
[string]$Level = 'INFO',
[string]$LogFile = "C:Logsscript_$(Get-Date -Format yyyyMMdd).log"
)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logEntry = "[$timestamp] [$Level] $Message"
# Ensure log directory exists
$logDir = Split-Path $LogFile -Parent
if (-not (Test-Path $logDir)) {
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
}
Add-Content -Path $LogFile -Value $logEntry
switch ($Level) {
'INFO' { Write-Host $logEntry -ForegroundColor Cyan }
'WARN' { Write-Host $logEntry -ForegroundColor Yellow }
'ERROR' { Write-Host $logEntry -ForegroundColor Red }
}
}
# Usage with error trapping
$ErrorActionPreference = 'Stop'
try {
Write-Log -Message "Starting service restart on $env:COMPUTERNAME"
Restart-Service -Name Spooler
Write-Log -Message "Spooler restarted successfully"
}
catch {
Write-Log -Message "Failed to restart Spooler: $($_.Exception.Message)" -Level ERROR
# Re-throw if the script should halt
throw
}
finally {
Write-Log -Message "Script block completed"
}
Step 3: Working with PowerShell Jobs and Runspaces
For parallelizing work across many servers, PowerShell jobs and runspaces provide significant performance gains. Background jobs are simpler but carry overhead; runspaces are faster for high-volume operations:
# Parallel disk space check using runspaces
$servers = Get-Content "C:Scriptsservers.txt"
$maxThreads = 20
$runspacePool = [RunspaceFactory]::CreateRunspacePool(1, $maxThreads)
$runspacePool.Open()
$jobs = [System.Collections.Generic.List[hashtable]]::new()
foreach ($server in $servers) {
$ps = [PowerShell]::Create().AddScript({
param($ComputerName)
try {
$disk = Get-WmiObject -Class Win32_LogicalDisk `
-ComputerName $ComputerName `
-Filter "DeviceID='C:'" -ErrorAction Stop
[PSCustomObject]@{
Server = $ComputerName
FreeGB = [math]::Round($disk.FreeSpace / 1GB, 2)
TotalGB = [math]::Round($disk.Size / 1GB, 2)
PctFree = [math]::Round(($disk.FreeSpace / $disk.Size) * 100, 1)
Status = 'OK'
}
}
catch {
[PSCustomObject]@{
Server = $ComputerName
FreeGB = 0
TotalGB = 0
PctFree = 0
Status = "Error: $($_.Exception.Message)"
}
}
}).AddArgument($server)
$ps.RunspacePool = $runspacePool
$jobs.Add(@{ PS = $ps; Handle = $ps.BeginInvoke() })
}
$results = foreach ($job in $jobs) {
$job.PS.EndInvoke($job.Handle)
$job.PS.Dispose()
}
$runspacePool.Close()
$results | Sort-Object PctFree | Format-Table -AutoSize
Step 4: Modules — Packaging Reusable Functions
Organize related functions into PowerShell modules so they can be imported across scripts and shared with team members. A module consists of a .psm1 file containing functions and a .psd1 manifest:
# Create module directory
$modulePath = "$env:ProgramFilesWindowsPowerShellModulesServerOps"
New-Item -ItemType Directory -Path $modulePath -Force
# ServerOps.psm1 - place in $modulePath
# Export only the public functions listed in the manifest
function Get-DiskHealth {
param([string[]]$ComputerName = $env:COMPUTERNAME)
foreach ($c in $ComputerName) {
Get-WmiObject -Class Win32_LogicalDisk -ComputerName $c |
Select-Object @{n='Server';e={$c}}, DeviceID,
@{n='FreeGB';e={[math]::Round($_.FreeSpace/1GB,2)}},
@{n='TotalGB';e={[math]::Round($_.Size/1GB,2)}}
}
}
function Test-ServerConnectivity {
param([string[]]$ComputerName)
$ComputerName | ForEach-Object {
[PSCustomObject]@{
Server = $_
Ping = (Test-Connection $_ -Count 1 -Quiet)
WinRM = (Test-WSMan $_ -ErrorAction SilentlyContinue) -ne $null
}
}
}
Export-ModuleMember -Function Get-DiskHealth, Test-ServerConnectivity
# Generate the module manifest
New-ModuleManifest -Path "$modulePathServerOps.psd1" `
-RootModule "ServerOps.psm1" `
-ModuleVersion "1.0.0" `
-Author "YourName" `
-Description "Server Operations Utility Functions" `
-FunctionsToExport @('Get-DiskHealth','Test-ServerConnectivity') `
-PowerShellVersion "4.0"
# Import and use
Import-Module ServerOps
Get-DiskHealth -ComputerName Server01,Server02
Step 5: Pipeline-Optimized Functions and OutputType
Well-written functions leverage the pipeline, declare their output types, and support -WhatIf. This enables chaining and testing without side effects:
[CmdletBinding(SupportsShouldProcess)]
[OutputType([PSCustomObject])]
function Set-ServiceStartupType {
param(
[Parameter(Mandatory, ValueFromPipelineByPropertyName)]
[string]$ComputerName,
[Parameter(Mandatory)]
[string]$ServiceName,
[ValidateSet('Automatic','Manual','Disabled')]
[string]$StartupType
)
PROCESS {
if ($PSCmdlet.ShouldProcess("$ComputerName$ServiceName", "Set startup to $StartupType")) {
try {
$svc = Get-WmiObject Win32_Service -ComputerName $ComputerName `
-Filter "Name='$ServiceName'" -ErrorAction Stop
$result = $svc.ChangeStartMode($StartupType)
[PSCustomObject]@{
ComputerName = $ComputerName
Service = $ServiceName
NewStartType = $StartupType
ReturnCode = $result.ReturnValue
Success = ($result.ReturnValue -eq 0)
}
}
catch {
Write-Error "Failed on $ComputerName`: $_"
}
}
}
}
# Chain with pipeline
Import-Csv servers.csv | Set-ServiceStartupType -ServiceName "W32Time" -StartupType Automatic
Step 6: Script Signing for Production Deployment
Before deploying scripts across an enterprise, sign them with a code-signing certificate. This ensures integrity and satisfies AllSigned execution policies:
# Request a code-signing certificate from your internal CA
$cert = Get-ChildItem Cert:CurrentUserMy -CodeSigningCert | Select-Object -First 1
# Sign the script
Set-AuthenticodeSignature -FilePath "C:ScriptsInvoke-AdminAudit.ps1" -Certificate $cert
# Verify the signature
Get-AuthenticodeSignature -FilePath "C:ScriptsInvoke-AdminAudit.ps1" |
Select-Object Status, SignerCertificate
# Set execution policy for the machine
Set-ExecutionPolicy AllSigned -Scope LocalMachine -Force
Verification and Testing
Validate scripts using Pester, the PowerShell testing framework available from the PowerShell Gallery:
# Install Pester
Install-Module Pester -Force
# Example test file: ServerOps.Tests.ps1
Describe "Get-DiskHealth" {
It "Returns objects with required properties" {
$result = Get-DiskHealth -ComputerName localhost
$result | Should Not BeNullOrEmpty
$result[0].PSObject.Properties.Name | Should Contain 'FreeGB'
}
It "Handles unreachable servers gracefully" {
{ Get-DiskHealth -ComputerName "nonexistent.local" } | Should Throw
}
}
# Run tests
Invoke-Pester -Path "C:ScriptsServerOps.Tests.ps1" -Verbose
Summary
Advanced PowerShell scripting on Windows Server 2012 R2 requires discipline in structure, error handling, parallelism, and modularity. Using comment-based help, [CmdletBinding()], proper logging, runspace pools for parallelism, signed modules, and Pester tests transforms ad hoc scripts into enterprise-grade automation tools. These practices reduce risk, improve maintainability, and form the foundation for infrastructure-as-code approaches on the Windows platform.