Simple PowerShell introduction for Windows Server administration, automation and scripting

Date posted: 2016-04-28
Last updated: 2026-11-07

Windows PowerShell is an important tool in Windows Server for administrators. You can use PowerShell for Windows Server administration, software installation, automation, and shell/command-prompt scripting.

Windows PowerShell is an important tool in Windows Server for administrators. You can use PowerShell for Windows Server administration, software installation, automation, and shell/command-prompt scripting.

In this article

About PowerShell: Cmdlets, The Get-WMiObject Cmdlet - Retrieving Data Using WMI

Scripting in Windows PowerShell: Command-line arguments in PS, If ... Else, If ... ElseIf ... Else logic, PowerShell Switch statement, PowerShell functions (Create a PowerShell function)

PowerShell Comparison operators - compare values in PowerShell

Windows Server administration, installation and automation with PowerShell

From CMD and Bash to PowerShell: Alias pitfalls

PowerShell for sysadmins and DevOps: Run a command on many servers, Read the event log, Query Active Directory without the AD module, Monitor with CIM and performance counters, Talk to REST APIs, Stop on errors, Check exit codes of native commands, Log what an unattended script did, Make changes only when asked: -WhatIf and -Apply, Parameters instead of $args

Conclusion

An advantage of PowerShell is the ability to execute ordinary Windows binaries. You can execute Windows commands from within PowerShell, no matter whether it is appcmd, dir, forfiles, or advanced netsh commands.

About PowerShell

Windows PowerShell is Microsoft's scripting language of choice. Windows PowerShell is a task-based command-line shell and scripting language designed especially for system administration. Windows PowerShell 5.1 is built on the Microsoft .NET Framework; PowerShell 7 runs on .NET, its cross-platform successor. PowerShell helps IT professionals and power users control and automate the administration of the Windows operating system and applications that run on Windows.

Because it's built on .NET, you can even use .NET functions and functionality. For example to make an SMTP connection:

(New-Object System.Net.Sockets.TcpClient).Connect( "smtp.example.com", "25" )

Met PowerShell verbinding maken met een SQL Server-database (in Dutch)

Built-in Windows PowerShell commands are called cmdlets. They let you manage the computers in your enterprise from the command line. Windows PowerShell providers let you access data stores, such as the registry and certificate store, as easily as you access the file system. In addition, Windows PowerShell has a rich expression parser and a fully-developed scripting language.

Cmdlets

A cmdlet is a lightweight command that is used in the Windows PowerShell environment. The Windows PowerShell runtime invokes these cmdlets within the context of automation scripts that are provided at the command-line. The Windows PowerShell runtime also invokes them programmatically through Windows PowerShell APIs.

The Get-WMiObject Cmdlet - Retrieving Data Using WMI

In scripts you often see the use of Get-WMiObject cmdlet. WMI stands for Windows Management Instrumentation. You may wonder why there is so much WMI usage in PowerShell, Microsoft explains:

At this point in time there are only a few cmdlets (Get-Process, Get-Service, and Get-EventLog) designed for carrying out system administration tasks. Because of that, WMI remains the primary automation technology for system administration, so system administrators will likely rely heavily on Get-WmiObject to help them with their routine management tasks.

And there's an added bonus, too: unlike most cmdlets, Get-WmiObject can be run against remote computers. That means you really can use Windows PowerShell as a management tool.

By default, Get-WmiObject binds to the root\cimv2 namespace on the local computer, making it extremely easy to return property values for any class found in cimv2.

Microsoft wrote this in the PowerShell 1.0 days. Today there are cmdlets for most administration tasks, and Get-EventLog is no longer part of PowerShell 7: use Get-WinEvent, which works in both versions. Still, for Windows administration with PowerShell, a basic understanding of WMI is useful.

Get-WmiObject only exists in Windows PowerShell 5.1. It was removed in PowerShell 6, so it is not available in PowerShell 7 either. Use the CIM cmdlets Get-CimInstance, Set-CimInstance and Invoke-CimMethod instead: they work in both versions and query the same WMI classes. With -ComputerName they connect to remote computers over WinRM.

