PowerShell for Sysadmins

PowerShell is the workhorse of modern Windows administration, and increasingly a viable cross-platform shell on macOS and Linux too. In this guide we'll walk through the things that actually matter when you're managing fleets of machines: the difference between Windows PowerShell and PowerShell 7+, how the object pipeline works, the cmdlets you'll lean on every day, how to write resilient scripts, and how to drive remote machines from your workstation.

Real-World Scenario

You manage 40 Windows Servers. Someone reports that a service is flapping on "one of the file servers, not sure which." With PowerShell remoting and a short pipeline, we can query all 40 hosts in parallel, filter on services in a stopped or non-running state, and have an answer in seconds rather than RDPing into each box.

Windows PowerShell vs PowerShell 7+

There are two PowerShells in the wild, and the distinction matters because some modules only work on one of them:

  • Windows PowerShell 5.1 ships built into every modern Windows. It runs on the .NET Framework, the executable is powershell.exe, and Microsoft considers it feature-complete (no new functionality is being added). Many on-box modules (such as the older ServerManager and parts of ActiveDirectory) target it.
  • PowerShell 7+ is the modern cross-platform successor. It runs on .NET (Core), the executable is pwsh (or pwsh.exe), and it gets all the new language features (ternary operator, pipeline chain operators && and ||, parallel ForEach-Object -Parallel, and so on).

Check what we're running with $PSVersionTable:

Identify the host

PS> $PSVersionTable.PSVersion
PS> $PSVersionTable.PSEdition   # "Desktop" = Windows PowerShell, "Core" = PowerShell 7+

Installing PowerShell 7 alongside Windows PowerShell is safe — the two coexist. On Windows 10/11 and Server 2016+ we can use winget:

Install PowerShell 7

# Windows (winget) — installs to "C:\Program Files\PowerShell\7\pwsh.exe"
winget install --id Microsoft.PowerShell --source winget

# macOS (Homebrew)
brew install --cask powershell

# Ubuntu/Debian (Microsoft repo)
sudo apt-get install -y wget apt-transport-https software-properties-common
wget -q https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/packages-microsoft-prod.deb
sudo dpkg -i packages-microsoft-prod.deb
sudo apt-get update
sudo apt-get install -y powershell

Verb-Noun Naming and Discoverability

Every cmdlet is named Verb-Noun: Get-Process, Set-Item, Restart-Service, Test-NetConnection. The verbs are not arbitrary — they're drawn from an approved list. We can see it ourselves:

Discover commands

Get-Verb                       # Approved verbs grouped by use case
Get-Command -Noun Service      # All cmdlets that operate on services
Get-Command Get-*Net*          # Wildcards work in command names
Get-Help Get-Process -Examples # Examples from the help system
Update-Help                    # Pull the latest help text down once (run elevated)

This is one of the better arguments for PowerShell: a brand-new tool you've never touched will almost always have a guessable name and a usable -Help output.

The Pipeline Passes Objects, Not Text

This is the single biggest mental shift coming from bash. In a Unix shell, every command emits text, and the next command parses that text with tools like awk, cut, grep. In PowerShell, each command emits objects with named properties, and the next command receives those objects directly.

Object pipeline in action

# Top 5 processes by working set, showing only name and memory in MB
Get-Process |
    Sort-Object -Property WorkingSet -Descending |
    Select-Object -First 5 -Property Name,
        @{Name='MemoryMB';Expression={[math]::Round($_.WorkingSet/1MB,1)}}

Notice we never parsed a column of text. Sort-Object sorted on a real numeric property, and Select-Object built a tiny custom object with a calculated property. That's the pipeline doing its real job.

Cmdlets You'll Use Every Day

Inspecting state

Read-only investigation

Get-Process                          # All running processes
Get-Process -Name chrome             # Filter by name
Get-Service                          # All services + state
Get-Service -Name 'W32Time','Spooler'
Get-ChildItem C:\Logs -Recurse -Filter *.log
Get-Item C:\Windows\System32\drivers\etc\hosts | Select-Object FullName, Length, LastWriteTime
Get-Content .\app.log -Tail 50       # Like `tail -n 50`
Get-Content .\app.log -Wait          # Like `tail -f`

Shaping data

Filter, project, sort, group, measure

# Where-Object: filter rows
Get-Service | Where-Object { $_.Status -eq 'Stopped' -and $_.StartType -eq 'Automatic' }

# Short syntax (PS 3.0+): no script block needed for simple comparisons
Get-Service | Where-Object Status -EQ 'Running'

# Select-Object: project columns
Get-Process | Select-Object Name, Id, CPU -First 10

# Sort-Object
Get-ChildItem C:\Logs | Sort-Object Length -Descending

# Group-Object: like SQL GROUP BY
Get-Process | Group-Object -Property Company | Sort-Object Count -Descending

# Measure-Object: aggregate
Get-ChildItem C:\Logs -Recurse | Measure-Object -Property Length -Sum -Average -Maximum

