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.