Get-CimInstance -ClassName Win32_OperatingSystem | Select-Object Caption, Version

# on a remote computer
Get-CimInstance -ClassName Win32_OperatingSystem -ComputerName server01 | Select-Object Caption, Version

Scripting in Windows PowerShell

One task you can use Windows PowerShell for is scripting: automate your daily tasks using simple PowerShell scripts. One of such is, for example, a PS script to monitor Windows services like LanmanWorkstation. Another to find all files owned by a particular user. These two examples should provide some insights in the earlier mentioned cmdlet usage in PS. As said, you can also combine PowerShell with your normal Windows commands in one script.

Command-line arguments in PS

A PS script can accept command-line arguments. Imagine an unattended installation script to automate the installation of components of your web server. For example a script to install PHP. Now, if you want to add an option to either update or install PHP, as a command-line argument, add the following at the top of your PS script (different styles and options for adding command-line arguments support are possible):

# initialization
$mode      = $args[0]
$arguments = @{
  "-i" = "install PHP";
  "-u" = "update PHP";
  "-v" = "PHP version information";
}

# ...
# ...

$map = $args[1]
if ( $mode -and $arguments.ContainsKey( $mode ) ) {

  # we've added a php-version.txt file containing 
  # PHP's version number, e.g 7.0.5
  if ( ( test-path php-version.txt ) ) {
    $version = get-content php-version.txt
  }
  set-variable -name versie -value $version -scope script

  if ( ( $mode -eq "-i" ) ) {
    install ( $version )
  }

  if ( ( $mode -eq "-u" ) ) {
    update ( $version , 0 )
  }

  if ( ( $mode -eq "-v" ) ) {
    $return = version ( 1 )
  }
}

else {
  write-host "[i] Usage:"
  foreach ( $s in $arguments.Keys ) {
    write-host "[i]  $s " $arguments[$s]
  }
}

Now when you call your script install-php.ps1 with a command-line argument -i, the install function is executed, installing PHP. Want to know the current installed PHP version? Let's have a look at the version function:

function version ( $returnerror ) {
  if ( test-path $target\php-version.txt ) {

    # open $target\php-version.txt containing 
    # the version number
    $found = get-content $target\php-version.txt
    write-host "[*] Version information for $target"
    write-host "[i] $found"
    return $found
  }

  else {
    if ( $returnerror ) {
      write-host "[E] Error: PHP is not installed!"
    }
    return 0
  }
}

As you can see, adding command-line arguments support into your PowerShell scripts may make your script library easier to understand. Now we have one PowerShell script for the installation of PHP, to update PHP and to look up its version. Otherwise that would have been three separate scripts.

You can easily do the same when installing Windows Server features:

# Accept a $feature argument to install a specific 
# Windows Server feature
param (
  [string]$feature = $(throw "-feature is required.")
 )

Add-WindowsFeature `
  -Name $feature `
  -Source \path\to\windows\2012r2\sources\sxs

If ... Else, If ... ElseIf ... Else logic

Flow control with If, ElseIf and Else in Windows PowerShell. Using If, ElseIf, Else you decide what a script has to do at a certain point, when a condition is met. An If is often used in an evaluation or comparison, e.g If $foo = bar, do this, Else do something else... He, that's logic! šŸ™‚

Let's have a look at the following example:

# If .. ElseIf .. ElseIf .. Else .. in PowerShell
param (
  [string]$a = $( throw "please provide a number 1 .. 6" )
)

If ( $a -eq "1" ) {
  write-host "Your number of choice: 1"
}
ElseIf ( $a -eq "2" ) {
  write-host "Your number of choice: 2"
}
ElseIf ( $a -eq "3" ) {
  write-host "Your number of choice: 3"
}
ElseIf ( $a -eq "4" ) {
  write-host "Your number of choice: 4"
}
ElseIf ( $a -eq "5" ) {
  write-host "Your number of choice: 5"
}
ElseIf ( $a -eq "6" ) {
  write-host "Your number of choice: 6"
}
Else {
  write-host "Can't read?! I asked for a number between 1 and 6!"
}

