Skip to main content
PowerShell · lesson 11 of 12
PowerShell intermediate 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

powershell remoting ssh invoke-command pssession

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

  1. Run where.ps1’s block against web2 without -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.

  1. 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.

  1. Open one session to each of web1 and web2, 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.

  1. Fix the last line of deserialize.ps1: get the first line of /etc/hostname on web1 by calling OpenText() 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.

Frequently Asked Questions

How do I run PowerShell commands on a remote Linux machine?
Use PowerShell remoting over SSH. The target needs PowerShell 7 and an sshd_config line that registers it as a subsystem: Subsystem powershell /usr/bin/pwsh -sshs -NoLogo. Then Invoke-Command -HostName server -UserName user -ScriptBlock { ... } runs the block there and returns the results as objects, authenticated with your normal SSH keys.
What's the difference between -HostName and -ComputerName in Invoke-Command?
-HostName connects over SSH and works on Windows, Linux and macOS with PowerShell 7. -ComputerName connects over WinRM (WS-Management), Windows' own remoting protocol, and on Linux or macOS it fails because no WSMan client library is available. Both run the same script block; only the transport and the authentication differ.
Why is my variable empty inside Invoke-Command?
The script block runs in a separate PowerShell process on the remote machine, which has never seen your local variables, so a plain $path there is a different, unset variable. Write $using:path to send the local value with the command, or declare param($path) in the block and pass the value with -ArgumentList.
When should I use New-PSSession instead of plain Invoke-Command?
Every Invoke-Command -HostName call opens a fresh remote PowerShell and closes it afterwards, so nothing set in one call survives to the next and each call pays the SSH connection cost again. New-PSSession keeps one remote process open: pass it with -Session to run several commands that share variables, loaded modules and the current directory, then close it with Remove-PSSession.
Why can't I call methods on objects returned from a remote command?
Results cross the network serialized as XML and come back as property snapshots typed Deserialized.<original type>. The properties and their values are all there, but the methods are not, because they belong to a live .NET object that only exists on the remote machine. Call the method inside the remote script block and return the plain result instead.