# ForEach-Object: per-row action
Get-Service | Where-Object Status -EQ 'Stopped' | ForEach-Object { "$($_.Name) is stopped" }

Parameter Splatting

Long parameter lists get ugly fast. Splatting lets us build a hashtable of parameters once and pass it to a cmdlet with @:

Splatting a hashtable

$params = @{
    Path        = 'C:\Backups\db.bak'
    Destination = '\\fileserver01\backups\sql\'
    Force       = $true
    Verbose     = $true
}
Copy-Item @params

Splatting is also the cleanest way to conditionally include parameters. Build the hashtable, then add keys only when they apply.

Error Handling: -ErrorAction, try/catch, $ErrorActionPreference

PowerShell distinguishes between terminating errors (which stop the pipeline) and non-terminating errors (which print red text but let the pipeline continue). For scripting, we usually want to promote non-terminating errors so we can catch them.

Promote and catch errors

try {
    Get-Item 'C:\definitely-not-there.txt' -ErrorAction Stop
}
catch [System.Management.Automation.ItemNotFoundException] {
    Write-Warning "File missing: $($_.Exception.Message)"
}
catch {
    Write-Error "Unexpected: $($_.Exception.Message)"
    throw   # Re-raise so the caller knows
}
finally {
    Write-Verbose 'Cleanup block always runs'
}

Set the default for an entire script with $ErrorActionPreference. 'Stop' is the safest for scripts you want to be reliable:

Script-wide error preference

# Top of script
$ErrorActionPreference = 'Stop'

# Now every cmdlet errors terminally and try/catch will see it

CIM/WMI: Querying System Inventory

Common Information Model (CIM) is the modern replacement for the older WMI cmdlets (Get-WmiObject). Get-CimInstance uses WS-Man rather than DCOM, plays nicely with PowerShell remoting, and is the supported path going forward.

Common CIM queries

# Operating system info
Get-CimInstance -ClassName Win32_OperatingSystem |
    Select-Object Caption, Version, OSArchitecture, LastBootUpTime

# Disk free space
Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DriveType=3" |
    Select-Object DeviceID,
        @{N='SizeGB'; E={[math]::Round($_.Size/1GB,1)}},
        @{N='FreeGB';E={[math]::Round($_.FreeSpace/1GB,1)}}

# Installed software (slower; reads from the WMI provider)
Get-CimInstance -ClassName Win32_Product | Select-Object Name, Version, Vendor

Note: Win32_Product triggers a Windows Installer integrity check on every machine it touches. For a faster, less invasive inventory, query the uninstall registry keys (HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall) directly.

Remote Management with PowerShell Remoting

PowerShell remoting uses WS-Management (WinRM) over HTTP/5985 or HTTPS/5986. On a domain network the server side is typically already enabled. To enable it manually:

Enable remoting (on the target, elevated)

Enable-PSRemoting -Force
# Optionally restrict to specific subnets / IPs:
Set-Item WSMan:\localhost\Client\TrustedHosts -Value 'fileserver01,fileserver02'

From our admin workstation we can then enter a single remote session or fan out to many machines in parallel:

Interactive session and fan-out

# Interactive: drop into a remote prompt
$cred = Get-Credential CONTOSO\admin
Enter-PSSession -ComputerName fileserver01 -Credential $cred
# ... run commands as if you were local ...
Exit-PSSession

# Fan-out: same command on many machines, results stream back
$servers = 'web01','web02','web03'
Invoke-Command -ComputerName $servers -Credential $cred -ScriptBlock {
    Get-Service -Name w3svc | Select-Object MachineName, Name, Status
}

# Persistent session for repeated commands
$s = New-PSSession -ComputerName web01 -Credential $cred
Invoke-Command -Session $s -ScriptBlock { Get-Service }
Remove-PSSession $s

When the source and target are not in the same Active Directory forest, prefer HTTPS (5986) with a real certificate over HTTP, and use -Authentication Negotiate or Kerberos rather than CredSSP unless you specifically need delegated credentials.

Modules and PowerShellGet

Modules ship cmdlets, functions, and DSC resources. Microsoft and the community publish to the PowerShell Gallery, and we install from there with Install-Module:

Working with modules

Get-Module                                # Currently loaded
Get-Module -ListAvailable                 # Installed but not loaded
Find-Module -Name PSReadLine              # Search the Gallery
Install-Module -Name PSReadLine -Scope CurrentUser
Update-Module -Name PSReadLine
Import-Module ActiveDirectory             # Usually auto-imports when you call a cmdlet

Best practice: install with -Scope CurrentUser unless the module needs to be available to every user on the host. Always inspect what you're installing — Find-Module Foo | Select-Object Name, Version, Author, ProjectUri — before trusting it.

Execution Policy