Playing with this, the output is:

PS C:\Users\jan> .\Desktop\if-else.ps1
please provide a number 1 .. 6
At C:\Users\jan\scripts\if-else.ps1:3 char:18
+   [string]$a = $( throw "please provide a number 1 .. 6" )
+                  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : OperationStopped: (please provide a number 1 .. 6:String) [], RuntimeException
    + FullyQualifiedErrorId : please provide a number 1 .. 6

PS C:\Users\jan> .\scripts\if-else.ps1 1
Your number of choice: 1
PS C:\Users\jan> .\scripts\if-else.ps1 3
Your number of choice: 3
PS C:\Users\jan> .\scripts\if-else.ps1 5
Your number of choice: 5
PS C:\Users\jan> .\scripts\if-else.ps1 6
Your number of choice: 6
PS C:\Users\jan> .\scripts\if-else.ps1 9
Can't read?! I asked for a number between 1 and 6!
PS C:\Users\jan>

Not very hard, but you can imagine a script like this can get very long and difficult to read.

Emulate ternary operator in PowerShell

PowerShell Switch statement

The switch statement in PowerShell enables you to write a script that can choose from a series of options, but without requiring you to write a long series of if statements. When having a large If ( ) {.. } ElseIf ( ) {.. } ElseIf ( ) {.. } Else {.. } block in your scripts, it's often better to rewrite those to a Switch statement. Not only makes this your PowerShell script execute faster (switch often is), it makes your script easier to read as well. Let's rewrite the above If .. ElseIf .. ElseIf .. Else .. logic.

# Switch statement in PowerShell
param (
  [string]$a = $( throw "please provide a number 1 .. 6" )
)

switch ( $a ) {
  1 { "Your number of choice: 1" }
  2 { "Your number of choice: 2" }
  3 { "Your number of choice: 3" }
  4 { "Your number of choice: 4" }
  5 { "Your number of choice: 5" }
  6 { "Your number of choice: 6" }
  default { "Can't read?! I asked for a number between 1 and 6!" }
}

As you can see, the same script rewritten with a Switch instead of If statement is much shorter and easier to read/understand. It outputs:

PS C:\Users\jan> .\scripts\switch.ps1
please provide a number 1 .. 6
At C:\Users\jan\scripts\switch.ps1:3 char:18
+   [string]$a = $( throw "please provide a number 1 .. 6" )
+                  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : OperationStopped: (please provide a number 1 .. 6:String) [], RuntimeException
    + FullyQualifiedErrorId : please provide a number 1 .. 6

PS C:\Users\jan> .\scripts\switch.ps1 1
Your number of choice: 1
PS C:\Users\jan> .\scripts\switch.ps1 5
Your number of choice: 5
PS C:\Users\jan> .\scripts\switch.ps1 3
Your number of choice: 3
PS C:\Users\jan> .\scripts\switch.ps1 6
Your number of choice: 6
PS C:\Users\jan> .\scripts\switch.ps1 66
Can't read?! I asked for a number between 1 and 6!
PS C:\Users\jan>

PowerShell functions

A function in PowerShell is like a function in any other language, whether it's C#, PHP, Bash. Within Powershell you can reference functions within statements. A function is basically a named block of code. When you call the function name, the script block within that function runs.

You can include any PowerShell statements within the script block, and you can add input parameters so you can use the same function in different situations. Let's look at how to create functions, define input parameters, and work with functions in PowerShell scripts.

Create a PowerShell function

A PowerShell function must have a function name, to call it by, and a script block between braces { }. For example:

function HelloWorld {
  Write-Host "Hello World in PowerShell!"
}

