A client with 212 Windows servers across four regions asked us to push a single time zone correction before a quarterly close. Their previous vendor had quoted two engineers for three nights. We finished the rollout in 41 minutes using PowerShell Invoke-Command, and the postmortem became the template for every multi-machine automation playbook we have built since. This walkthrough retraces that engagement, the mistakes we made along the way, and the patterns that survived into production.
Why Invoke-Command Beats Logging Into 200 Boxes
The trade-off in fleet automation is always the same: scripted speed versus the safety of touching one machine at a time. Invoke-Command sits in the middle because it parallelizes by default while still returning per-host results you can audit. Under the hood it rides WS-Management, the same WinRM transport that PowerShell sessions use, so the moving parts are already on every modern Windows Server.
The simplest form is honestly the one most teams never outgrow:
Invoke-Command -ComputerName Server01, Server02 -ScriptBlock { Get-Service }
That one line is the entire value proposition. Hand it a list, hand it a script block, and PowerShell fans the work out. The defaults matter though, and they bit us early.
The First Mistake: Trusting the Default Throttle
On day one of the engagement, we ran a hotfix inventory against the full server list. Invoke-Command throttles to 32 concurrent connections by default, which sounds generous until you realize the client’s domain controllers were also fielding authentication for 6,000 endpoints during business hours. We saw Kerberos pre-auth latency spike on two DCs within 90 seconds. The script finished, but the on-call team noticed.
The fix is to right-size the fan-out the same way you’d right-size a worker pool in any distributed system. ThrottleLimit is the knob:
Invoke-Command -ComputerName $servers -ScriptBlock { Get-Process } -ThrottleLimit 8
We settled on eight concurrent sessions during business hours and forty after 8pm. The cost envelope of an automation run is not just runtime, it includes the blast radius if something goes wrong, and a smaller throttle gives you a smaller blast radius. The Microsoft Learn reference for Invoke-Command documents the parameter in detail, including its interaction with AsJob.
Pulling the Target List From Active Directory
Hardcoding 212 server names into a script is the kind of thing that looks fine on Tuesday and breaks on Thursday when someone decommissions a box. We pulled the list dynamically from a group that the client already maintained for patch waves:
$computerNames = Get-ADGroupMember PatchWave-Prod | Get-ADComputer -Properties DnsHostName | Select-Object -ExpandProperty DnsHostName
Then the actual fan-out becomes trivial:
Invoke-Command -ComputerName $computerNames -ScriptBlock { Get-Service dnscache }
This pattern matters because it makes the automation a function of the source of truth rather than a snapshot of it. If you have done a proper IT assessment and your AD groups already reflect operational reality, your scripts inherit that hygiene for free.
The Bulk Update That Actually Shipped
The original ask was a time zone change. The script we ran, after two dry runs against a five-server canary, looked like this:
$servers = Get-ADGroupMember PatchWave-Prod | Get-ADComputer | Select-Object -ExpandProperty DnsHostName
Invoke-Command -ComputerName $servers -ScriptBlock {
try {
tzutil /s "Pacific Standard Time"
Write-Output "Updated: $env:COMPUTERNAME"
} catch {
Write-Output "Failed on $env:COMPUTERNAME: $_"
}
} -ThrottleLimit 10 -ErrorAction Continue
Two things in that block earned their keep. The try/catch ensured that one failing host did not poison the entire run, and ErrorAction Continue kept Invoke-Command from short-circuiting on the first transport hiccup. Out of 212 servers, six failed, all because their WinRM listeners had been disabled by a misapplied GPO eighteen months earlier. We fixed those by hand and re-ran against the failure list.
Persistent Sessions When You Need More Than One Command
One-to-many remoting is great for fire-and-forget commands, but when you need three or four related operations sharing state on the remote host, build a PSSession and reuse it. The session amortizes the connection cost and lets you treat the remote machine like a long-lived target rather than a stateless endpoint. We use this pattern heavily for our managed VPS server fleet, where a single maintenance window might run inventory, patch, reboot, and verify in sequence.
The Security Conversation Nobody Wants to Have
Here is the opinionated part: if your Invoke-Command surface is not constrained, you have built a lateral movement highway with your own hands. WinRM is a legitimate admin protocol and an attacker’s favorite, and the convenience that makes it useful to your ops team makes it equally useful to anyone who lands a credential. We have walked into environments where Domain Admins could remote into every server in the forest from any workstation, and that is not a configuration, that is a liability.
The mitigations are well documented. Use JEA (Just Enough Administration) to scope what remote callers can actually do, restrict TrustedHosts properly, and align your WinRM configuration with the CIS Benchmarks for Windows Server. We pair every remoting rollout with the same hardening pass we describe in our piece on creating enforced authentication policies in Active Directory, because remoting without authentication policy is a partial control at best.
One Caveat Worth Naming
Invoke-Command is not a deployment system. It is excellent for ad-hoc fleet operations and small batched changes, but if you find yourself building idempotency, retry queues, and state tracking on top of it, you are reinventing configuration management. At that point the honest move is to graduate to DSC, Ansible, or Intune for whatever workload makes sense. We hit that line on the same client when they asked us to manage software installs, and that became a separate conversation captured in our notes on packaging non-MSI apps for Intune Win32 deployment.
What to Take Into Your Next Run
Start with a five-host canary, pull your target list from a source of truth rather than a text file, throttle conservatively during business hours, and wrap your script block in try/catch with ErrorAction Continue so one bad host does not kill the run. Capture output to a transcript and diff the success list against your target list before you call the job done. If you want a second set of eyes on your remoting posture or your broader automation pipeline, get in touch with our team and we will walk it with you. Forty-one minutes for 212 servers is not magic, it is just Invoke-Command used with the same discipline you would apply to any production change.


