PowerShell 7 runs the same object pipeline on Windows, Linux and macOS, so one PowerShell cheat sheet can cover all three. The commands do not always behave the same, though. Get-Service does not exist on Linux, ls is not an alias there, and Invoke-Command -ComputerName lands you in Windows PowerShell 5.1 unless you ask for 7.
The reference tables for cmdlets, the pipeline, objects, remoting and modules come first, then the parts that need explaining, such as SSH remoting from Linux into Windows. We checked the commands in October 2026 on PowerShell 7.6.9 on Windows Server 2025 and Ubuntu 26.04, and every output shown comes from one of those two machines.
PowerShell cheat sheet: quick reference
Each table below groups the commands by task. Anything marked Windows only needs Windows APIs and fails on Linux and macOS. The rest is cross-platform, and we ran most rows on the Ubuntu box and the Windows-specific ones on Windows Server; a few, such as Enter-PSSession, are interactive forms of commands we ran non-interactively.
Start PowerShell and get help
| Command | What it does |
|---|---|
pwsh | Start PowerShell 7 (Windows PowerShell 5.1 is powershell.exe) |
pwsh -NoProfile -c 'Get-Date' | Run one command without loading the profile |
$PSVersionTable | Version, edition (Core or Desktop) and OS |
Get-Command -Noun Process | Find cmdlets by noun |
Get-Command *-Service | Find cmdlets by wildcard |
Get-Help Get-Process -Examples | Usage examples for a cmdlet |
Update-Help -UICulture en-US | Download full help files |
Get-Process | Get-Member | Properties and methods of the output objects |
Get-Alias -Definition Get-ChildItem | Aliases that point to a cmdlet |
Pipeline and objects
| Command | What it does |
|---|---|
Get-Process | Where-Object CPU -gt 1 | Filter objects by a property |
Select-Object -First 5 Name, Id | Keep some properties and some objects |
Sort-Object WorkingSet64 -Descending | Sort by a property |
Group-Object Extension | Count objects per value |
Measure-Object Length -Sum -Average | Count, sum, average, max |
ForEach-Object { $_.Name } | Run a block for each object |
ForEach-Object -Parallel { ... } -ThrottleLimit 5 | Run blocks in parallel runspaces |
Format-Table Name, Id -AutoSize | Table view (use last in the pipeline) |
Format-List * | Every property, one per line |
@{n='MB';e={$_.WorkingSet64/1MB}} | Calculated property for Select-Object |
Files and text
| Command | What it does |
|---|---|
Get-ChildItem -Recurse -Filter *.conf | Find files recursively (alias gci, dir) |
Get-Content app.log -Tail 20 | Last lines of a file (-Wait follows it) |
Set-Content f.txt 'text' | Write a file (overwrites) |
Add-Content f.txt 'more' | Append to a file |
New-Item -ItemType Directory logs | Create a folder |
Copy-Item src dst -Recurse | Copy files or folders |
Move-Item draft.txt final.txt | Move or rename |
Remove-Item old.bak -WhatIf | Preview a delete before running it |
Test-Path /etc/hosts | True if the path exists |
Select-String -Path *.log -Pattern 'error' | grep for PowerShell |
Processes, services and jobs
| Command | What it does |
|---|---|
Get-Process -Id $PID | The current PowerShell process |
Stop-Process -Name sleep | Kill processes by name |
Start-Job { Start-Sleep 2; 'done' } | Background job in a new process |
Receive-Job -Id 1 -Wait -AutoRemoveJob | Collect job output and clean up |
Start-ThreadJob { ... } | Lighter job in a thread |
Get-Service | Where-Object Status -eq Running | Running services (Windows only) |
Restart-Service Spooler | Restart a service (Windows only) |
Variables and operators
| Command | What it does |
|---|---|
-eq -ne -gt -lt -ge -le | Comparison (case-insensitive by default) |
'pwsh' -like 'p*' | Wildcard match |
'error 404' -match '\d+' | Regex match, result in $Matches[0] |
1,2,3 -contains 2 | Is the value in the collection |
'a,b,c' -split ',' | Split a string (-join reverses it) |
$null ?? 'default' | Null-coalescing |
$x -gt 3 ? 'big' : 'small' | Ternary operator |
Test-Path f.txt && 'ok' | Run the second command only on success (|| on failure) |
Get-ChildItem @params | Splat a hashtable of parameters |
[pscustomobject]@{Name='web01'} | Build your own object |
JSON, CSV and web
| Command | What it does |
|---|---|
ConvertTo-Json -Depth 5 | Objects to JSON (default depth is 2) |
Get-Content f.json | ConvertFrom-Json | JSON to objects |
Export-Csv procs.csv | Objects to CSV (no #TYPE line in 7) |
Import-Csv procs.csv | CSV rows to objects |
Invoke-RestMethod https://api.github.com/... | Call an API, JSON parsed for you |
Invoke-WebRequest $url -Method Head | Raw HTTP request with status code |
Errors and debugging
| Command | What it does |
|---|---|
try { ... -ErrorAction Stop } catch { $_ } | Catch an error from one cmdlet |
$ErrorActionPreference = 'Stop' | Make every error terminating |
-ErrorAction SilentlyContinue | Ignore an error for one call |
$Error[0].Exception.Message | Message of the last error |
Get-Error | Full detail of the last error |
Measure-Command { ... } | Time a block |
Remoting over SSH and WinRM
| Command | What it does |
|---|---|
Enter-PSSession -HostName web01 -UserName admin | Interactive shell over SSH |
Invoke-Command -HostName web01,db01 -UserName admin { hostname } | Run a block on many hosts over SSH |
$s = New-PSSession -HostName web01 -UserName admin | Reusable session |
Get-PSSession | Remove-PSSession | List and close sessions |
Invoke-Command -ComputerName srv01 -ConfigurationName PowerShell.7 { ... } | WinRM into PowerShell 7 (Windows only) |
Enable-PSRemoting -Force | Register WinRM endpoints (Windows, admin) |
Test-WSMan srv01 | Is WinRM answering |
Modules with PSResourceGet
| Command | What it does |
|---|---|
Find-PSResource -Name PSScriptAnalyzer | Search the PowerShell Gallery |
Install-PSResource Pester -Scope CurrentUser -TrustRepository | Install without the trust prompt |
Get-InstalledPSResource | Modules installed from a repository |
Update-PSResource -Name Pester | Update a module |
Uninstall-PSResource -Name Pester | Remove a module |
Set-PSResourceRepository -Name PSGallery -Trusted | Trust the gallery permanently |
Get-Module -ListAvailable | Every module on the module path |
Import-Module PSScriptAnalyzer | Load a module into the session |
Windows system information
| Command | What it does |
|---|---|
Get-CimInstance Win32_OperatingSystem | OS version, last boot, free memory |
Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3' | Local disks and free space |
Get-WinEvent -LogName System -MaxEvents 5 | Newest event log entries |
Get-NetIPAddress -AddressFamily IPv4 | IP addresses per interface |
Test-NetConnection 1.1.1.1 -Port 443 | TCP port check |
Get-HotFix | Installed updates |
Import-Module ServerManager -UseWindowsPowerShell | Load a 5.1-only module through a proxy session |
Get-ExecutionPolicy -List | Script policy per scope |
Profile and shell settings
| Command | What it does |
|---|---|
$PROFILE | Select-Object * | All four profile paths |
New-Item -ItemType File $PROFILE -Force | Create your profile script |
Get-PSReadLineOption | Edit mode, prediction and history file |
Get-PSReadLineKeyHandler -Chord Ctrl+r | What a key does (reverse history search) |
$env:PSModulePath -split [IO.Path]::PathSeparator | Where modules load from |
That’s the whole sheet. The rest of this page explains the rows that behave differently between operating systems, with the output we got on both machines.
How do you install PowerShell 7 on Windows and Linux?
On Windows you install the MSI from the GitHub release, and on Ubuntu 26.04 you install the .deb from the same release page, because Microsoft’s apt repository for 26.04 does not carry the stable package yet. We registered the repository the documented way, ran apt-get update, and then asked for the stable package:
sudo apt-get install -y powershell
apt could not find it, even with the Microsoft source in place:
E: Unable to locate package powershell
The 26.04 feed (codename resolute) only lists powershell-preview, so Microsoft’s documented repository method fails on 26.04 even though Microsoft’s support page lists 26.04 as supported. The .deb from GitHub is the same universal package, and the version check below always picks the newest release instead of a pinned one:
VER=$(curl -s https://api.github.com/repos/PowerShell/PowerShell/releases/latest | grep '"tag_name"' | cut -d '"' -f 4 | sed 's/^v//')
echo "$VER"
wget https://github.com/PowerShell/PowerShell/releases/download/v${VER}/powershell_${VER}-1.deb_amd64.deb
sudo apt-get install -y ./powershell_${VER}-1.deb_amd64.deb
Then start pwsh and print the version table. A healthy install reports PSEdition Core and the distro name:
pwsh -NoProfile -c '$PSVersionTable'
Our Ubuntu box printed this table (we dropped the long PSCompatibleVersions row). The WSManStackVersion row shows up on Linux too, but WinRM remoting from Linux still fails, as the remoting section shows:
Name Value
---- -----
PSVersion 7.6.9
PSEdition Core
GitCommitId 7.6.9
OS Ubuntu 26.04.1 LTS
Platform Unix
PSRemotingProtocolVersion 2.4
SerializationVersion 1.1.0.1
WSManStackVersion 3.0
Whichever method you use, three quick checks confirm the install before you build anything on it:
which pwshon Linux returns/usr/bin/pwsh, and on Windows the binary sits inC:\Program Files\PowerShell\7.pwsh --versionprints the same release the GitHub API reported.$PSVersionTable.PSEditionreadsCore, notDesktop.
On Ubuntu 24.04 the Microsoft repository does carry the stable package, and our PowerShell install guide for Ubuntu and Debian covers that path. Rocky Linux and RHEL readers can follow the PowerShell setup for RHEL 10 and Rocky Linux 10, and Mac users have the macOS install steps.
winget was absent on our Windows Server 2025 image (on Server, the Microsoft docs say it only ships with Desktop Experience), so we used the MSI. The properties come from Microsoft’s Windows install page; ENABLE_PSREMOTING=1 matters later because it registers the WinRM endpoint for PowerShell 7:
$ver = (Invoke-RestMethod https://api.github.com/repos/PowerShell/PowerShell/releases/latest).tag_name.TrimStart('v')
Invoke-WebRequest "https://github.com/PowerShell/PowerShell/releases/download/v$ver/PowerShell-$ver-win-x64.msi" -OutFile "C:\lab\PowerShell-$ver-win-x64.msi"
Start-Process msiexec.exe -Wait -ArgumentList "/package C:\lab\PowerShell-$ver-win-x64.msi /quiet ADD_EXPLORER_CONTEXT_MENU_OPENPOWERSHELL=1 ADD_FILE_CONTEXT_MENU_RUNPOWERSHELL=1 ENABLE_PSREMOTING=1 REGISTER_MANIFEST=1 USE_MU=1 ENABLE_MU=1 ADD_PATH=1"
The MSI installs to C:\Program Files\PowerShell\7 and leaves Windows PowerShell 5.1 in place, so both shells stay on the PATH. On Windows 11 or a Server with Desktop Experience, winget install --id Microsoft.PowerShell --source winget does the same job, and our winget guide covers that tool. The same Microsoft install page also says that winget installs the MSIX package by default since 7.6 and that no MSI ships from 7.7 onward, so scripted MSI installs like the one above have a limited life.
Here is how the two shells compared on the same Windows Server 2025 VM. We timed -NoProfile -c exit three times each:
| Check | PowerShell 7 (pwsh) | Windows PowerShell 5.1 |
|---|---|---|
| Startup, three runs | 198 to 248 ms | 540 to 662 ms |
(Get-Command).Count | 2052 | 2014 |
curl | Not an alias | Alias of Invoke-WebRequest |
&&, ??, ternary | Work | Not available |
ForEach-Object -Parallel | Works | Not available |
| Default WinRM endpoint it lands in | 5.1 unless you pass -ConfigurationName PowerShell.7 | 5.1 |
The startup gap is the one you feel. A scheduled task that runs a short script every minute pays about a third of a second less per run under pwsh. On Ubuntu, pwsh -NoProfile -c exit took 391 to 428 ms on a 2 vCPU VM.
How do you find the right cmdlet?
Use Get-Command to find the cmdlet, Get-Help to learn its parameters and Get-Member to see what objects it returns. Cmdlet names follow a Verb-Noun pattern, so a search by noun usually lists the whole family:
Get-Command -Noun Service
On Ubuntu that printed nothing at all, which is your first hint that the service cmdlets are Windows only. On Linux, (Get-Command).Count returned 298 commands against 2052 on Windows Server, because most Windows modules (networking, storage, CIM, event logs) have no Linux build.
Help is the other surprise on a fresh install. PowerShell 7 ships without full help files, so the first Get-Help call warns you and shows only the syntax:
Get-Help cannot find the Help files for this cmdlet on this computer. It is displaying only partial help.
Run Update-Help -UICulture en-US once per user. It took 45 seconds on our Ubuntu VM, and afterwards Get-Help Get-Process -Examples printed the full examples. The default scope is the current user, so each new account needs the step again; add -Scope AllUsers from an elevated or sudo shell to cover everyone on the machine.
Aliases are where cross-platform scripts break. On Windows, ls and cat are aliases of Get-ChildItem and Get-Content. On Linux, PowerShell removes those aliases so the real binaries keep working, and the alias lookup fails:
Get-Alias: This command cannot find a matching alias because an alias with the name 'ls' does not exist.
So ls | Select-Object Name works on Windows and returns nothing useful on Linux, because /usr/bin/ls sends text, not objects. Spell out the cmdlet name in any script that might run on both. We also tripped over a less obvious alias while scripting the lab: a helper function we named R never ran, because r is a built-in alias of Invoke-History and aliases win over functions. Every call failed with Cannot locate the history for command line. Check a short name with Get-Alias before you use it.
How does the PowerShell pipeline pass objects?
The PowerShell pipeline passes .NET objects, not text, so you filter and sort on named properties instead of cutting columns with awk. This command picks the five processes using the most memory and adds a calculated megabytes column:
Get-Process | Sort-Object WorkingSet64 -Descending | Select-Object -First 5 Name, Id, @{n='MB';e={[math]::Round($_.WorkingSet64/1MB,1)}}
On the Ubuntu VM pwsh itself was the largest process at about 110 MB. That figure matters if you plan to run pwsh in a small container or on a 512 MB VM, because the shell alone takes a fifth of the memory before your script loads anything:
Name Id MB
---- -- --
pwsh 33466 110.50
snapd 32569 43.10
unattended-upgrade-shutdown 1534 32.00
networkd-dispatcher 1441 29.20
packagekitd 32450 22.20
Group-Object and Measure-Object answer the “how many” questions that would need sort | uniq -c in Bash. Grouping /etc by extension showed 45 files with no extension and 31 .conf files, and Measure-Object summed 1107 files in /usr/bin in one line:
Get-ChildItem /usr/bin | Measure-Object -Property Length -Sum -Average -Maximum
The result is itself an object, so you can pipe it on or read one property with .Sum:
Count : 1107
Average : 143424.717253839
Sum : 158771162
Maximum : 25214552
Minimum :
StandardDeviation :
Property : Length
ForEach-Object -Parallel is the PowerShell 7 feature that saves the most time in admin scripts, because each block runs in its own runspace. We timed five one-second sleeps both ways:
(Measure-Command { 1..5 | ForEach-Object { Start-Sleep 1 } }).TotalSeconds
(Measure-Command { 1..5 | ForEach-Object -Parallel { Start-Sleep 1 } -ThrottleLimit 5 }).TotalSeconds
Sequential took 5.01 seconds and parallel took 1.33 seconds on Ubuntu (1.08 on Windows Server). With -ThrottleLimit 2 over four items, Windows took 2.06 seconds, which shows the limit doing its job. Variables from the outer scope are not visible inside the parallel block, so pass them in as $using:name. The other limit is cost: parallel blocks run in separate runspaces, and the Microsoft docs warn that this overhead can make small, fast blocks slower than a plain loop. Use it for slow, independent work such as network calls and remote checks, not for tiny loops.
Keep Format-Table and Format-List at the very end of a pipeline. They turn objects into display records, so anything after them (Export-Csv, ConvertTo-Json) gets formatting data instead of your properties.
Which operators and variables do you use most?
The comparison operators are words (-eq, -gt, -like, -match) because > and < are redirection, and they are case-insensitive unless you use the c form such as -ceq. PowerShell 7 also adds the shorthand that 5.1 lacks. We checked each one on Ubuntu:
'error 404' -match '\d+'; $Matches[0]
$null ?? 'default'
$x = 5; $x -gt 3 ? 'big' : 'small'
Test-Path /etc/hosts && 'exists'
Get-Item /nope 2>$null || 'missing'
Each line printed what you would expect. Note that Test-Path printed its own True before exists, because && does not swallow the first command’s output:
True
404
default
big
True
exists
missing
Splatting keeps long commands readable. Put the parameters in a hashtable and pass it with @ instead of $; this counted 31 .conf files in /etc:
$p = @{Path='/etc'; Filter='*.conf'}
(Get-ChildItem @p).Count
When you need your own records, build them with [pscustomobject]@{Name='web01';Port=443}. Unlike a plain hashtable, the result prints as a table and works with Export-Csv and Sort-Object like any cmdlet output.
How do you work with JSON, CSV and REST APIs?
Invoke-RestMethod calls an API and turns the JSON reply into objects, so you can select fields straight away. This is how we checked the latest PowerShell release during the lab:
Invoke-RestMethod https://api.github.com/repos/PowerShell/PowerShell/releases/latest | Select-Object tag_name, published_at
The release tag and date come back as properties, so there is no jq step and no string parsing. That is also why the install section could read the latest version in one line on Windows, while the Bash version needs grep and cut:
tag_name published_at
-------- ------------
v7.6.9 10/25/2026 17:26:05
Going the other way, ConvertTo-Json has a trap: it stops at a depth of 2 and flattens anything deeper into a type name. PowerShell 7 at least warns you now:
@{a=@{b=@{c=@{d=1}}}} | ConvertTo-Json
Look at the c key. The data is gone and only the class name is left:
Resulting JSON is truncated as serialization has exceeded the set depth of 2.
{
"a": {
"b": {
"c": "System.Collections.Hashtable"
}
}
}
Add -Depth 5 (or higher for API payloads) and the full tree comes back. We set it on every ConvertTo-Json that writes a config file or a request body, because a truncated payload usually fails far from the line that caused it.
CSV changed between versions too. Export-Csv in PowerShell 7 no longer writes the #TYPE header line that 5.1 adds, so the file starts with the column names and other tools read it cleanly. Our export of three processes began with "Name","Id", every value quoted, and Import-Csv read it back as objects.
How do you catch errors in PowerShell scripts?
Wrap the call in try and catch and add -ErrorAction Stop, because most cmdlet errors are non-terminating and skip the catch block otherwise. This is the pattern that works:
try { Get-Item /nope -ErrorAction Stop } catch { "Caught: $($_.Exception.Message)" }
The catch block ran and printed the clean message. Drop -ErrorAction Stop and the same line prints the red error instead, because the catch never fires for a non-terminating error:
Caught: Cannot find path '/nope' because it does not exist.
For a whole script, set $ErrorActionPreference = 'Stop' at the top instead of adding the parameter to every call. Inside the catch, $_.FullyQualifiedErrorId gives a stable ID such as PathNotFound,Microsoft.PowerShell.Commands.GetItemCommand, which is safer to test against than the message text. When you need the full picture after a failure, Get-Error prints the exception type, target object, script line and stack trace; on our test it ran to about 60 lines for a single missing path.
Which cmdlets only work on Windows?
Anything that talks to a Windows subsystem only works on Windows, because the cmdlets are thin wrappers over Windows APIs that have no Linux equivalent. Before you share a script with Linux users, check that it avoids these families or guards them with if ($IsWindows):
- No service cmdlets (
Get-Service,Restart-Service) run outside the guard. - No CIM or WMI queries (
Get-CimInstance) run outside the guard. - No event log reads (
Get-WinEvent) or registry paths (HKLM:) run outside the guard. - No networking cmdlets (
Get-NetIPAddress,Test-NetConnection) orGet-HotFixrun outside the guard.
On Linux these fail before they run, with the same “not recognized” error you get from a typo:
The term 'Get-Service' is not recognized as a name of a cmdlet, function, script file, or executable program.
Check the spelling of the name, or if a path was included, verify that the path is correct and try again.
On Windows Server 2025 the same cmdlets all worked under pwsh. This one returns the OS, last boot and free memory in a single object:
Get-CimInstance Win32_OperatingSystem | Select-Object Caption, Version, LastBootUpTime, @{n='FreeGB';e={[math]::Round($_.FreePhysicalMemory/1MB,2)}}
Our 8 GB lab VM had about 5 GB free right after boot. FreePhysicalMemory is reported in kilobytes, so dividing by 1MB turns it into gigabytes, which is the kind of unit detail a calculated property hides from whoever reads the report:
Caption Version LastBootUpTime FreeGB
------- ------- -------------- ------
Microsoft Windows Server 2025 Datacenter 10.0.26100 10/29/2026 8:59:25 AM 5.05
Some Windows modules still only load in Windows PowerShell 5.1. ServerManager is one, and Get-Module -ListAvailable ServerManager returns nothing in pwsh. Load it with -UseWindowsPowerShell and PowerShell 7 runs it in a hidden 5.1 process for you:
Import-Module ServerManager -UseWindowsPowerShell
Get-WindowsFeature | Where-Object Installed | Select-Object -First 5 Name
The import prints a warning that explains the trade-off. The objects you get back are deserialized copies, so their methods are gone and only the properties remain:
WARNING: Module ServerManager is loaded in Windows PowerShell using WinPSCompatSession remoting session; please note that all input and output of commands from this module will be deserialized objects. If you want to load this module into PowerShell please use 'Import-Module -SkipEditionCheck' syntax.
One result surprised us. Get-WmiObject was removed from PowerShell 7, yet on our Windows Server it still answered, and Get-WmiObject Win32_BIOS returned the VM’s firmware vendor. Get-Command showed why: it was a proxy function forwarding into the same WinPSCompatSession, not a native cmdlet. Scripts that lean on it run slower and get deserialized objects, so move them to Get-CimInstance, which is native in 7 and also reaches remote machines.
Execution policy is the last Windows-only setting. Get-ExecutionPolicy -List on Server 2025 showed RemoteSigned at LocalMachine for both shells. On Linux the policy is fixed at Unrestricted and Set-ExecutionPolicy RemoteSigned fails with Operation is not supported on this platform.
How does PowerShell remoting work over SSH and WinRM?
PowerShell 7 remotes over SSH on every platform with -HostName, and over WinRM on Windows with -ComputerName. SSH is the one that crosses operating systems, and it is what we used to run commands on Windows Server 2025 from the Ubuntu box. You need three things on each target:
- An SSH server that is running and reachable on port 22.
- A
Subsystem powershellline in the sshd config that starts pwsh in server mode. - Key-based login for the remote user, so scripts never wait on a password prompt.
Ubuntu reads drop-in files from /etc/ssh/sshd_config.d/, so the subsystem line can live in its own file and survive package updates to the main config:
echo 'Subsystem powershell /usr/bin/pwsh -sshs -NoLogo' | sudo tee /etc/ssh/sshd_config.d/50-powershell.conf
sudo sshd -t
sudo systemctl restart ssh
sshd -t returned nothing, which means the syntax is valid, and sudo sshd -T | grep -i subsystem then listed both the powershell and sftp subsystems. Without that line the client connects and then gives up with an error that never mentions the config (it is listed in the troubleshooting section below).
Windows Server 2025 already had the OpenSSH server capability installed, sshd running and the inbound firewall rule enabled, so only the config needed changes. Open the server config:
notepad C:\ProgramData\ssh\sshd_config
Add the subsystem line next to the sftp one. The Microsoft docs use the 8.3 short path because sshd cannot handle the space in Program Files, and we added -NoLogo to match the Linux side. Our image also shipped with the administrators block commented out, so we enabled it, which makes admin accounts read their keys from administrators_authorized_keys:
Subsystem powershell c:/progra~1/powershell/7/pwsh.exe -sshs -NoLogo
Match Group administrators
AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys
The Linux side needs a key pair and the Windows host key before the first connection. We created the key with ssh-keygen -t ed25519 as the Linux user, added the server’s host key with ssh-keyscan 10.0.1.51 >> ~/.ssh/known_hosts, and created a local Windows account in the Administrators group to log in as. Paste the Linux user’s public key into C:\ProgramData\ssh\administrators_authorized_keys, lock the file down to Administrators and SYSTEM, and restart sshd. The Microsoft docs require that ACL, and key login fails when the file is readable by other accounts:
icacls C:\ProgramData\ssh\administrators_authorized_keys /inheritance:r /grant "Administrators:F" /grant "SYSTEM:F"
Restart-Service sshd
From the Ubuntu box, one Invoke-Command now reaches both operating systems. The screenshot shows the first run against Windows and a fan-out to both hosts:

The PSComputerName column tells you which host each result came from, which matters once a block runs on twenty servers. The post-quantum warning comes from the Ubuntu 26.04 OpenSSH client because the Windows server ships OpenSSH 9.5, which lacks the newer key exchange. It is a warning only, the session works, and the lasting fix is a newer OpenSSH on the Windows side. Key generation and known_hosts handling on the Linux end are covered in our SSH commands cheat sheet.
WinRM is still the default between Windows machines, and it has one behaviour that catches people out. From pwsh, Invoke-Command -ComputerName without a configuration name connects to the Microsoft.PowerShell endpoint, which is Windows PowerShell 5.1. We ran the same version check against both endpoints:
Invoke-Command -ComputerName localhost -Credential $cred -ScriptBlock { $PSVersionTable.PSVersion.ToString() }
Invoke-Command -ComputerName localhost -Credential $cred -ConfigurationName PowerShell.7 -ScriptBlock { $PSVersionTable.PSVersion.ToString() }
The first came back as 5.1 and the second as 7, even though both ran from the same pwsh window:
5.1.26100.7462
7.6.9
So a script that uses ?? or -Parallel inside the remote block fails on the default endpoint even though you started it from pwsh. Pass -ConfigurationName PowerShell.7, or set $PSSessionConfigurationName = 'PowerShell.7' in your profile. The PowerShell.7 endpoint exists because the MSI ran with ENABLE_PSREMOTING=1; on a machine installed without it, run Enable-PSRemoting -Force from pwsh as an administrator. That command only touches PowerShell 6+ endpoints and says so:
PowerShell remoting has been enabled only for PowerShell 6+ configurations and does not affect Windows PowerShell remoting configurations. Run this cmdlet in Windows PowerShell to affect all PowerShell remoting configurations.
WinRM is already set up to receive requests on this computer.
WinRM is already set up for remote management on this computer.
Linux cannot use -ComputerName at all: Invoke-Command -ComputerName localhost on Ubuntu failed with the WSMan error listed further down. For mixed fleets, standardize on SSH and keep WinRM for Windows-to-Windows work. If you manage .NET code from PowerShell as well, our guide on working with .NET as a PowerShell developer picks up from here.
How do you install modules with PSResourceGet?
Use Install-PSResource with -TrustRepository in scripts, or trust the PowerShell Gallery once with Set-PSResourceRepository. PowerShell 7 ships both module managers: Microsoft.PowerShell.PSResourceGet (the *-PSResource cmdlets) and the older PowerShellGet with Install-Module. They install to the same folders, so a module installed with one shows up in Get-InstalledPSResource and Get-InstalledModule alike.
The gallery starts out untrusted, and that is where unattended installs go wrong. Searching is fine, but an install without -TrustRepository shows a confirmation prompt, and in a non-interactive session it then fails with an error that says nothing about trust:
Untrusted repository
You are installing the modules from an untrusted repository. If you trust this
repository, change its Trusted value by running the Set-PSResourceRepository
cmdlet. Are you sure you want to install the PSResource from 'PSGallery'?
[Y] Yes [A] Yes to All [N] No [L] No to All [S] Suspend [?] Help
(default is "N"):Object reference not set to an instance of an object.
Adding -Confirm:$false did not help; the same prompt and the same failure came back. Only -TrustRepository installed the module:
Install-PSResource -Name PSScriptAnalyzer -Scope CurrentUser -TrustRepository
Get-InstalledPSResource
The module then lists with its version and source. For build agents and provisioning scripts, settle on one of these so no install ever stops at a prompt:
- Every
Install-PSResourcecall in the script carries-TrustRepository. - The image runs
Set-PSResourceRepository -Name PSGallery -Trustedonce, andGet-PSResourceRepositoryshowsTrustedasTrue. - Internal builds point at your own repository, registered with
Register-PSResourceRepositoryand marked trusted.
We trusted and then un-trusted PSGallery on the Ubuntu box, and Get-PSResourceRepository flipped between True and False as expected. This is what the installed module looked like:
Name Version Prerelease Repository Description
---- ------- ---------- ---------- -----------
PSScriptAnalyzer 1.25.0 PSGallery PSScriptAnalyzer provides script analysis and checks for potential code defects in the scrip…
PSScriptAnalyzer is worth having for exactly the alias problem covered earlier. Pointed at function f { gci }, it flagged PSAvoidUsingCmdletAliases with the message 'gci' is an alias of 'Get-ChildItem'. The legacy Install-Module -Name Pester -Scope CurrentUser -Force installed Pester with no prompt at all on the same untrusted gallery. That is convenient, but -Force also overrides other install warnings, which is why we prefer the explicit -TrustRepository switch.
Modules land in different folders per platform, which matters when you copy one by hand or bake it into an image:
| Scope | Ubuntu 26.04 | Windows Server 2025 |
|---|---|---|
| Current user | ~/.local/share/powershell/Modules | Documents\PowerShell\Modules |
| All users | /usr/local/share/powershell/Modules | C:\Program Files\PowerShell\Modules |
| Shipped with pwsh | /opt/microsoft/powershell/7/Modules | C:\Program Files\PowerShell\7\Modules |
| Windows PowerShell 5.1 paths | None | Also on the path, after the pwsh folders |
Windows pwsh also searches the 5.1 module folders after its own, so modules written for 5.1 are found too. The ones that need the full .NET Framework load through the compatibility session, the way ServerManager did in the Windows section.
Where does the PowerShell profile live?
$PROFILE holds the path of your current-user, current-host profile, and it has three siblings for the other scopes. On Ubuntu the file does not exist until you create it:
$PROFILE | Select-Object *
New-Item -ItemType File -Path $PROFILE -Force
Linux follows the XDG layout, so the user profiles sit under ~/.config/powershell and the all-users ones next to the binary:
AllUsersAllHosts : /opt/microsoft/powershell/7/profile.ps1
AllUsersCurrentHost : /opt/microsoft/powershell/7/Microsoft.PowerShell_profile.ps1
CurrentUserAllHosts : /home/harrison/.config/powershell/profile.ps1
CurrentUserCurrentHost : /home/harrison/.config/powershell/Microsoft.PowerShell_profile.ps1
On Windows the user profile lives in Documents\PowerShell, a different folder from 5.1’s Documents\WindowsPowerShell, so your old profile does not follow you to pwsh. Copy what you still need. An empty profile cost us nothing measurable at startup (418 to 440 ms against 391 to 428 ms without it), but every module you import there adds to each new shell.
Keep the profile lean, because it runs in every new interactive shell and in every scheduled pwsh call started without -NoProfile, so a slow import is paid many times a day. Before you commit a profile to your dotfiles, check it against this list:
pwsh -c exitstarts within a few hundred milliseconds ofpwsh -NoProfile -c exit.- The profile imports only modules you use in most sessions.
- Windows-only lines sit inside
if ($IsWindows) { }when the same profile syncs to Linux or macOS.
PSReadLine handles the command line itself. By default it ran in Emacs edit mode with HistoryAndPlugin predictions shown inline, Ctrl+r bound to reverse history search, and history saved to ~/.local/share/powershell/PSReadLine/ConsoleHost_history.txt. That history file keeps every command you type across sessions, including any secret you pasted on the command line, so clear it after a mistake. For a Git-aware prompt on Windows, posh-git adds branch and status to it.
How do you fix common PowerShell 7 errors?
These are the errors we actually hit while building the lab, with the cause and the fix for each. Most of them come from a command that works on one platform being run on the other, so the message rarely points at the real problem.
Error: “subsystem request failed on channel 0”
The target’s sshd has no Subsystem powershell line. SSH itself connected, which is why the full message also says The SSH transport process has abruptly terminated causing this remote session to break. Add the subsystem line from the remoting section, run sshd -t and restart sshd.
Error: “This parameter set requires WSMan, and no supported WSMan client library was found”
You used -ComputerName on Linux or macOS, which means WinRM, and pwsh on those platforms has no WSMan client. Switch to -HostName and -UserName so the session runs over SSH.
Error: “Connecting to remote server localhost failed … Access is denied”
We got this on Windows when running Invoke-Command -ComputerName localhost as the SYSTEM account, which has no network credential for WinRM. A normal admin session or an explicit -Credential for a local administrator fixed it on all three endpoints.
Error: “Object reference not set to an instance of an object” from Install-PSResource
The trust prompt for the untrusted PSGallery had no one to answer it. Add -TrustRepository to the install, or run Set-PSResourceRepository -Name PSGallery -Trusted once on build machines.
Error: “The term ‘Get-Service’ is not recognized”
The cmdlet only exists on Windows. On Linux, call systemctl directly from pwsh, for example systemctl is-active ssh, and wrap Windows-only blocks in if ($IsWindows) { ... } in shared scripts.
Error: “Operation is not supported on this platform” from Set-ExecutionPolicy
Execution policy is a Windows feature. Linux and macOS always run as Unrestricted, so remove the call from cross-platform setup scripts or guard it with $IsWindows.
Error: “E: Unable to locate package powershell” on Ubuntu 26.04
Microsoft’s 26.04 apt feed only had powershell-preview when we tested. Install the .deb from the GitHub release as shown in the install section, and check apt-cache policy powershell again later to see whether the stable package has arrived.
Checklist: set up PowerShell 7 on a new machine
Run through this list on each new workstation or server. Every item is something you can verify from the shell in a few seconds:
pwsh --versionprints the release you expect, and$PSVersionTable.PSEditionreadsCore.Update-Help -UICulture en-UShas run for your account (or once with-Scope AllUsers), soGet-Helpshows full examples.Test-Path $PROFILEreturnsTrue, and the profile only loads modules you use daily.Get-PSResourceRepositoryshows PSGallery trusted, or your scripts pass-TrustRepository.sshd -Tlists the powershell subsystem on every host you plan to reach with-HostName.- On Windows,
Get-PSSessionConfigurationlistsPowerShell.7, and your WinRM calls pass-ConfigurationName PowerShell.7. - Shared scripts use full cmdlet names, and
Invoke-ScriptAnalyzerreports noPSAvoidUsingCmdletAliaseswarnings.
If you’re still choosing a daily shell for Linux or macOS, our comparison of Bash, Zsh, Fish, PowerShell and Nushell shows where pwsh fits next to them. Windows admins who want more depth can pick from our Windows Server administration books.