As you can see, this function definition begins with the function keyword, followed by the function's name: HelloWorld. The script block includes just one command, it echos the string "Hello World in PowerShell!". When you create a function, PowerShell stores it in memory for the duration of your session. During that session, you can call the function at any time by simply entering the function's name, as in HelloWorld. After pressing ENTER in the PowerShell shell, or from the point in your script, the function block is run and the output presented. In this case the above mentioned string, but it can be anything.

Let's add the function "HelloWorld" beneath the function, and execute it as a PS script. The script becomes:

function HelloWorld {
  Write-Host "Hello World in PowerShell!"
}

HelloWorld

And it'll output:

PS C:\Users\jan> .\scripts\helloworld.ps1
Hello World in PowerShell!

Powershell functions also accept parameters:

function testFunction ($arg1, $arg2) {
  Write-Host $arg1
  Write-Host $arg2
}

There are pitfalls when calling function parameters though! In Calling Functions in PowerShell, Thomas Lee provides a succinct example of one pitfall that even experienced PowerShell scripters will occasionally tumble into-writing a basic function call that produces unexpected results. Read Michael Sorens' simple-talk.com article Down the Rabbit Hole- A Study in PowerShell Pipelines, Functions, and Parameters

PowerShell Comparison operators - compare values in PowerShell

PowerShell supports a number of comparison operators. The following operators are all case-insensitive by default:

operatorexplanation
-eqEqual
-neNot equal
-gtGreater than
-geGreater than or equal
-ltLess than
-leLess than or equal
-LikeWildcard comparison
-NotLikeWildcard comparison
-MatchRegular expression comparison
-NotMatchRegular expression comparison
-ContainsContainment operator
-NotContainsContainment operator
-InLike -contains, but with the operands reversed (PowerShell 3.0)
-NotInLike -notcontains, but with the operands reversed (PowerShell 3.0)
-ReplaceReplace operator

By default, all comparison operators are case-insensitive. To make a comparison operator case-sensitive, precede the operator name with a "c". For example, the case-sensitive version of "-eq" is "-ceq". To make the case-insensitivity explicit, precede the operator with an "i". For example, the explicitly case-insensitive version of "-eq" is "-ieq".

Run Update-Help and about_Comparison_Operators to view its help information.

Windows Server administration, installation and automation with PowerShell

Windows PowerShell provides a lot of Windows Server administration cmdlets. Cmdlets to use when installing Windows Server, automate installations, DevOps, and so on. There are just too many to mention them all. Some important ones - to me, in my daily routine - are:

Often, when there is a cmdlet starting with Get-, there is also one with Set-. In my daily routine, Set-FsrmQuota is as important to me as Get-FsrmQuota. The same goes for Get-Acl and Set-Acl, and so on.

Two example PS administration scripts:

find-all-PHP-handlers-matching-an-scriptProcessor-condition.ps1

