PowerShell · lesson 11 of 12
PowerShell Remoting over SSH: Invoke-Command, Sessions, and $using:
Run PowerShell on other machines over SSH: Invoke-Command to one host or many, $using: for local values, persistent sessions, and why results lose methods.
By SkillByExample · Published
Everything so far ran on the machine in front of you. Remoting sends a script block to another machine, runs it in a PowerShell process there, and brings the results back as objects. This lesson uses SSH, the transport that works the same on Linux, macOS and Windows.
A Lab to Practise On
You need a target with PowerShell 7 and an SSH server. This image has both; the one line that
makes remoting work registers pwsh as an SSH subsystem.
# pslab: PowerShell 7 + OpenSSH server/client, for remoting-over-SSH practice on localhost
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y --no-install-recommends wget ca-certificates openssh-server openssh-client \
&& wget -q https://packages.microsoft.com/config/ubuntu/24.04/packages-microsoft-prod.deb \
&& dpkg -i packages-microsoft-prod.deb && rm packages-microsoft-prod.deb \
&& apt-get update && apt-get install -y --no-install-recommends powershell=7.6.7-1.deb \
&& rm -rf /var/lib/apt/lists/* && mkdir -p /run/sshd
# The one line remoting needs: register pwsh as an SSH subsystem
RUN echo 'Subsystem powershell /usr/bin/pwsh -sshs -NoLogo' >> /etc/ssh/sshd_config \
&& echo 'PasswordAuthentication no' >> /etc/ssh/sshd_config \
&& useradd -m -s /bin/bash ops && mkdir -p /home/ops/.ssh && chown ops:ops /home/ops/.ssh && chmod 700 /home/ops/.ssh
CMD ["/usr/sbin/sshd", "-D", "-e"]
It pins PowerShell 7.6.7, the version every output in this lesson came from; drop =7.6.7-1.deb
to install the latest instead. Save it as Dockerfile in an empty folder, then build two targets and a client from it, with an
SSH key so the client can log in as ops:
# setup.sh - two SSH targets (web1, web2) and a client, all running PowerShell 7
docker build -q -t pslab . > /dev/null
docker network create pslab > /dev/null
for h in web1 web2; do
docker run -d --name $h --hostname $h --network pslab pslab > /dev/null
done
# the client mounts this folder at /lab, so scripts you save here run there
docker run -d --name client --hostname client --network pslab -v "$PWD:/lab" -w /lab pslab > /dev/null
# a key pair in client; its public half becomes ops's authorized key on each target
docker exec client sh -c "mkdir -p -m 700 /root/.ssh && ssh-keygen -q -t ed25519 -N '' -f /root/.ssh/id_ed25519"
docker exec client cat /root/.ssh/id_ed25519.pub > key.pub
for h in web1 web2; do
docker cp -q key.pub $h:/home/ops/.ssh/authorized_keys
docker exec $h sh -c 'chown ops:ops /home/ops/.ssh/authorized_keys && chmod 600 /home/ops/.ssh/authorized_keys'
done
# wait until both sshd's answer, then record their host keys
docker exec client sh -c 'until ssh-keyscan web1 web2 > /root/.ssh/known_hosts 2>/dev/null && [ $(wc -l < /root/.ssh/known_hosts) -ge 2 ]; do sleep 1; done'
docker exec client pwsh -NoLogo -Command '"PowerShell $($PSVersionTable.PSVersion) on $(hostname)"'
docker exec client ssh ops@web1 'echo "ssh as $(whoami) on $(hostname): ok"'
$ bash setup.sh
PowerShell 7.6.7 on client
ssh as ops on web1: ok
On Git Bash for Windows, run export MSYS_NO_PATHCONV=1 first, or Docker receives rewritten
container paths. The last two lines prove the pieces work: PowerShell runs in the client, and the
client reaches web1 over SSH with its key and no password. Open a shell with
docker exec -it client bash; it starts in /lab, which is your folder on the host, so a script
you save there runs in the client. Every $ command below runs in that shell. When you’re done,
docker rm -f web1 web2 client; docker network rm pslab; docker rmi pslab; rm key.pub removes it
all.
Where the Code Runs
# where.ps1 - the script block runs on web1; everything else runs here
Invoke-Command -HostName web1 -UserName ops -ScriptBlock {
"running on: $(hostname) as $(whoami)"
}
"back on: $(hostname) as $(whoami)"
$ pwsh where.ps1
running on: web1 as ops
back on: client as root
The script block ran on web1 as ops, the user from the SSH login. The last line wasn’t in a
script block, so it ran in the client as root. -HostName is what selects SSH: it uses your
~/.ssh keys and known_hosts, just like the ssh command.
-ComputerName Is the Other Transport
Most remoting examples you’ll find online use -ComputerName. Here is what it does on Linux:
# computername.ps1 - -ComputerName means WinRM, which pwsh on Linux can't speak
try {
Invoke-Command -ComputerName web1 -ScriptBlock { hostname } -ErrorAction Stop
} catch {
"ComputerName failed: $($_.Exception.Message)"
}
Invoke-Command -HostName web1 -UserName ops -ScriptBlock { "HostName worked: $(hostname)" }
$ pwsh computername.ps1
ComputerName failed: This parameter set requires WSMan, and no supported WSMan client library was found. WSMan is either not installed or unavailable for this system.
HostName worked: web1
-ComputerName selects WinRM (WS-Management), Windows’ own remoting protocol, and PowerShell on
Linux or macOS has no WSMan client, so the call fails before it connects. -HostName selects SSH
and works everywhere PowerShell 7 runs. The script block and the results are the same either way;
only the transport and the login differ.
One Block, Many Hosts
# fanout.ps1 - one script block, two hosts, results tagged with where they came from
$result = Invoke-Command -HostName web1, web2 -UserName ops -ScriptBlock {
[pscustomobject]@{
User = whoami
Home = $HOME
Pwsh = $PSVersionTable.PSVersion.ToString()
}
}
$result | Sort-Object PSComputerName |
Format-Table PSComputerName, User, Home, Pwsh
$ pwsh fanout.ps1
PSComputerName User Home Pwsh
-------------- ---- ---- ----
web1 ops /home/ops 7.6.7
web2 ops /home/ops 7.6.7
-HostName takes a list, and the block runs on every host at once. Each result comes back with a
PSComputerName property added, so you can tell which machine it came from. The results arrive
in whatever order the hosts finish, which is why the script sorts them before printing.
The Empty-Variable Bug, and $using:
# using.ps1 - a local variable does not travel to the remote side on its own
$path = '/etc/hostname'
$target = @{ HostName = 'web1'; UserName = 'ops' }
Invoke-Command @target { "plain: [$path]" }
Invoke-Command @target { "using: [$using:path] -> $(Get-Content $using:path)" }
Invoke-Command @target { param($p) "args: [$p] -> $(Get-Content $p)" } -ArgumentList $path
$ pwsh using.ps1
plain: []
using: [/etc/hostname] -> web1
args: [/etc/hostname] -> web1
plain: printed an empty path with no error. The block ran in a new PowerShell on web1 that has
never seen your $path, so $path there is a different, unset variable. $using:path sends the
local value along with the command. A param() block with -ArgumentList does the same, the way
you’d pass arguments to a function. Use $using: for a value or two, and param() when the block
is long enough to read like a function.
Running a Local Script File Remotely
report.ps1 is an ordinary script that knows nothing about remoting:
# report.ps1 - a plain local script; nothing in it knows about remoting
param([string]$Folder = '/etc')
$files = Get-ChildItem $Folder -File
[pscustomobject]@{
Folder = $Folder
Files = $files.Count
Largest = ($files | Sort-Object Length -Descending | Select-Object -First 1).Name
}
# filepath.ps1 - send a local script file to run on both hosts
Invoke-Command -HostName web1, web2 -UserName ops -FilePath ./report.ps1 -ArgumentList /etc/ssh |
Sort-Object PSComputerName |
Format-Table PSComputerName, Folder, Files, Largest
$ pwsh filepath.ps1
PSComputerName Folder Files Largest
-------------- ------ ----- -------
web1 /etc/ssh 9 moduli
web2 /etc/ssh 9 moduli
-FilePath reads the file on the client and sends its contents as the script block, so
report.ps1 never has to exist on web1 or web2. -ArgumentList feeds its param() block.
This is how one script in a repository runs against a whole fleet.
Each Call Starts from Scratch
# nosession.ps1 - each Invoke-Command -HostName call starts a fresh remote pwsh
$target = @{ HostName = 'web1'; UserName = 'ops' }
Invoke-Command @target { $count = 1 }
Invoke-Command @target { $count++; "count = [$count]" }
$pids = 1..2 | ForEach-Object { Invoke-Command @target { $PID } }
"same remote PID both times: $($pids[0] -eq $pids[1])"
$ pwsh nosession.ps1
count = [1]
same remote PID both times: False
The first call set $count = 1, but the second call printed 1, not 2. Each
Invoke-Command -HostName starts a fresh remote PowerShell and closes it afterwards, so $count
was unset and $count++ took it from nothing to 1. The process IDs differ for the same reason.
Sessions Keep State
# session.ps1 - a PSSession keeps one remote pwsh alive between calls
$s = New-PSSession -HostName web1 -UserName ops
Invoke-Command -Session $s { $count = 1 }
Invoke-Command -Session $s { $count++; "count = [$count]" }
$pids = 1..2 | ForEach-Object { Invoke-Command -Session $s { $PID } }
"same remote PID both times: $($pids[0] -eq $pids[1])"
Get-PSSession | Format-Table ComputerName, Transport, State
Remove-PSSession $s
"open sessions now: $(@(Get-PSSession).Count)"
$ pwsh session.ps1
count = [2]
same remote PID both times: True
ComputerName Transport State
------------ --------- -----
web1 SSH Opened
open sessions now: 0
New-PSSession opens one remote PowerShell and keeps it running, so $count survives between
calls and both calls report the same process. You also pay the SSH handshake once instead of on
every call. Get-PSSession lists the open sessions, and Remove-PSSession closes them; a session
you forget stays open until the PowerShell process that opened it exits, so a script you run
with & from an open pwsh prompt leaves its sessions behind.
An Interactive Remote Prompt
Enter-PSSession gives you a prompt on the remote machine, like ssh, but with PowerShell on
both ends. Start pwsh -NoLogo, which skips the version banner, and type these lines:
$ pwsh -NoLogo
PS /lab> Enter-PSSession -HostName web1 -UserName ops
[ops@web1]: PS /home/ops> hostname
web1
[ops@web1]: PS /home/ops> $PWD.Path
/home/ops
[ops@web1]: PS /home/ops> Get-Process -Id $PID | Select-Object -ExpandProperty ProcessName
pwsh
[ops@web1]: PS /home/ops> exit
PS /lab> hostname
client
PS /lab>
The prompt’s [ops@web1]: prefix shows where your commands now run, and the session starts in the
remote user’s home directory. exit brings you back to the client. Enter-PSSession is for
exploring by hand at a prompt; in a script, use Invoke-Command or a session instead.
Results Come Back Without Their Methods
# deserialize.ps1 - what comes back from a remote call is a snapshot, not the object
$local = Get-Item /etc/hostname
$remote = Invoke-Command -HostName web1 -UserName ops { Get-Item /etc/hostname }
"local: $($local.pstypenames[0])"
"remote: $($remote.pstypenames[0])"
"local methods: $(@($local | Get-Member -MemberType Method).Count)"
"remote methods: $((($remote | Get-Member -MemberType Method).Name) -join ', ')"
"remote Length still there: $($remote.Length) bytes"
$local.OpenText().ReadLine()
$remote.OpenText().ReadLine()
$ pwsh deserialize.ps1
local: System.IO.FileInfo
remote: Deserialized.System.IO.FileInfo
local methods: 23
remote methods: GetType, ToString
remote Length still there: 5 bytes
client
InvalidOperation: /lab/deserialize.ps1:12
Line |
12 | $remote.OpenText().ReadLine()
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
| Method invocation failed because [Deserialized.System.IO.FileInfo] does
| not contain a method named 'OpenText'.
The local Get-Item returns a live System.IO.FileInfo with 23 methods. The remote one returns a
Deserialized.System.IO.FileInfo with only GetType and ToString. Results travel as XML, so
what arrives is a snapshot of the properties, like Length above, and nothing that needs the
real object on web1. Calling a method on it fails. Run the method in the remote block and send
back the plain value instead.
Practice
- Run
where.ps1’s block againstweb2without-UserName, by putting the user in the host name.
Solution
# p1.ps1 - user@host in -HostName replaces -UserName
Invoke-Command -HostName ops@web2 -ScriptBlock {
"running on: $(hostname) as $(whoami)"
}
$ pwsh p1.ps1
running on: web2 as ops
-HostName accepts user@host, the same form as ssh, so -UserName isn’t needed.
- Send two local values to both hosts with
$using:: a file path and a pattern, then print the matching line from each host’s copy of that file.
Solution
# p2.ps1 - two local values sent to both hosts with $using:
$file = '/etc/ssh/sshd_config'
$pattern = '^Subsystem\s+powershell'
Invoke-Command -HostName web1, web2 -UserName ops -ScriptBlock {
$line = Select-String -Path $using:file -Pattern $using:pattern
"$(hostname): $($line.Line)"
} | Sort-Object
$ pwsh p2.ps1
web1: Subsystem powershell /usr/bin/pwsh -sshs -NoLogo
web2: Subsystem powershell /usr/bin/pwsh -sshs -NoLogo
Each $using: reference is sent along with the block, so any number of local values can travel.
The line came from each host’s own sshd_config, the subsystem line from the Dockerfile.
- Open one session to each of
web1andweb2, store the host name in a variable in each, and read it back in a second call.
Solution
# p3.ps1 - one session per host; each keeps its own state
$sessions = New-PSSession -HostName web1, web2 -UserName ops
Invoke-Command -Session $sessions { $started = hostname }
Invoke-Command -Session $sessions { "$(hostname) remembers: $started" } | Sort-Object
Remove-PSSession $sessions
$ pwsh p3.ps1
web1 remembers: web1
web2 remembers: web2
New-PSSession with two host names returns two sessions, and -Session runs the block in every
one. Each remote process keeps its own $started.
- Fix the last line of
deserialize.ps1: get the first line of/etc/hostnameonweb1by callingOpenText()where it works.
Solution
# p4.ps1 - call the method where the live object is, and return plain data
$firstLine = Invoke-Command -HostName web1 -UserName ops {
$reader = (Get-Item /etc/hostname).OpenText()
try { $reader.ReadLine() } finally { $reader.Close() }
}
"first line on web1: $firstLine"
"returned type: $($firstLine.GetType().FullName)"
$ pwsh p4.ps1
first line on web1: web1
returned type: System.String
The method runs on web1, where the FileInfo is live, and only the string comes back. A string
survives the trip as a real System.String, because simple types are rebuilt exactly; only
complex objects become Deserialized.* snapshots.
Next: running remote commands as background jobs, handling a host that’s down, copying files through a session, and the limits of remoting over SSH.