Execution policy is a safety net, not a security boundary. It stops accidental double-clicks of malicious .ps1 files; it does not stop a determined attacker. The values worth knowing:

  • Restricted – No scripts run. Default on client OS.
  • RemoteSigned – Local scripts run; downloaded scripts must be signed. A sensible default for workstations.
  • AllSigned – Every script must be signed. Best for tightly-controlled servers.
  • Bypass – Nothing is blocked, no warnings. Useful for one-off automation runners (e.g. CI agents) where the script source is trusted.
  • Unrestricted – Avoid. It still warns on remote scripts but encourages bad habits.

Setting execution policy

Get-ExecutionPolicy -List
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned

# One-off override for a single invocation (does not persist)
pwsh -ExecutionPolicy Bypass -File .\deploy.ps1

Script Structure: param() and Comment-Based Help

A well-formed PowerShell script looks like a cmdlet from the outside: typed parameters, validation attributes, and help text the user can read with Get-Help.

A skeleton script

<#
.SYNOPSIS
    Restarts a Windows service on one or more remote computers.
.DESCRIPTION
    Connects to each computer over PowerShell remoting, restarts the named
    service, and reports the result.
.PARAMETER ComputerName
    One or more computers to target. Defaults to the local host.
.PARAMETER ServiceName
    The short name of the service (e.g. 'Spooler').
.EXAMPLE
    .\Restart-RemoteService.ps1 -ComputerName web01,web02 -ServiceName w3svc
.NOTES
    Author: Quantum Repository
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
    [Parameter(Mandatory = $false)]
    [string[]] $ComputerName = $env:COMPUTERNAME,

    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string] $ServiceName
)

$ErrorActionPreference = 'Stop'

foreach ($name in $ComputerName) {
    if ($PSCmdlet.ShouldProcess($name, "Restart service $ServiceName")) {
        try {
            Invoke-Command -ComputerName $name -ScriptBlock {
                param($svc) Restart-Service -Name $svc -Force
            } -ArgumentList $ServiceName
            Write-Output "OK: $name"
        }
        catch {
            Write-Warning "FAIL: $name - $($_.Exception.Message)"
        }
    }
}

SupportsShouldProcess gives us -WhatIf and -Confirm for free. That is a real lifesaver when a script is about to do something destructive against 200 machines.

Profiles

Profiles are scripts that run automatically when a PowerShell session starts. Common uses: aliases, prompt customization, frequently-loaded modules. The path lives in the $PROFILE automatic variable.

Editing your profile

# Each shows the file path PowerShell expects (it may not exist yet)
$PROFILE                                # Current host, current user
$PROFILE.AllUsersAllHosts               # All users, every host

# Create + open
if (-not (Test-Path $PROFILE)) { New-Item -Type File -Path $PROFILE -Force }
notepad $PROFILE

A small profile that earns its keep:

Example $PROFILE contents

Set-Alias ll Get-ChildItem
Import-Module PSReadLine
Set-PSReadLineOption -PredictionSource History -EditMode Windows
function Get-PublicIP { (Invoke-RestMethod 'https://api.ipify.org?format=json').ip }

Five One-Liners Worth Memorizing

1. Top 10 largest files under a path

Get-ChildItem C:\ -Recurse -File -ErrorAction SilentlyContinue |
    Sort-Object Length -Descending |
    Select-Object -First 10 FullName,
        @{N='SizeMB'; E={[math]::Round($_.Length/1MB,2)}}

2. Find listening TCP ports and the owning process

Get-NetTCPConnection -State Listen |
    Select-Object LocalAddress, LocalPort,
        @{N='Process'; E={(Get-Process -Id $_.OwningProcess).ProcessName}},
        OwningProcess

3. Check service health across many hosts

Invoke-Command -ComputerName (Get-Content .\servers.txt) -ScriptBlock {
    Get-Service spooler, w32time | Select-Object PSComputerName, Name, Status
}

4. Tail an event log live for warnings/errors

Get-WinEvent -LogName System -MaxEvents 50 |
    Where-Object { $_.LevelDisplayName -in 'Warning','Error' } |
    Format-Table TimeCreated, Id, ProviderName, Message -Wrap

5. Quick HTTP probe across a list of URLs

'https://example.com','https://google.com','https://does-not-exist.invalid' |
    ForEach-Object {
        try {
            $r = Invoke-WebRequest -Uri $_ -Method Head -TimeoutSec 5 -UseBasicParsing
            [pscustomobject]@{ Url = $_; Status = $r.StatusCode }
        } catch {
            [pscustomobject]@{ Url = $_; Status = "ERR: $($_.Exception.Message)" }
        }
    }

Summary

The PowerShell mental model — verb-noun cmdlets, object pipeline, splatting, structured error handling, remoting — turns a lot of "click through MMC on 20 servers" work into a one-line query. Lean on Get-Help, lean on Get-Command, and remember: if a non-terminating error is biting your script, the answer is almost always -ErrorAction Stop plus a try/catch.