Get-Website | % {
  $sitename = $_.name; Write-Host "[X] Processing $sitename ... ";
  Try {
    Get-WebConfiguration /system.webServer/handlers/* -Location $_.name | Where {
      $_.scriptProcessor -eq "d:\PHP\php56\php-cgi.exe"
    }
  }
  Catch { }
}
Get-Website | % {
  $sitename = $_.name;
    $handlers = Get-WebConfiguration /system.webServer/handlers/* -Location $sitename
    If ( $handlers.scriptProcessor -eq "d:\PHP\php70\php-cgi.exe" ) {
      Write-Host $sitename
    }
}

w3wp-uptime.ps1

Get-Process w3wp | % {
  $pid = $_.Id
  $wmiProc = Get-WmiObject Win32_Process -Filter "ProcessID = '$pid'"
  New-Object PSObject -Property @{
    CommandLine = $wmiProc.ComandLine
    StartTime = $_.StartTime
  }
}

(Code credits: Chris N on StackOverflow)

The same with Get-CimInstance, which also works in PowerShell 7. Note that $PID is an automatic variable in PowerShell (the process ID of the PowerShell session itself) and can't be overwritten, so this version uses $ProcessId:

Get-Process -Name w3wp | ForEach-Object {
  $ProcessId = $_.Id
  $CimProcess = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId = $ProcessId"
  [pscustomobject]@{
    CommandLine = $CimProcess.CommandLine
    StartTime   = $_.StartTime
  }
}

From CMD and Bash to PowerShell

Tested on Windows 11 in Windows PowerShell 5.1.26100 and PowerShell 7.6.9.

You already know ipconfig, netstat and findstr, and they still work in PowerShell. The PowerShell cmdlets return objects instead of text, so you can filter on a property instead of parsing columns.

CMDBashPowerShell
dirlsGet-ChildItem
type file.logtail -f file.logGet-Content -Path file.log -Tail 20 -Wait
findstrgrepSelect-String
forfiles /d -30find -mtime +30Get-ChildItem | Where-Object LastWriteTime -lt (Get-Date).AddDays(-30)
ipconfigip addrGet-NetIPConfiguration
netstat -anoss -tlnpGet-NetTCPConnection -State Listen
nslookupdigResolve-DnsName
telnet host 25nc -zv host 25Test-NetConnection -ComputerName host -Port 25
tasklistpsGet-Process
taskkill /f /pid 1234kill -9 1234Stop-Process -Id 1234 -Force
sc querysystemctlGet-Service
wevtutiljournalctlGet-WinEvent
certutil -hashfilesha256sumGet-FileHash
systeminfouname -aGet-ComputerInfo
shutdown /r /t 0rebootRestart-Computer

Some examples:

# Which process listens on port 443?
Get-NetTCPConnection -State Listen -LocalPort 443 -ErrorAction SilentlyContinue |
    Select-Object LocalAddress, LocalPort, OwningProcess,
        @{ Name = 'ProcessName'; Expression = { (Get-Process -Id $_.OwningProcess).ProcessName } }

# MX records of a domain
Resolve-DnsName -Name saotn.org -Type MX

# Log files older than 30 days, dry run first
Get-ChildItem -Path D:\Logs -Filter *.log -Recurse |
    Where-Object LastWriteTime -lt (Get-Date).AddDays(-30) |
    Remove-Item -WhatIf

Without -ErrorAction SilentlyContinue, Get-NetTCPConnection shows an error when nothing listens on the port, instead of returning nothing.

On a network where a Zero Trust solution like Zscaler intercepts DNS traffic, Resolve-DnsName and nslookup show you the answer of that DNS proxy, not the real public records. This small dig function for your $PROFILE asks Google's DNS over HTTPS resolver instead:

function dig {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory, Position = 0, HelpMessage = 'DNS name to look up')]
        [string]$Name,

        [Parameter(Position = 1)]
        [string]$Type = 'A'
    )
    $Uri = 'https://dns.google/resolve?name={0}&type={1}' -f [uri]::EscapeDataString($Name), $Type
    $Response = Invoke-RestMethod -Uri $Uri
    if ($Response.Status -ne 0) {
        # 3 = NXDOMAIN, 2 = SERVFAIL
        Write-Warning "DNS status $($Response.Status) for $Name ($Type)"
    }
    $Response.Answer | Select-Object name, type, TTL, data
}
PS C:\> dig saotn.org MX

name       type TTL data
----       ---- --- ----
saotn.org.   15 300 10 h58.mijn.host.

The type column is the numeric record type: 1 is A, 15 is MX, 16 is TXT and 28 is AAAA.

Not everything has a cmdlet. For copying large directory trees, robocopy is still the better tool, and appcmd remains handy for IIS.

Alias pitfalls

PowerShell has aliases like ls, dir, cat and ps, so old habits keep working. A few aliases point to something else than you expect:

  • sc is an alias for Set-Content in Windows PowerShell 5.1. sc query does not query services: it waits for input to write to a file called query. Type sc.exe query, or use Get-Service. PowerShell 7 removed the alias, so there sc runs sc.exe.
  • curl and wget are aliases for Invoke-WebRequest in 5.1. Options like curl -I or curl -k fail. Type curl.exe to run the real curl that ships with Windows. PowerShell 7 removed these aliases: curl runs curl.exe, and wget is not found.
  • where is an alias for Where-Object, in both versions. To find an executable in your PATH, use where.exe, or Get-Command.
  • fc is an alias for Format-Custom, not the file compare tool, in both versions. Use fc.exe, or Compare-Object.

Use aliases at the prompt, but write full cmdlet names in scripts. Get-Alias -Definition Get-ChildItem shows which aliases point to a cmdlet, and Get-Command sc shows what a name resolves to.

PowerShell for sysadmins and DevOps

Tested on Windows 11 in Windows PowerShell 5.1.26100 and PowerShell 7.6.9.

The cmdlets below are the ones I use most in scripts that run unattended: in a scheduled task, a deployment pipeline or against a list of servers.

Run a command on many servers

Invoke-Command runs a script block on remote servers over WinRM, on up to 32 servers at a time by default. Each result carries a PSComputerName property, so you know where it came from.

$servers = 'web01', 'web02', 'web03'
Invoke-Command -ComputerName $servers -ScriptBlock {
    Get-Service -Name W3SVC, WAS
} | Select-Object PSComputerName, Name, Status

For an interactive session on one server, use Enter-PSSession -ComputerName web01.

The objects come back serialized. Status is now a plain string like Running, not an enum, and methods like .Stop() are gone. Filtering with Where-Object Status -eq 'Running' still works, but if you want to act on a service, do it inside the script block.

A server that cannot be reached gives an error for that server only; the others continue. In a domain, use server names instead of IP addresses, so Kerberos authentication works.

Read the event log

Get-EventLog is no longer part of PowerShell 7. On Windows, PowerShell 7 can still load it from Windows PowerShell through its compatibility layer, a hidden Windows PowerShell session, but that is slower and only works on Windows. Get-WinEvent works natively in both versions, and with -FilterHashtable the filtering happens in the event log service instead of in PowerShell, which is much faster:

# Services that stopped unexpectedly in the last 24 hours
Get-WinEvent -FilterHashtable @{
    LogName   = 'System'
    Id        = 7031, 7034
    StartTime = (Get-Date).AddDays(-1)
} -ErrorAction SilentlyContinue | Select-Object TimeCreated, Id, Message

Mind the -ErrorAction SilentlyContinue: when no event matches, Get-WinEvent does not return nothing, but throws "No events were found that match the specified selection criteria". With $ErrorActionPreference = 'Stop' (see below), that stops your script.

Query Active Directory without the AD module

[adsisearcher] is a type accelerator for System.DirectoryServices.DirectorySearcher. It works on any domain-joined Windows machine, in Windows PowerShell 5.1 and PowerShell 7, without the ActiveDirectory module from RSAT. That is handy on servers where you don't want to install RSAT, or in a small script that OpenSSH calls to look up a user's public key.

$searcher = [adsisearcher]'(&(objectCategory=person)(objectClass=user)(sAMAccountName=jdoe))'
$null = $searcher.PropertiesToLoad.AddRange([string[]]('samaccountname', 'mail', 'memberof'))
$user = $searcher.FindOne()
$user.Properties['mail']

A few things to keep in mind:

  • The property names in the result are lowercase, and every value is a collection: use $user.Properties['mail'][0] for a single value.
  • PropertiesToLoad limits what the domain controller sends back, which makes a search over many objects a lot faster.
  • FindAll() returns a collection that holds resources: call .Dispose() on it when you are done, for example in a finally block.
  • Never put user input in an LDAP filter as-is. A value like jdoe*)(objectClass=* changes the filter. Escape it first:
function ConvertTo-LdapFilterValue([string]$Value) {
    [regex]::Replace($Value, '[\\*()\x00]', { param($m) '\{0:x2}' -f [int][char]$m.Value })
}

ConvertTo-LdapFilterValue 'jdoe*)(objectClass=*'
# jdoe\2a\29\28objectClass=\2a

I use [adsisearcher] to list all SPNs used in your Active Directory and to get SSH public keys from Active Directory for SSH authentication. More in my Active Directory guide.

Monitor with CIM and performance counters

Windows exposes its performance counters as CIM classes, so Get-CimInstance can read them in both versions. The Win32_PerfFormattedData_* classes contain calculated values, like requests per second. The Win32_PerfRawData_* classes contain the raw, ever-increasing counters, which a monitoring system like Zabbix turns into a rate itself.

# Current connections and GET requests per second for all IIS websites
Get-CimInstance -ClassName Win32_PerfFormattedData_W3SVC_WebService -Filter "Name = '_Total'" |
    Select-Object Name, CurrentConnections, GetRequestsPersec

This is the basis of my Zabbix templates for IIS: a PowerShell script queries the counters once and returns JSON for a Zabbix item. See Get IIS current connections using PowerShell, Monitor IIS application pools in Zabbix and the other posts in my monitoring guide. Many WMI queries per polling interval can push the WMI Provider Host past its memory quota; if WMI starts failing, see Increase WMI memory to support large volume of queries.

Talk to REST APIs

Invoke-RestMethod converts the JSON response to objects for you. That makes it easy to use the APIs of Azure DevOps, Zabbix or GitHub:

Invoke-RestMethod -Uri 'https://api.github.com/repos/PowerShell/PowerShell/releases/latest' |
    Select-Object tag_name, published_at

When you send JSON, mind the -Depth of ConvertTo-Json. The default is 2, and deeper levels are converted to a string like System.Collections.Hashtable. Windows PowerShell 5.1 does that silently; PowerShell 7 at least shows a warning. Set the depth explicitly:

$body = @{
    name = 'web01'
    tags = @(@{ tag = 'iis'; owner = @{ team = 'hosting' } })
}

$body | ConvertTo-Json -Compress
# {"tags":[{"owner":"System.Collections.Hashtable","tag":"iis"}],"name":"web01"}

$body | ConvertTo-Json -Depth 5 -Compress
# {"tags":[{"owner":{"team":"hosting"},"tag":"iis"}],"name":"web01"}

In Windows PowerShell 5.1 on an older server, an HTTPS request may fail because TLS 1.2 is not enabled for .NET. Enable it for the session with:

[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

Stop on errors

By default, PowerShell writes an error and continues with the next line. In an unattended script, that means a failed step is followed by steps that assume it succeeded. Set this at the top of your script:

$ErrorActionPreference = 'Stop'

try {
    Stop-WebAppPool -Name 'MyAppPool'
    Copy-Item -Path .\release\* -Destination D:\Sites\MyApp -Recurse -Force
    Start-WebAppPool -Name 'MyAppPool'
}
catch {
    Write-Error "Deployment failed: $_"
    exit 1
}

Check exit codes of native commands

$ErrorActionPreference does not apply to executables like appcmd, netsh or robocopy. Their result is in $LASTEXITCODE, the PowerShell equivalent of %ERRORLEVEL% in CMD, and you have to check it yourself. I wrote more about that in PowerShell return value, exit code, or ErrorLevel equivalent.

& "$env:windir\System32\inetsrv\appcmd.exe" list site /name:"Default Web Site"
if ($LASTEXITCODE -ne 0) {
    throw "appcmd failed with exit code $LASTEXITCODE"
}

A real-life example: appcmd needs an elevated prompt. Without it, it prints a configuration error about redirection.config and exits with code 5. A script that does not check $LASTEXITCODE just continues.

Controleren of een PowerShell-script als administrator wordt uitgevoerd (in Dutch)

This matters in Azure DevOps too. The PowerShell task looks only at the exit code of the last native command, so an appcmd that failed halfway through your script goes unnoticed. In PowerShell 7.4 and later, $PSNativeCommandUseErrorActionPreference = $true makes a non-zero exit code an error. Keep robocopy in mind: its exit codes below 8 mean success, and 1 means "files copied".

Log what an unattended script did

Start-Transcript writes everything that appears in the console to a file, including errors. That is often all you need for a scheduled task:

Start-Transcript -Path "D:\Logs\cleanup-$(Get-Date -Format yyyyMMdd).log" -Append
# ... your script ...
Stop-Transcript

Make changes only when asked: -WhatIf and -Apply

Anything that changes or removes something should show what it would do first. There are two ways to build that into your own scripts.

With SupportsShouldProcess, your script gets -WhatIf and -Confirm for free. The cmdlets inside it, like Remove-Item, also inherit -WhatIf:

[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Mandatory)]
    [string]$Path,

    [int]$Days = 30
)

Get-ChildItem -Path $Path -File -Recurse |
    Where-Object LastWriteTime -lt (Get-Date).AddDays(-$Days) |
    ForEach-Object {
        if ($PSCmdlet.ShouldProcess($_.FullName, 'Remove')) {
            Remove-Item -Path $_.FullName
        }
    }

Run it with -WhatIf first, and without once the list is correct.

-WhatIf has one weakness: you have to remember it. Forget it once, and the script removes everything. For scripts that change a lot at once, I turn it around: the script is a dry run by default, and only makes changes with an explicit -Apply switch:

[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [string]$Path,

    [int]$Days = 30,

    [switch]$Apply
)

$Files = Get-ChildItem -Path $Path -File -Recurse |
    Where-Object LastWriteTime -lt (Get-Date).AddDays(-$Days)

if (-not $Apply) {
    $Files | ForEach-Object { "Would remove: $($_.FullName)" }
    "Dry run: $(@($Files).Count) file(s). Run again with -Apply to remove them."
    return
}

$Files | Remove-Item -Verbose

Running .\cleanup.ps1 -Path D:\Logs only lists the files. .\cleanup.ps1 -Path D:\Logs -Apply removes them. Forgetting a switch now means nothing happens, instead of everything.

Parameters instead of $args

The PHP install script earlier in this post reads $args[0] and checks the value itself. A param() block does that for you, and adds tab completion and Get-Help support:

param(
    [Parameter(Mandatory)]
    [ValidateSet('Install', 'Update', 'Version')]
    [string]$Mode
)

switch ($Mode) {
    'Install' { Install-Php }
    'Update'  { Update-Php }
    'Version' { Get-PhpVersion }
}

.\install-php.ps1 -Mode Upgrade now fails with a clear message that lists the valid values. A missing -Mode prompts for a value in an interactive shell, and fails in a pipeline or a scheduled task.

Conclusion

Windows PowerShell is powerful, but unfortunately, PowerShell isn't as powerful and interactive as a shell as GNU/Linux's Bash is (in my opinion). However, you can still create very powerful PowerShell scripts to use in your daily Windows Server administration tasks. The number of available cmdlets for PowerShell is too large to mention them all here.

In this post I showed you a small fraction of those cmdlets, and gave you some beginner insights in PowerShell usage for Windows Server administration, automation and shell/command-prompt scripting.

Summary

  • Windows PowerShell is essential for Windows Server administration, software installation, and automation.
  • PowerShell allows execution of standard Windows commands and its built-in commands called cmdlets for better management.
  • Scripting in PowerShell promotes automation of daily tasks with the inclusion of command-line arguments and flow control.
  • Functions and comparison operators enhance code organization and logic in PowerShell scripts.
  • Despite its limitations compared to Bash, PowerShell provides many cmdlets for efficient Windows Server management.

I write these posts in my spare time, based on real problems from my day job as a sysadmin. If this one saved you some debugging time, a small donation is much appreciated. Thanks! šŸ™

Leave a Comment