<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0"
     xmlns:atom="http://www.w3.org/2005/Atom"
     xmlns:content="http://purl.org/rss/1.0/modules/content/"
     xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>PowerShell · Loosely Decoupled</title>
    <link>https://looselydecoupled.com/topics/powershell/</link>
    <description>Parameter design, error and exit-code handling, the AWS.Tools modules in production, and the cmdlets that only work on Windows.</description>
    <language>en-us</language>
    
    <copyright>© 2026 Emmanuel Tsouris</copyright>
    <lastBuildDate>Sun, 04 Oct 2026 00:00:00 +0000</lastBuildDate>
    <atom:link href="https://looselydecoupled.com/topics/powershell/index.xml" rel="self" type="application/rss+xml" />
    
    <item>
      <title>Move a 7 Days to Die Dedicated Server to a New Windows Server by Restoring Its Last Backup</title>
      <link>https://looselydecoupled.com/move-7-days-to-die-dedicated-server-to-new-windows-server/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/move-7-days-to-die-dedicated-server-to-new-windows-server/</guid>
      <pubDate>Sun, 04 Oct 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Windows Server</category>
      <category>PowerShell</category>
      <category>Automation</category>
      
      <description>A 7 Days to Die dedicated server running the old world on a new Windows Server, its build and save files checked against the old one before players move.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Move the world in this order: stop the old server, take one last backup of it, build the new server, restore that backup onto it, check the world, and only then give players the new address. Stopping first means nothing can be played on the old server after the backup that the new one doesn&rsquo;t have. The world and the server&rsquo;s settings are the only things the new machine can&rsquo;t get from Steam, and the backup holds both. That holds for a Navezgane world. A random-gen world also keeps its terrain in <code>GeneratedWorlds</code>, which this method doesn&rsquo;t move, and mods aren&rsquo;t archived either.</p>
<p>On the old server, in an elevated PowerShell with <code>Stop-GameServer</code> from the part on <a href="https://looselydecoupled.com/start-7-days-to-die-server-at-boot-and-stop-it-cleanly/">starting and stopping the server</a> defined:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$telnet</span> <span class="p">=</span> <span class="nb">Read-Host</span> <span class="s1">&#39;Telnet password (Enter if none)&#39;</span> <span class="n">-AsSecureString</span>
</span></span><span class="line"><span class="cl"><span class="nb">Stop-GameServer</span> <span class="n">-Password</span> <span class="nv">$telnet</span>
</span></span><span class="line"><span class="cl"><span class="nb">Start-ScheduledTask</span> <span class="n">-TaskName</span> <span class="n">7DaysToDieServerS3Backup</span></span></span></code></pre></div></div>
  </div>
</div><p>Step 1 waits for the task, checks that the backup started after the stop, and turns off the old server&rsquo;s tasks. On the new server, install the game and its prerequisites with the earlier parts. Then download the newest archive as SYSTEM, copy <code>myserver.xml</code> out of it, and run <code>Restore-GameWorld</code> from the part on <a href="https://looselydecoupled.com/restore-7-days-to-die-world-without-merging/">restoring without merging</a>. The game-specific parts are the save folder, the config file and the log lines that show the world loaded.</p>
<p>The step most likely to stop you is the download. The backup task wrote the archive with the credentials in SYSTEM&rsquo;s profile, so the download has to run as SYSTEM too, and the backup user&rsquo;s policy has to allow <code>s3:GetObject</code>. The CloudFormation template in the <a href="https://looselydecoupled.com/backup-7-days-to-die-server-s3/">backup article</a> creates a user whose policy does.</p>

</div>

<p>The backup article started from a goal: a server I can replace without a second thought. This part is the replacing: the migration of a dedicated server from one Windows machine to another, world and settings included. My server runs on Windows Server 2019, and I want it on 2025. I rehearsed the move on two Windows Server 2025 machines. The world was a new Navezgane that nobody had played, so what follows is the order and the commands, not a move with players in it.</p>
<!-- style-check: procedure -->
<h2 id="1-stop-the-old-server-and-take-the-last-backup">1. Stop the old server and take the last backup<a class="heading-anchor" href="https://looselydecoupled.com/move-7-days-to-die-dedicated-server-to-new-windows-server/#1-stop-the-old-server-and-take-the-last-backup"
     aria-label="Link to section: 1. Stop the old server and take the last backup" data-pagefind-ignore></a>
</h2>
<p>Do these on the old server, in this order. Stopping the server first closes the window in which a player could change the world after the backup. The backup then copies a world that nothing is writing to, which the backup article lists under &ldquo;Saves are captured mid-write&rdquo; as the case it can&rsquo;t promise.</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$task</span> <span class="p">=</span> <span class="s1">&#39;7DaysToDieServerS3Backup&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Disable-ScheduledTask</span> <span class="n">-TaskName</span> <span class="nv">$task</span> <span class="p">|</span> <span class="nb">Out-Null</span>
</span></span><span class="line"><span class="cl"><span class="k">while</span> <span class="p">((</span><span class="nb">Get-ScheduledTask</span> <span class="n">-TaskName</span> <span class="nv">$task</span><span class="p">).</span><span class="py">State</span> <span class="o">-eq</span> <span class="s1">&#39;Running&#39;</span><span class="p">)</span> <span class="p">{</span> <span class="nb">Start-Sleep</span> <span class="n">-Seconds</span> <span class="mf">5</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">$telnet</span> <span class="p">=</span> <span class="nb">Read-Host</span> <span class="s1">&#39;Telnet password (Enter if none)&#39;</span> <span class="n">-AsSecureString</span>
</span></span><span class="line"><span class="cl"><span class="nb">Stop-GameServer</span> <span class="n">-Password</span> <span class="nv">$telnet</span>
</span></span><span class="line"><span class="cl"><span class="nv">$stopped</span> <span class="p">=</span> <span class="nb">Get-Date</span>
</span></span><span class="line"><span class="cl"><span class="nv">$stopped</span> <span class="p">=</span> <span class="nv">$stopped</span><span class="p">.</span><span class="py">AddTicks</span><span class="p">(-(</span><span class="nv">$stopped</span><span class="p">.</span><span class="py">Ticks</span> <span class="p">%</span> <span class="p">[</span><span class="no">TimeSpan</span><span class="p">]::</span><span class="n">TicksPerSecond</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="nb">Enable-ScheduledTask</span> <span class="n">-TaskName</span> <span class="nv">$task</span> <span class="p">|</span> <span class="nb">Out-Null</span>
</span></span><span class="line"><span class="cl"><span class="nb">Start-ScheduledTask</span> <span class="n">-TaskName</span> <span class="nv">$task</span>
</span></span><span class="line"><span class="cl"><span class="nb">Start-Sleep</span> <span class="n">-Seconds</span> <span class="mf">5</span>
</span></span><span class="line"><span class="cl"><span class="k">while</span> <span class="p">((</span><span class="nb">Get-ScheduledTask</span> <span class="n">-TaskName</span> <span class="nv">$task</span><span class="p">).</span><span class="py">State</span> <span class="o">-eq</span> <span class="s1">&#39;Running&#39;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Start-Sleep</span> <span class="n">-Seconds</span> <span class="mf">1</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">$info</span> <span class="p">=</span> <span class="nb">Get-ScheduledTaskInfo</span> <span class="n">-TaskName</span> <span class="nv">$task</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nv">$info</span><span class="p">.</span><span class="py">LastRunTime</span> <span class="o">-lt</span> <span class="nv">$stopped</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">throw</span> <span class="s1">&#39;The backup that finished started before the server stopped. Run it again.&#39;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">$info</span><span class="p">.</span><span class="py">LastTaskResult</span>
</span></span><span class="line"><span class="cl"><span class="nb">Disable-ScheduledTask</span> <span class="n">-TaskName</span> <span class="n">7DaysToDieServer</span>
</span></span><span class="line"><span class="cl"><span class="nb">Disable-ScheduledTask</span> <span class="n">-TaskName</span> <span class="nv">$task</span></span></span></code></pre></div></div>
  </div>
</div><p>The password is read as a secure string, so it stays out of the console history. With no telnet password set, press Enter and nothing is sent. The last two lines turn off the old server&rsquo;s tasks before anyone is told the new address. <code>7DaysToDieServer</code> starts the server at every boot, so a reboot would otherwise bring the old world back where a player could still reach it. The backup task would write an archive of the old world with a newer name than any the new server makes. A restore that takes the newest archive would then pick it. Both tasks read <code>Disabled</code> afterwards.</p>
<p>Disabling the backup task first keeps its daily trigger from starting a run while the server stops, and the first loop waits out a run that had already begun, because starting a running task does nothing and a backup that began before the stop could hold a world that was still being written. The task is enabled again only to take this last backup. Task Scheduler records the last run time to the second, so <code>$stopped</code> is cut to the second too, and the check fails only for a run that started before the stop. If the daily time falls in this window, run the step again.</p>
<p>The result must be <code>0</code>. The script reads the object back from S3 and compares its size before it exits <code>0</code>, so a <code>0</code> means the archive is in the bucket. The newest log under <code>backup-logs</code> names it:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Upload successful: 1806071 bytes, STANDARD_IA</span></span></code></pre></div></div>
  </div>
</div><p>The backup took seven seconds here and the archive was 1.8 MB, because the world was new. Note the archive&rsquo;s name, which is a timestamp. On a world people have played it is larger.</p>
<p>This step assumes the backup task from the backup article is already running on the old server, with its script archiving <code>myserver.xml</code>, as the configuration part says to change it. If the archive holds the shipped <code>serverconfig.xml</code> instead, your settings exist only on the old server, and step 4 has a way to copy them.</p>
<h2 id="2-build-the-new-server">2. Build the new server<a class="heading-anchor" href="https://looselydecoupled.com/move-7-days-to-die-dedicated-server-to-new-windows-server/#2-build-the-new-server"
     aria-label="Link to section: 2. Build the new server" data-pagefind-ignore></a>
</h2>
<p>On the new server, in an elevated PowerShell, follow the earlier parts for what each owns:</p>
<ol>
<li><a href="https://looselydecoupled.com/install-steamcmd-steam-dedicated-server-windows/">Install SteamCMD and the server</a>. It took about eight minutes here, which is most of the time the move takes.</li>
<li>Install the Visual C++ runtime from <a href="https://looselydecoupled.com/configure-7-days-to-die-server-and-open-ports/">the configuration part</a>, step 1, and open the firewall ports from its step 5. Skip its steps 2 to 4, because the settings come from the archive.</li>
<li>Install AWS Tools for all users, from step 1 of the <a href="https://looselydecoupled.com/backup-7-days-to-die-server-s3/">backup article</a>. The runtime and the AWS modules together took 25 seconds.</li>
<li>Write the backup user&rsquo;s access key into SYSTEM&rsquo;s profile, as step 2 of the backup article does, and the bucket name into <code>C:\Scripts\backup-config.txt</code>, as its step 3 does. The new server uses the same key as the old one.</li>
</ol>
<p>This part moves a world on one of the maps the game ships, such as Navezgane. It doesn&rsquo;t move a random-gen world: that one also needs its <code>GeneratedWorlds</code> folder, and <code>Restore-GameWorld</code> refuses an archive that holds one, so steps 3 and 4 stop there. For a random-gen world, copy <code>Saves</code> and <code>GeneratedWorlds</code> across by hand with both servers stopped instead; I didn&rsquo;t test that. Don&rsquo;t start the new server yet.</p>
<h2 id="3-download-the-latest-archive-as-system">3. Download the latest archive as SYSTEM<a class="heading-anchor" href="https://looselydecoupled.com/move-7-days-to-die-dedicated-server-to-new-windows-server/#3-download-the-latest-archive-as-system"
     aria-label="Link to section: 3. Download the latest archive as SYSTEM" data-pagefind-ignore></a>
</h2>
<p>The key is in SYSTEM&rsquo;s profile, so the download runs as SYSTEM, the way the backup does. A scheduled task is the way the backup article runs something as SYSTEM. None of the earlier parts downloads an archive this way, so this step is new. Save this as <code>C:\Scripts\Get-LatestBackup.ps1</code>:</p>
<div class="code-block" data-copyable data-copy-label="Get-LatestBackup.ps1">
  <div class="code-block__head"><span class="code-block__lang code-block__file">Get-LatestBackup.ps1</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$ErrorActionPreference</span> <span class="p">=</span> <span class="s1">&#39;Stop&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Import-Module</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="py">S3</span>
</span></span><span class="line"><span class="cl"><span class="nv">$bucket</span> <span class="p">=</span> <span class="p">(</span><span class="nb">Get-Content</span> <span class="s1">&#39;C:\Scripts\backup-config.txt&#39;</span> <span class="n">-First</span> <span class="mf">1</span><span class="p">).</span><span class="py">Trim</span><span class="p">()</span>
</span></span><span class="line"><span class="cl"><span class="nv">$prefix</span> <span class="p">=</span> <span class="s1">&#39;7days-backups/&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$latest</span> <span class="p">=</span> <span class="nb">Get-S3Object</span> <span class="n">-BucketName</span> <span class="nv">$bucket</span> <span class="n">-Prefix</span> <span class="nv">$prefix</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Where-Object</span> <span class="p">{</span> <span class="nv">$_</span><span class="p">.</span><span class="py">Key</span> <span class="o">-match</span> <span class="s1">&#39;7dtd-backup-[^/]+\.zip$&#39;</span> <span class="p">}</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Sort-Object</span> <span class="n">LastModified</span> <span class="n">-Descending</span> <span class="p">|</span> <span class="nb">Select-Object</span> <span class="n">-First</span> <span class="mf">1</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">-not</span> <span class="nv">$latest</span><span class="p">)</span> <span class="p">{</span> <span class="k">throw</span> <span class="s2">&#34;No archive under </span><span class="nv">$prefix</span><span class="s2"> in </span><span class="nv">$bucket</span><span class="s2">.&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="nb">New-Item</span> <span class="n">-ItemType</span> <span class="n">Directory</span> <span class="n">-Path</span> <span class="n">C:</span><span class="p">\</span><span class="n">backups</span> <span class="n">-Force</span> <span class="p">|</span> <span class="nb">Out-Null</span>
</span></span><span class="line"><span class="cl"><span class="nv">$zip</span> <span class="p">=</span> <span class="nb">Join-Path</span> <span class="n">C:</span><span class="p">\</span><span class="n">backups</span> <span class="p">(</span><span class="nb">Split-Path</span> <span class="nv">$latest</span><span class="p">.</span><span class="py">Key</span> <span class="n">-Leaf</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="nb">Read-S3Object</span> <span class="n">-BucketName</span> <span class="nv">$bucket</span> <span class="n">-Key</span> <span class="nv">$latest</span><span class="p">.</span><span class="py">Key</span> <span class="o">-File</span> <span class="nv">$zip</span> <span class="p">|</span> <span class="nb">Out-Null</span>
</span></span><span class="line"><span class="cl"><span class="nv">$size</span> <span class="p">=</span> <span class="p">(</span><span class="nb">Get-Item</span> <span class="nv">$zip</span><span class="p">).</span><span class="py">Length</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nv">$size</span> <span class="o">-ne</span> <span class="nv">$latest</span><span class="p">.</span><span class="n">Size</span><span class="p">)</span> <span class="p">{</span> <span class="k">throw</span> <span class="s2">&#34;Size mismatch: S3 </span><span class="p">$(</span><span class="nv">$latest</span><span class="p">.</span><span class="n">Size</span><span class="p">)</span><span class="s2">, local </span><span class="nv">$size</span><span class="s2">.&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="s2">&#34;Downloaded </span><span class="nv">$zip</span><span class="s2"> (</span><span class="nv">$size</span><span class="s2"> bytes)&#34;</span></span></span></code></pre></div></div>
  </div>
</div><p>It picks the archive that S3 says is newest, downloads it to <code>C:\backups</code>, and stops if the local size differs from the object&rsquo;s. Then run it once as SYSTEM, wait for it and remove the task:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$command</span> <span class="p">=</span> <span class="s1">&#39;&amp; C:\Scripts\Get-LatestBackup.ps1 *&gt; C:\Scripts\get-latest.log&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$action</span> <span class="p">=</span> <span class="nb">New-ScheduledTaskAction</span> <span class="n">-Execute</span> <span class="s1">&#39;powershell.exe&#39;</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-Argument</span> <span class="s2">&#34;-NoProfile -ExecutionPolicy Bypass -Command </span><span class="se">`&#34;</span><span class="nv">$command</span><span class="se">`&#34;</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Register-ScheduledTask</span> <span class="n">-TaskName</span> <span class="s1">&#39;GetLatestBackup&#39;</span> <span class="n">-Action</span> <span class="nv">$action</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-User</span> <span class="s1">&#39;SYSTEM&#39;</span> <span class="n">-RunLevel</span> <span class="n">Highest</span> <span class="p">|</span> <span class="nb">Out-Null</span>
</span></span><span class="line"><span class="cl"><span class="nb">Start-ScheduledTask</span> <span class="n">-TaskName</span> <span class="s1">&#39;GetLatestBackup&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Start-Sleep</span> <span class="n">-Seconds</span> <span class="mf">3</span>
</span></span><span class="line"><span class="cl"><span class="k">while</span> <span class="p">((</span><span class="nb">Get-ScheduledTask</span> <span class="n">-TaskName</span> <span class="s1">&#39;GetLatestBackup&#39;</span><span class="p">).</span><span class="py">State</span> <span class="o">-eq</span> <span class="s1">&#39;Running&#39;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Start-Sleep</span> <span class="n">-Seconds</span> <span class="mf">1</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">(</span><span class="nb">Get-ScheduledTaskInfo</span> <span class="n">-TaskName</span> <span class="s1">&#39;GetLatestBackup&#39;</span><span class="p">).</span><span class="py">LastTaskResult</span>
</span></span><span class="line"><span class="cl"><span class="nb">Get-Content</span> <span class="n">C:</span><span class="p">\</span><span class="n">Scripts</span><span class="p">\</span><span class="nb">get-latest</span><span class="p">.</span><span class="py">log</span>
</span></span><span class="line"><span class="cl"><span class="nb">Unregister-ScheduledTask</span> <span class="n">-TaskName</span> <span class="s1">&#39;GetLatestBackup&#39;</span> <span class="n">-Confirm:</span><span class="vm">$false</span></span></span></code></pre></div></div>
  </div>
</div><p>The result is <code>0</code> and the log reads:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Downloaded C:\backups\7dtd-backup-20261005-063440.zip (1806071 bytes)</span></span></code></pre></div></div>
  </div>
</div><p>The name is the one step 1 noted. The download took five seconds.</p>
<h2 id="4-copy-the-settings-and-restore-the-world-saves">4. Copy the settings and restore the world saves<a class="heading-anchor" href="https://looselydecoupled.com/move-7-days-to-die-dedicated-server-to-new-windows-server/#4-copy-the-settings-and-restore-the-world-saves"
     aria-label="Link to section: 4. Copy the settings and restore the world saves" data-pagefind-ignore></a>
</h2>
<p>The restore function doesn&rsquo;t touch settings: its archive&rsquo;s <code>serverconfig.xml</code> or <code>myserver.xml</code> stays in the zip. A move needs them, so copy the settings file out of the archive first:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$zip</span> <span class="p">=</span> <span class="p">(</span><span class="nb">Get-ChildItem</span> <span class="n">C:</span><span class="p">\</span><span class="n">backups</span><span class="p">\</span><span class="n">7dtd-backup</span><span class="p">-*.</span><span class="n">zip</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Sort-Object</span> <span class="n">LastWriteTime</span> <span class="n">-Descending</span> <span class="p">|</span> <span class="nb">Select-Object</span> <span class="n">-First</span> <span class="mf">1</span><span class="p">).</span><span class="py">FullName</span>
</span></span><span class="line"><span class="cl"><span class="nv">$unpacked</span> <span class="p">=</span> <span class="nb">Join-Path</span> <span class="nv">$env:TEMP</span> <span class="s1">&#39;archive-config&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Expand-Archive</span> <span class="n">-LiteralPath</span> <span class="nv">$zip</span> <span class="n">-DestinationPath</span> <span class="nv">$unpacked</span> <span class="n">-Force</span>
</span></span><span class="line"><span class="cl"><span class="nb">Copy-Item</span> <span class="p">(</span><span class="nb">Join-Path</span> <span class="nv">$unpacked</span> <span class="s1">&#39;myserver.xml&#39;</span><span class="p">)</span> <span class="n">C:</span><span class="p">\</span><span class="n">7DaysToDieServer</span><span class="p">\</span><span class="n">myserver</span><span class="p">.</span><span class="py">xml</span>
</span></span><span class="line"><span class="cl"><span class="nb">Remove-Item</span> <span class="nv">$unpacked</span> <span class="n">-Recurse</span></span></span></code></pre></div></div>
  </div>
</div><p>The copy from the archive had the same SHA-256 as the old server&rsquo;s file, so the name, password, slots and <code>UserDataFolder</code> came across with it. <code>UserDataFolder</code> puts <code>Saves</code> beside the executable, and the restore function looks for it there. If the archive holds <code>serverconfig.xml</code> and not <code>myserver.xml</code>, it has the shipped settings: copy your <code>myserver.xml</code> from the old server by hand.</p>
<p>Then define <code>Restore-GameWorld</code> from the <a href="https://looselydecoupled.com/restore-7-days-to-die-world-without-merging/">restore part</a> and run it on the archive:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Restore-GameWorld</span> <span class="n">-ArchivePath</span> <span class="nv">$zip</span></span></span></code></pre></div></div>
  </div>
</div><div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">Restore-GameWorld</span> <span class="console__token">-ArchivePath</span> <span class="console__token">$zip</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Restored</span> <span class="console__token">Saves</span> <span class="console__token">from</span> <span class="console__token">7dtd-backup-20261005-063440.zip.</span></span></code></pre>
    </div>
  </div></div><p>There was no old <code>Saves</code> on the new server, so the function printed no line about one, and it started nothing because no server was running. It finished in under a second. It compared every restored file with the archive by name and size before it returned.</p>
<h2 id="5-start-the-server-and-check-the-world">5. Start the server and check the world<a class="heading-anchor" href="https://looselydecoupled.com/move-7-days-to-die-dedicated-server-to-new-windows-server/#5-start-the-server-and-check-the-world"
     aria-label="Link to section: 5. Start the server and check the world" data-pagefind-ignore></a>
</h2>
<p>Register the task from step 1 of the <a href="https://looselydecoupled.com/start-7-days-to-die-server-at-boot-and-stop-it-cleanly/">part on starting at boot</a> and start it with its step 3. The server was ready 54 seconds after the start here. Then read three things.</p>
<p>The log names the world and the game name from the settings you copied, and the world loading:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Select-String</span> <span class="n">-Path</span> <span class="n">C:</span><span class="p">\</span><span class="n">7DaysToDieServer</span><span class="p">\</span><span class="n">output_log</span><span class="p">.</span><span class="py">txt</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-Pattern</span> <span class="s1">&#39;GamePref.GameName =&#39;</span><span class="p">,</span> <span class="s1">&#39;GamePref.GameWorld =&#39;</span><span class="p">,</span> <span class="s1">&#39;createWorld&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;StartGame done&#39;</span></span></span></code></pre></div></div>
  </div>
</div><p>Each log line starts with a timestamp, which is left out here.</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">GamePref.GameName = MyGame
</span></span><span class="line"><span class="cl">GamePref.GameWorld = Navezgane
</span></span><span class="line"><span class="cl">INF createWorld: Navezgane, Navezgane (src: GameData, DeviceLocal), MyGame, GameModeSurvival
</span></span><span class="line"><span class="cl">INF createWorld() done
</span></span><span class="line"><span class="cl">INF StartGame done</span></span></code></pre></div></div>
  </div>
</div><p>The build matches the old server&rsquo;s, read on both machines:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Select-String</span> <span class="n">-Pattern</span> <span class="s1">&#39;&#34;buildid&#34;&#39;</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-Path</span> <span class="n">C:</span><span class="p">\</span><span class="n">7DaysToDieServer</span><span class="p">\</span><span class="n">steamapps</span><span class="p">\</span><span class="n">appmanifest_294420</span><span class="p">.</span><span class="n">acf</span></span></span></code></pre></div></div>
  </div>
</div><p>Both read <code>24994542</code>. If yours differ, Steam published a build between the two installs and the new server is the newer one. The <a href="https://looselydecoupled.com/update-7-days-to-die-server-only-on-new-build/">update part</a> covers how the server&rsquo;s build is compared with the one Steam publishes. I didn&rsquo;t test loading an older world on a newer build.</p>
<p>Then stop the new server with <code>Stop-GameServer -Password $telnet</code>, the function from step 4 of the <a href="https://looselydecoupled.com/start-7-days-to-die-server-at-boot-and-stop-it-cleanly/">start-at-boot part</a>, with <code>$telnet</code> read as in step 1. It exited with code <code>0</code> in four seconds, the task read <code>Ready</code> with a last result of <code>0</code>, and the log had <code>SaveAndCleanupWorld</code>. Run this on both machines, once the new server has stopped, and compare the two outputs:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$root</span> <span class="p">=</span> <span class="s1">&#39;C:\7DaysToDieServer\Saves&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Get-ChildItem</span> <span class="nv">$root</span> <span class="n">-Recurse</span> <span class="o">-File</span> <span class="p">|</span> <span class="nb">Where-Object</span> <span class="n">Extension</span> <span class="o">-ne</span> <span class="s1">&#39;.bak&#39;</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Sort-Object</span> <span class="n">FullName</span> <span class="p">|</span> <span class="nb">ForEach-Object</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="s1">&#39;{0} {1} {2}&#39;</span> <span class="o">-f</span> <span class="nv">$_</span><span class="p">.</span><span class="py">FullName</span><span class="p">.</span><span class="py">Substring</span><span class="p">(</span><span class="nv">$root</span><span class="p">.</span><span class="n">Length</span><span class="p">),</span> <span class="nv">$_</span><span class="p">.</span><span class="n">Length</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="p">(</span><span class="nb">Get-FileHash</span> <span class="nv">$_</span><span class="p">.</span><span class="n">FullName</span><span class="p">).</span><span class="py">Hash</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span> <span class="p">|</span> <span class="nb">Out-File</span> <span class="n">C:</span><span class="p">\</span><span class="nb">saves-manifest</span><span class="p">.</span><span class="py">txt</span> <span class="n">-Encoding</span> <span class="n">ascii</span></span></span></code></pre></div></div>
  </div>
</div><p>Copy the old server&rsquo;s file to the new one as <code>C:\old-manifest.txt</code> and run <code>Compare-Object (Get-Content C:\old-manifest.txt) (Get-Content C:\saves-manifest.txt)</code>. No output means the two lists are identical, and here they were: 61 files, size for size and hash for hash. To see it report a difference, I changed one byte of <code>sdcs_profiles.sdf</code> on the new server and ran the command again. It printed that file&rsquo;s line twice, once for each hash, with <code>=&gt;</code> on the new server&rsquo;s line and <code>&lt;=</code> on the old one&rsquo;s, and printed nothing again once I put the byte back. The server had started and stopped on the new machine, and the old one had not run since its backup. The <code>.bak</code> files are left out because the server writes new ones when it starts, and the new machine&rsquo;s differed from the old. On a world people played, I can&rsquo;t say whether a start and a stop leaves every other hash alone.</p>
<h2 id="6-move-the-players-and-the-backups">6. Move the players and the backups<a class="heading-anchor" href="https://looselydecoupled.com/move-7-days-to-die-dedicated-server-to-new-windows-server/#6-move-the-players-and-the-backups"
     aria-label="Link to section: 6. Move the players and the backups" data-pagefind-ignore></a>
</h2>
<p>Start the new server again with <code>Start-ScheduledTask -TaskName 7DaysToDieServer</code>, and give players its address. Whether they can reach it depends on the network in front of it, which the configuration part leaves to you.</p>
<p>Then save the script and register the task on the new server with steps 4 and 5 of the backup article. Change the script&rsquo;s <code>$Config</code> line to <code>myserver.xml</code>, as the configuration part says, or the new archives hold the shipped settings. Run the task once from the scheduler. Here it exited <code>0</code> and wrote a second archive, 1.8 MB, and a listing of the bucket as SYSTEM showed both archives.</p>
<p>The old server&rsquo;s tasks have been off since step 1; leave it stopped. Deleting it is yours to do, after the new one has made good backups.</p>
<!-- style-check: end procedure -->
<h2 id="check-that-the-key-reaches-only-its-bucket">Check that the key reaches only its bucket<a class="heading-anchor" href="https://looselydecoupled.com/move-7-days-to-die-dedicated-server-to-new-windows-server/#check-that-the-key-reaches-only-its-bucket"
     aria-label="Link to section: Check that the key reaches only its bucket" data-pagefind-ignore></a>
</h2>
<p>The key should reach one bucket and no other. As SYSTEM on the new server, a listing of a different bucket that exists, one the user&rsquo;s policy doesn&rsquo;t name, was refused. I tried one other bucket:</p>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">User:</span> <span class="console__token">arn:aws:iam::&lt;account&gt;:user/&lt;backup</span> <span class="console__token">user&gt;</span> <span class="console__token">is</span> <span class="console__token">not</span> <span class="console__token">authorized</span> <span class="console__token">to</span> <span class="console__token">perform:</span> <span class="console__token">s3:ListBucket</span> <span class="console__token">on</span> <span class="console__token">resource:</span> <span class="console__token">&#34;arn:aws:s3:::&lt;other</span> <span class="console__token">bucket&gt;&#34;</span> <span class="console__token">because</span> <span class="console__token">no</span> <span class="console__token">identity-based</span> <span class="console__token">policy</span> <span class="console__token">allows</span> <span class="console__token">the</span> <span class="console__token">s3:ListBucket</span> <span class="console__token">action</span></span></code></pre>
    </div>
  </div></div><p>A download of an archive in the right bucket succeeded with the same credentials, which is what step 3 did.</p>
<h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/move-7-days-to-die-dedicated-server-to-new-windows-server/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="download-fails-with-is-not-authorized-to-perform-s3getobject">Download fails with <code>is not authorized to perform: s3:GetObject</code><a class="heading-anchor" href="https://looselydecoupled.com/move-7-days-to-die-dedicated-server-to-new-windows-server/#download-fails-with-is-not-authorized-to-perform-s3getobject"
     aria-label="Link to section: Download fails with is not authorized to perform: s3:GetObject" data-pagefind-ignore></a>
</h3>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">User:</span> <span class="console__token">arn:aws:iam::&lt;account&gt;:user/&lt;backup</span> <span class="console__token">user&gt;</span> <span class="console__token">is</span> <span class="console__token">not</span> <span class="console__token">authorized</span> <span class="console__token">to</span> <span class="console__token">perform:</span> <span class="console__token">s3:GetObject</span> <span class="console__token">on</span> <span class="console__token">resource:</span> <span class="console__token">&#34;arn:aws:s3:::&lt;bucket&gt;/7days-backups/&lt;archive&gt;&#34;</span> <span class="console__token">because</span> <span class="console__token">no</span> <span class="console__token">identity-based</span> <span class="console__token">policy</span> <span class="console__token">allows</span> <span class="console__token">the</span> <span class="console__token">s3:GetObject</span> <span class="console__token">action</span></span></code></pre>
    </div>
  </div></div><p>The backup user&rsquo;s policy lacks <code>s3:GetObject</code>. I removed it from the test user&rsquo;s policy and ran the download as SYSTEM, and it failed this way while listing the bucket still worked. Add the permission, as the template in the backup article has it, and run step 3 again.</p>
<h3 id="backup-on-the-old-server-fails-with-aws-tools-for-powershell-not-available">Backup on the old server fails with <code>AWS Tools for PowerShell not available</code><a class="heading-anchor" href="https://looselydecoupled.com/move-7-days-to-die-dedicated-server-to-new-windows-server/#backup-on-the-old-server-fails-with-aws-tools-for-powershell-not-available"
     aria-label="Link to section: Backup on the old server fails with AWS Tools for PowerShell not available" data-pagefind-ignore></a>
</h3>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">ERROR:</span> <span class="console__token">AWS</span> <span class="console__token">Tools</span> <span class="console__token">for</span> <span class="console__token">PowerShell</span> <span class="console__token">not</span> <span class="console__token">available</span> <span class="console__token">to</span> <span class="console__token">this</span> <span class="console__token">account:</span> <span class="console__token">The</span> <span class="console__token">specified</span> <span class="console__token">module</span> <span class="console__token">&#39;AWS.Tools.SecurityToken&#39;</span> <span class="console__token">was</span> <span class="console__token">not</span> <span class="console__token">loaded</span> <span class="console__token">because</span> <span class="console__token">no</span> <span class="console__token">valid</span> <span class="console__token">module</span> <span class="console__token">file</span> <span class="console__token">was</span> <span class="console__token">found</span> <span class="console__token">in</span> <span class="console__token">any</span> <span class="console__token">module</span> <span class="console__token">directory..</span> <span class="console__token">Install</span> <span class="console__token">AWS.Tools.S3</span> <span class="console__token">and</span> <span class="console__token">AWS.Tools.SecurityToken</span> <span class="console__token">with</span> <span class="console__token">-Scope</span> <span class="console__token">AllUsers</span></span></code></pre>
    </div>
  </div></div><p>The task&rsquo;s result was <code>1</code> and nothing reached S3. The old server had <code>AWS.Tools.S3</code> and not <code>AWS.Tools.SecurityToken</code>; it is what step 1 of the backup article installs. Run step 1 of the backup article, which installs both for all users, and start the task again.</p>
<h2 id="what-i-didnt-test">What I didn&rsquo;t test<a class="heading-anchor" href="https://looselydecoupled.com/move-7-days-to-die-dedicated-server-to-new-windows-server/#what-i-didnt-test"
     aria-label="Link to section: What I didn’t test" data-pagefind-ignore></a>
</h2>
<ul>
<li>A world people had played, or a client connecting to the new server, or the ports from outside the machine.</li>
<li>A reboot of the new server, so the at-boot trigger is registered and not seen to fire.</li>
<li>The old server&rsquo;s backup task running after the move. <code>Disable-ScheduledTask</code> on both tasks was run, and both read <code>Disabled</code>.</li>
<li>Steps 2 and 3 of the backup article on either machine. The access key was written into SYSTEM&rsquo;s profile on both for me, and I took the bucket name from the stack&rsquo;s output.</li>
</ul>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>OS</dt> <dd>Windows Server 2025 Datacenter (10.0.26100)</dd></div>
    <div class="tested__row"><dt>PowerShell</dt> <dd>Windows PowerShell 5.1, elevated</dd></div>
    <div class="tested__row"><dt>Game</dt> <dd>7 Days to Die dedicated server (294420), build 24994542</dd></div>
    <div class="tested__row"><dt>Move</dt> <dd>old server to new server by restoring the S3 backup, with a new Navezgane world</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
    <item>
      <title>Start a 7 Days to Die Dedicated Server at Boot on Windows and Stop It Cleanly</title>
      <link>https://looselydecoupled.com/start-7-days-to-die-server-at-boot-and-stop-it-cleanly/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/start-7-days-to-die-server-at-boot-and-stop-it-cleanly/</guid>
      <pubDate>Fri, 02 Oct 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Windows Server</category>
      <category>PowerShell</category>
      <category>Automation</category>
      
      <description>Run a 7 Days to Die server from a Windows scheduled task with no time limit, and stop it over telnet so the shutdown finishes and the world is saved.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Register a scheduled task whose action is the server executable itself, with an at-startup trigger, the SYSTEM account and the execution time limit set to zero. Stop the server with its telnet <code>shutdown</code> command, which the server accepts on <code>127.0.0.1:8081</code> by default.</p>
<p>It is the only stop I found that finishes the shutdown. The process exited with code <code>0</code> and wrote every save file. <code>Stop-ScheduledTask</code> ended it in about a second with exit code <code>1067</code> and wrote about half of them. The default time limit is 72 hours, after which Task Scheduler stops the server the same abrupt way.</p>
<p>In an elevated PowerShell on the server, with your config file (<code>myserver.xml</code> here) in the install folder and <code>UserDataFolder</code> set in it:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$dir</span> <span class="p">=</span> <span class="s1">&#39;C:\7DaysToDieServer&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$log</span> <span class="p">=</span> <span class="nb">Join-Path</span> <span class="nv">$dir</span> <span class="s1">&#39;output_log.txt&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$flags</span> <span class="p">=</span> <span class="s1">&#39;-quit -batchmode -nographics -configfile=myserver.xml -dedicated&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$arguments</span> <span class="p">=</span> <span class="s2">&#34;-logfile </span><span class="se">`&#34;</span><span class="nv">$log</span><span class="se">`&#34;</span><span class="s2"> </span><span class="nv">$flags</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$action</span> <span class="p">=</span> <span class="nb">New-ScheduledTaskAction</span> <span class="n">-Execute</span> <span class="p">(</span><span class="nb">Join-Path</span> <span class="nv">$dir</span> <span class="s1">&#39;7DaysToDieServer.exe&#39;</span><span class="p">)</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-Argument</span> <span class="nv">$arguments</span> <span class="n">-WorkingDirectory</span> <span class="nv">$dir</span>
</span></span><span class="line"><span class="cl"><span class="nv">$trigger</span> <span class="p">=</span> <span class="nb">New-ScheduledTaskTrigger</span> <span class="n">-AtStartup</span>
</span></span><span class="line"><span class="cl"><span class="nv">$settings</span> <span class="p">=</span> <span class="nb">New-ScheduledTaskSettingsSet</span> <span class="n">-ExecutionTimeLimit</span> <span class="p">([</span><span class="no">TimeSpan</span><span class="p">]::</span><span class="n">Zero</span><span class="p">)</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-AllowStartIfOnBatteries</span> <span class="n">-DontStopIfGoingOnBatteries</span>
</span></span><span class="line"><span class="cl"><span class="nb">Register-ScheduledTask</span> <span class="n">-TaskName</span> <span class="s1">&#39;7DaysToDieServer&#39;</span> <span class="n">-Action</span> <span class="nv">$action</span> <span class="n">-Trigger</span> <span class="nv">$trigger</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-Settings</span> <span class="nv">$settings</span> <span class="n">-User</span> <span class="s1">&#39;SYSTEM&#39;</span> <span class="n">-RunLevel</span> <span class="n">Highest</span></span></span></code></pre></div></div>
  </div>
</div><p>Then <code>Start-ScheduledTask -TaskName 7DaysToDieServer</code> starts it, and the <code>Stop-GameServer</code> function in step 4 stops it. The game-specific parts are the server&rsquo;s flags, its ready line, and telnet as the way to stop it.</p>
<p>I didn&rsquo;t restart the test server, so the at-startup trigger is confirmed to be registered, not confirmed to fire. Step 5 has the check to run after your first reboot.</p>

</div>

<p>The previous part left a server you start by hand. This part makes Windows start it when the machine boots, keep it running, and stop it without losing the world.</p>
<!-- style-check: procedure -->
<h2 id="1-register-the-task">1. Register the task<a class="heading-anchor" href="https://looselydecoupled.com/start-7-days-to-die-server-at-boot-and-stop-it-cleanly/#1-register-the-task"
     aria-label="Link to section: 1. Register the task" data-pagefind-ignore></a>
</h2>
<p>The task runs the server executable directly. Nothing wraps it, so the task&rsquo;s state says whether the server is running, and stopping the task stops the server.</p>
<p>The previous part&rsquo;s <code>UserDataFolder</code> setting matters here. A task that runs as SYSTEM has no user profile of its own, and without that setting the server writes its world under the SYSTEM profile, at <code>C:\Windows\System32\config\systemprofile\AppData\Roaming\7DaysToDie</code>. Check that your <code>myserver.xml</code> has it set before you register the task.</p>
<p>This registers the task:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$dir</span> <span class="p">=</span> <span class="s1">&#39;C:\7DaysToDieServer&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$log</span> <span class="p">=</span> <span class="nb">Join-Path</span> <span class="nv">$dir</span> <span class="s1">&#39;output_log.txt&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$flags</span> <span class="p">=</span> <span class="s1">&#39;-quit -batchmode -nographics -configfile=myserver.xml -dedicated&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$arguments</span> <span class="p">=</span> <span class="s2">&#34;-logfile </span><span class="se">`&#34;</span><span class="nv">$log</span><span class="se">`&#34;</span><span class="s2"> </span><span class="nv">$flags</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$action</span> <span class="p">=</span> <span class="nb">New-ScheduledTaskAction</span> <span class="n">-Execute</span> <span class="p">(</span><span class="nb">Join-Path</span> <span class="nv">$dir</span> <span class="s1">&#39;7DaysToDieServer.exe&#39;</span><span class="p">)</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-Argument</span> <span class="nv">$arguments</span> <span class="n">-WorkingDirectory</span> <span class="nv">$dir</span>
</span></span><span class="line"><span class="cl"><span class="nv">$trigger</span> <span class="p">=</span> <span class="nb">New-ScheduledTaskTrigger</span> <span class="n">-AtStartup</span>
</span></span><span class="line"><span class="cl"><span class="nv">$settings</span> <span class="p">=</span> <span class="nb">New-ScheduledTaskSettingsSet</span> <span class="n">-ExecutionTimeLimit</span> <span class="p">([</span><span class="no">TimeSpan</span><span class="p">]::</span><span class="n">Zero</span><span class="p">)</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-AllowStartIfOnBatteries</span> <span class="n">-DontStopIfGoingOnBatteries</span>
</span></span><span class="line"><span class="cl"><span class="nb">Register-ScheduledTask</span> <span class="n">-TaskName</span> <span class="s1">&#39;7DaysToDieServer&#39;</span> <span class="n">-Action</span> <span class="nv">$action</span> <span class="n">-Trigger</span> <span class="nv">$trigger</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-Settings</span> <span class="nv">$settings</span> <span class="n">-User</span> <span class="s1">&#39;SYSTEM&#39;</span> <span class="n">-RunLevel</span> <span class="n">Highest</span></span></span></code></pre></div></div>
  </div>
</div><p>What each part does:</p>
<ul>
<li><code>-WorkingDirectory</code> sets the folder the server starts in. The server found <code>myserver.xml</code> next to its executable without it, so this is a precaution and not a requirement.</li>
<li><code>-AtStartup</code> runs the task when Windows starts, before anyone logs on.</li>
<li><code>-User 'SYSTEM'</code> with <code>-RunLevel Highest</code> runs it as the SYSTEM account, with no password to store. SYSTEM has machine-wide rights, so a flaw in the server or a mod it loads would have them too. If players you don&rsquo;t trust can reach the server, register the task under a dedicated local account that can write only to the install folder. I didn&rsquo;t test that.</li>
<li><code>-ExecutionTimeLimit ([TimeSpan]::Zero)</code> turns the time limit off. The next step shows why that is the setting to get right.</li>
<li><code>-AllowStartIfOnBatteries</code> and <code>-DontStopIfGoingOnBatteries</code> turn off two defaults that exist for laptops. Both are on by default, and a virtual machine has no battery, so these are harmless there.</li>
</ul>
<p><code>Register-ScheduledTask</code> prints an error if the name already exists, and leaves the first task as it was. Running the block twice is harmless. To change a task, remove it with <code>Unregister-ScheduledTask -TaskName '7DaysToDieServer' -Confirm:$false</code> and register it again.</p>
<p>Read the settings back:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">$task</span> <span class="console__token">=</span> <span class="console__token">Get-ScheduledTask</span> <span class="console__token">-TaskName</span> <span class="console__token">&#39;7DaysToDieServer&#39;</span>
</span><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">$task.Settings.ExecutionTimeLimit</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">PT0S</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">$task.Triggers[0].CimClass.CimClassName</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">MSFT_TaskBootTrigger</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">$task.Principal.UserId</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">SYSTEM</span></span></code></pre>
    </div>
  </div></div><p><code>PT0S</code> is how Task Scheduler stores no limit.</p>
<h2 id="2-turn-off-the-72-hour-execution-time-limit">2. Turn off the 72-hour execution time limit<a class="heading-anchor" href="https://looselydecoupled.com/start-7-days-to-die-server-at-boot-and-stop-it-cleanly/#2-turn-off-the-72-hour-execution-time-limit"
     aria-label="Link to section: 2. Turn off the 72-hour execution time limit" data-pagefind-ignore></a>
</h2>
<p>A task registered with no settings read back <code>PT72H</code>. Task Scheduler ends a task that runs longer than its limit, and a game server is meant to run for weeks.</p>
<p>To see what it does to the server, I registered the same task with a limit of two minutes and started it. The server ran until the limit, and Task Scheduler then ended it, about 123 seconds after the start. The task went back to <code>Ready</code> with a last result of <code>267014</code>, which is Task Scheduler&rsquo;s code for a task that was terminated. The log shows the server beginning its shutdown, but the limit ends the process the same abrupt way <code>Stop-ScheduledTask</code> does in step 4, which is not a clean stop.</p>
<p>A longer limit only moves that moment. With <code>PT72H</code> the server would have been stopped three days after each start, and nothing would have restarted it. The <code>-ExecutionTimeLimit ([TimeSpan]::Zero)</code> in step 1 turns the limit off.</p>
<p>There is a second way to build this task, and I tested it too. The action starts a small script that launches the server detached, with <code>Start-Process</code>, and exits. The task then finishes in seconds, and the limit never applies. With a two-minute limit the server was still running after more than three minutes, the task itself had finished within seconds, and <code>Stop-ScheduledTask</code> left the server running. That design needs the telnet stop in step 4 anyway. I used the direct task because its state says whether the server is running.</p>
<h2 id="3-start-the-task-and-wait-for-the-server">3. Start the task and wait for the server<a class="heading-anchor" href="https://looselydecoupled.com/start-7-days-to-die-server-at-boot-and-stop-it-cleanly/#3-start-the-task-and-wait-for-the-server"
     aria-label="Link to section: 3. Start the task and wait for the server" data-pagefind-ignore></a>
</h2>
<p><code>Start-ScheduledTask</code> returns as soon as the task starts. The server takes about 40 seconds to be ready. This starts the task and waits for the server&rsquo;s own ready line:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$log</span> <span class="p">=</span> <span class="s1">&#39;C:\7DaysToDieServer\output_log.txt&#39;</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nb">Get-Process</span> <span class="n">7DaysToDieServer</span> <span class="n">-ErrorAction</span> <span class="n">SilentlyContinue</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">throw</span> <span class="s1">&#39;A 7DaysToDieServer process is already running. Stop it first.&#39;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="nb">Remove-Item</span> <span class="n">-LiteralPath</span> <span class="nv">$log</span> <span class="n">-ErrorAction</span> <span class="n">SilentlyContinue</span>
</span></span><span class="line"><span class="cl"><span class="nb">Start-ScheduledTask</span> <span class="n">-TaskName</span> <span class="s1">&#39;7DaysToDieServer&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$deadline</span> <span class="p">=</span> <span class="p">(</span><span class="nb">Get-Date</span><span class="p">).</span><span class="py">AddMinutes</span><span class="p">(</span><span class="mf">5</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">$ready</span> <span class="p">=</span> <span class="vm">$false</span>
</span></span><span class="line"><span class="cl"><span class="k">while</span> <span class="p">(</span><span class="o">-not</span> <span class="nv">$ready</span> <span class="o">-and</span> <span class="p">(</span><span class="nb">Get-Date</span><span class="p">)</span> <span class="o">-lt</span> <span class="nv">$deadline</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$ready</span> <span class="p">=</span> <span class="p">(</span><span class="nb">Test-Path</span> <span class="nv">$log</span><span class="p">)</span> <span class="o">-and</span>
</span></span><span class="line"><span class="cl">        <span class="p">(</span><span class="nb">Select-String</span> <span class="n">-Path</span> <span class="nv">$log</span> <span class="n">-Pattern</span> <span class="s1">&#39;StartGame done&#39;</span> <span class="n">-Quiet</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">-not</span> <span class="nv">$ready</span><span class="p">)</span> <span class="p">{</span> <span class="nb">Start-Sleep</span> <span class="n">-Seconds</span> <span class="mf">2</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">-not</span> <span class="nv">$ready</span><span class="p">)</span> <span class="p">{</span> <span class="k">throw</span> <span class="s1">&#39;The server was not ready after five minutes.&#39;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">$task</span> <span class="p">=</span> <span class="nb">Get-ScheduledTask</span> <span class="n">-TaskName</span> <span class="s1">&#39;7DaysToDieServer&#39;</span>
</span></span><span class="line"><span class="cl"><span class="s1">&#39;Task state: {0}; last result: {1}&#39;</span> <span class="o">-f</span> <span class="nv">$task</span><span class="p">.</span><span class="n">State</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="nb">Get-ScheduledTaskInfo</span> <span class="n">-TaskName</span> <span class="s1">&#39;7DaysToDieServer&#39;</span><span class="p">).</span><span class="n">LastTaskResult</span></span></span></code></pre></div></div>
  </div>
</div><p>It stops if a <code>7DaysToDieServer</code> process is already running, however it was started. A task in the <code>Ready</code> state would start a second server on the same ports and folder if you had started the first one by hand. And <code>Start-ScheduledTask</code> on a running task does nothing and returns no error. It does set the last result to <code>2147946720</code>, the refusal from the default one-instance rule, and a later read of the result would mislead. It deletes the old log first. The check looks for <code>StartGame done</code>, and a leftover log would satisfy it at once.</p>
<p>While the server runs, the output is:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Task state: Running; last result: 267009</span></span></code></pre></div></div>
  </div>
</div><p>A state of <code>Running</code> and a last result of <code>267009</code> mean the task is running. That is a status code and not an error.</p>
<h2 id="4-stop-the-server-with-the-telnet-shutdown-command">4. Stop the server with the telnet shutdown command<a class="heading-anchor" href="https://looselydecoupled.com/start-7-days-to-die-server-at-boot-and-stop-it-cleanly/#4-stop-the-server-with-the-telnet-shutdown-command"
     aria-label="Link to section: 4. Stop the server with the telnet shutdown command" data-pagefind-ignore></a>
</h2>
<p>The server has a telnet console, on <code>127.0.0.1:8081</code> by default, and its <code>shutdown</code> command is the stop that works. Setting <code>TelnetPassword</code> makes the console listen on every interface instead, so keep Windows Firewall and any cloud network security group closed to that port. This function sends the command, with the password first if there is one, and waits for the process to exit:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="kd">function</span><span class="w"> </span><span class="nb">Stop-GameServer</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">param</span><span class="p">([</span><span class="no">securestring</span><span class="p">]</span><span class="nv">$Password</span><span class="p">,</span> <span class="p">[</span><span class="no">int</span><span class="p">]</span><span class="nv">$Port</span> <span class="p">=</span> <span class="mf">8081</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">[</span><span class="no">int</span><span class="p">]</span><span class="nv">$TimeoutSeconds</span> <span class="p">=</span> <span class="mf">90</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$server</span> <span class="p">=</span> <span class="vm">@</span><span class="p">(</span><span class="nb">Get-Process</span> <span class="n">7DaysToDieServer</span> <span class="n">-ErrorAction</span> <span class="n">SilentlyContinue</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nv">$server</span><span class="p">.</span><span class="py">Count</span> <span class="o">-ne</span> <span class="mf">1</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">throw</span> <span class="s2">&#34;Expected one 7DaysToDieServer process, found </span><span class="p">$(</span><span class="nv">$server</span><span class="p">.</span><span class="n">Count</span><span class="p">)</span><span class="s2">.&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="vm">$null</span> <span class="p">=</span> <span class="nv">$server</span><span class="p">[</span><span class="mf">0</span><span class="p">].</span><span class="py">Handle</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$client</span> <span class="p">=</span> <span class="nb">New-Object</span> <span class="n">Net</span><span class="p">.</span><span class="py">Sockets</span><span class="p">.</span><span class="py">TcpClient</span><span class="p">(</span><span class="s1">&#39;127.0.0.1&#39;</span><span class="p">,</span> <span class="nv">$Port</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$stream</span> <span class="p">=</span> <span class="nv">$client</span><span class="p">.</span><span class="py">GetStream</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$stream</span><span class="p">.</span><span class="py">ReadTimeout</span> <span class="p">=</span> <span class="mf">2000</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$buffer</span> <span class="p">=</span> <span class="nb">New-Object</span> <span class="n">byte</span><span class="p">[]</span> <span class="mf">4096</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$reply</span> <span class="p">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nv">$text</span> <span class="p">=</span> <span class="s1">&#39;&#39;</span>
</span></span><span class="line"><span class="cl">            <span class="nv">$until</span> <span class="p">=</span> <span class="p">(</span><span class="nb">Get-Date</span><span class="p">).</span><span class="py">AddSeconds</span><span class="p">(</span><span class="mf">3</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="k">while</span> <span class="p">((</span><span class="nb">Get-Date</span><span class="p">)</span> <span class="o">-lt</span> <span class="nv">$until</span> <span class="o">-and</span>
</span></span><span class="line"><span class="cl">                    <span class="p">(</span><span class="nv">$count</span> <span class="p">=</span> <span class="nv">$stream</span><span class="p">.</span><span class="py">Read</span><span class="p">(</span><span class="nv">$buffer</span><span class="p">,</span> <span class="mf">0</span><span class="p">,</span> <span class="nv">$buffer</span><span class="p">.</span><span class="n">Length</span><span class="p">))</span> <span class="o">-gt</span> <span class="mf">0</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                    <span class="nv">$text</span> <span class="p">+=</span> <span class="p">[</span><span class="no">Text.Encoding</span><span class="p">]::</span><span class="n">ASCII</span><span class="p">.</span><span class="py">GetString</span><span class="p">(</span><span class="nv">$buffer</span><span class="p">,</span> <span class="mf">0</span><span class="p">,</span> <span class="nv">$count</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">                <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="k">catch</span> <span class="p">[</span><span class="no">IO.IOException</span><span class="p">]</span> <span class="p">{</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="nv">$text</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$send</span> <span class="p">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="k">param</span><span class="p">(</span><span class="nv">$line</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="nv">$bytes</span> <span class="p">=</span> <span class="p">[</span><span class="no">Text.Encoding</span><span class="p">]::</span><span class="n">ASCII</span><span class="p">.</span><span class="py">GetBytes</span><span class="p">(</span><span class="s2">&#34;</span><span class="nv">$line</span><span class="se">`r`n</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="nv">$stream</span><span class="p">.</span><span class="py">Write</span><span class="p">(</span><span class="nv">$bytes</span><span class="p">,</span> <span class="mf">0</span><span class="p">,</span> <span class="nv">$bytes</span><span class="p">.</span><span class="n">Length</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="nv">$Password</span> <span class="o">-and</span> <span class="nv">$Password</span><span class="p">.</span><span class="py">Length</span> <span class="o">-gt</span> <span class="mf">0</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="p">&amp;</span> <span class="nv">$send</span> <span class="p">([</span><span class="no">Net.NetworkCredential</span><span class="p">]::</span><span class="n">new</span><span class="p">(</span><span class="s1">&#39;&#39;</span><span class="p">,</span> <span class="nv">$Password</span><span class="p">).</span><span class="n">Password</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="nv">$login</span> <span class="p">=</span> <span class="p">&amp;</span> <span class="nv">$reply</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="nv">$login</span> <span class="o">-match</span> <span class="s1">&#39;Password incorrect&#39;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="k">throw</span> <span class="s1">&#39;The telnet password is missing or wrong.&#39;</span>
</span></span><span class="line"><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="nv">$login</span> <span class="o">-notmatch</span> <span class="s1">&#39;Logon successful&#39;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="k">throw</span> <span class="s1">&#39;The server did not confirm the telnet login.&#39;</span>
</span></span><span class="line"><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">&amp;</span> <span class="nv">$send</span> <span class="s1">&#39;shutdown&#39;</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">((&amp;</span> <span class="nv">$reply</span><span class="p">)</span> <span class="o">-match</span> <span class="s1">&#39;Password incorrect&#39;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="k">throw</span> <span class="s1">&#39;The telnet password is missing or wrong.&#39;</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">finally</span> <span class="p">{</span> <span class="nv">$client</span><span class="p">.</span><span class="py">Close</span><span class="p">()</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">-not</span> <span class="nv">$server</span><span class="p">[</span><span class="mf">0</span><span class="p">].</span><span class="py">WaitForExit</span><span class="p">(</span><span class="nv">$TimeoutSeconds</span> <span class="p">*</span> <span class="mf">1000</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">throw</span> <span class="s2">&#34;The server was still running after </span><span class="nv">$TimeoutSeconds</span><span class="s2"> seconds.&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;Stopped, exit code {0}.&#39;</span> <span class="o">-f</span> <span class="nv">$server</span><span class="p">[</span><span class="mf">0</span><span class="p">].</span><span class="py">ExitCode</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p>It stops with an error and doesn&rsquo;t touch the process in two cases. The telnet port refuses the connection, which is what <code>TelnetEnabled</code> set to <code>false</code> does. Or the server answers <code>Password incorrect</code>. It never forces the process to end. Run it as <code>Stop-GameServer</code>. With a telnet password, read it as a secure string so it stays out of your console history and any transcript:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$telnet</span> <span class="p">=</span> <span class="nb">Read-Host</span> <span class="s1">&#39;Telnet password&#39;</span> <span class="n">-AsSecureString</span>
</span></span><span class="line"><span class="cl"><span class="nb">Stop-GameServer</span> <span class="n">-Password</span> <span class="nv">$telnet</span></span></span></code></pre></div></div>
  </div>
</div><p>The function turns it back into text only to send it. The game&rsquo;s telnet console takes it unencrypted, one more reason to keep that port closed.</p>
<p>On the task&rsquo;s server it stopped the process with exit code <code>0</code>. The task went back to <code>Ready</code> with a last result of <code>0</code>, because the process ended by itself. The server wrote 9 to 14 files under <code>Saves</code> and its log ran on to the end of its shutdown, after <code>SaveAndCleanupWorld</code> and <code>Saving 0 of chunks</code>, with 47 more lines. It took two to eight seconds.</p>
<p>With a telnet password set, I connected three ways and sent <code>shutdown</code> each time. With no password, or a wrong one, the server answered <code>Password incorrect, please enter password:</code> and kept running, and the function reports that at once. With the right password it stopped in two seconds, exit code <code>0</code>, and the log held <code>SaveAndCleanupWorld</code>.</p>
<p>Each wrong attempt counts toward the server&rsquo;s <code>TelnetFailedLoginLimit</code>, which was 10 on this server. The function sends the password first and reads the reply, for at most three seconds, and sends <code>shutdown</code> only after the server has said <code>Logon successful</code>, so a wrong password uses one attempt. Sent together, the <code>shutdown</code> after a wrong password would count as a second one. With the limit set to 3, a wrong password and then a missing one still left the right password working.</p>
<p>If the reply is late or says neither, the function stops with <code>The server did not confirm the telnet login.</code> The server also prints its log to a telnet connection, so a read that waited only for silence could go on as long as the server logged something every two seconds.</p>
<h3 id="why-stop-scheduledtask-isnt-a-clean-stop">Why <code>Stop-ScheduledTask</code> isn&rsquo;t a clean stop<a class="heading-anchor" href="https://looselydecoupled.com/start-7-days-to-die-server-at-boot-and-stop-it-cleanly/#why-stop-scheduledtask-isnt-a-clean-stop"
     aria-label="Link to section: Why Stop-ScheduledTask isn’t a clean stop" data-pagefind-ignore></a>
</h3>
<p><code>Stop-ScheduledTask -TaskName '7DaysToDieServer'</code> is the obvious stop for a task, and it ends the process, but not cleanly. I ran it twice on the task&rsquo;s server:</p>
<ul>
<li>The process exited with code <code>1067</code> after about a second. <code>1067</code> is <code>ERROR_PROCESS_ABORTED</code>, &ldquo;The process terminated unexpectedly&rdquo;, and it is the exit code the process had both times Task Scheduler ended it here.</li>
<li>The log began the shutdown, with <code>SaveAndCleanupWorld</code> and <code>Stopping server</code>, and then ended at <code>Cleared queues.</code> instead of running on for another 40 or so lines.</li>
<li>It wrote three to five files under <code>Saves</code>, among them <code>main.ttw</code>, <code>players.xml</code> and <code>decoration.7dt</code>. The telnet stop also wrote <code>vehicles.dat</code>, <code>power.dat</code>, <code>turrets.dat</code>, <code>drones.dat</code> and <code>blockLimits.dat</code>, and none of those five were written in either <code>Stop-ScheduledTask</code> run.</li>
<li>The task read <code>Ready</code> with a last result of <code>267014</code>, Task Scheduler&rsquo;s code for a terminated task.</li>
</ul>
<p>I ran these on a new world with nobody connected, so I can&rsquo;t say what the missing files hold in a world that people are playing. Their names suggest vehicles, power, turrets, drones and block limits. Use <code>Stop-ScheduledTask</code> as the fallback when telnet isn&rsquo;t available.</p>
<h3 id="other-ways-to-stop-it-taskkill-and-the-close-request">Other ways to stop it: taskkill and the close request<a class="heading-anchor" href="https://looselydecoupled.com/start-7-days-to-die-server-at-boot-and-stop-it-cleanly/#other-ways-to-stop-it-taskkill-and-the-close-request"
     aria-label="Link to section: Other ways to stop it: taskkill and the close request" data-pagefind-ignore></a>
</h3>
<p>With telnet off, I tried the other ways a script might stop the process. The first two behave differently when a console session started the server and when the task did, so the table has both:</p>
<div class="table-wrap" data-scrollable data-label="Table: Method">
  <table>
    <thead>
      <tr><th scope="col">Method</th><th scope="col">Server started by the task</th><th scope="col">Server started from a console session</th></tr>
    </thead>
    <tbody>
      <tr><td><code>CloseMainWindow()</code> on the process</td><td>Returned <code>False</code>, and the server kept running</td><td>Returned <code>True</code>, and the server was still running 10 seconds later</td></tr>
      <tr><td><code>taskkill /PID &lt;id&gt;</code> without <code>/F</code></td><td><code>ERROR: ... This process can only be terminated forcefully (with /F option).</code> The server kept running</td><td><code>SUCCESS: Sent termination signal</code>, and the server was still running five seconds later</td></tr>
      <tr><td><code>Stop-Process -Force</code></td><td>Not run</td><td>Ended it at once. The log has no <code>SaveAndCleanupWorld</code></td></tr>
    </tbody>
  </table>
</div>
<p>The server runs with <code>-batchmode -nographics</code>, and the two polite requests had no effect on it. <code>Stop-Process -Force</code> is the last resort. After it, the server had logged no save, so whatever happened since the last save is gone.</p>
<!-- style-check: end procedure -->
<h2 id="5-check-the-task-after-the-first-reboot">5. Check the task after the first reboot<a class="heading-anchor" href="https://looselydecoupled.com/start-7-days-to-die-server-at-boot-and-stop-it-cleanly/#5-check-the-task-after-the-first-reboot"
     aria-label="Link to section: 5. Check the task after the first reboot" data-pagefind-ignore></a>
</h2>
<p>The at-startup trigger was registered and read back as a boot trigger, and the task starts on command. I didn&rsquo;t restart the test server, so I haven&rsquo;t seen the trigger fire. After your first reboot, check that it did:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Get-ScheduledTaskInfo</span> <span class="n">-TaskName</span> <span class="s1">&#39;7DaysToDieServer&#39;</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Select-Object</span> <span class="n">LastRunTime</span><span class="p">,</span> <span class="n">LastTaskResult</span>
</span></span><span class="line"><span class="cl"><span class="nb">Get-Process</span> <span class="n">7DaysToDieServer</span> <span class="n">-ErrorAction</span> <span class="n">SilentlyContinue</span></span></span></code></pre></div></div>
  </div>
</div><p>A <code>LastRunTime</code> near the boot time and a running process mean it worked. Stop the server with step 4 before you restart the machine on purpose. I haven&rsquo;t tested what Windows does to the server when it shuts down with the task running, and if it ends the process the way <code>Stop-ScheduledTask</code> does, the save is only partial.</p>
<p>Nothing here restarts the server if it crashes. The task has no restart settings.</p>
<h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/start-7-days-to-die-server-at-boot-and-stop-it-cleanly/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="server-stops-by-itself-after-three-days">Server stops by itself after three days<a class="heading-anchor" href="https://looselydecoupled.com/start-7-days-to-die-server-at-boot-and-stop-it-cleanly/#server-stops-by-itself-after-three-days"
     aria-label="Link to section: Server stops by itself after three days" data-pagefind-ignore></a>
</h3>
<p>The task has the default 72-hour limit, or whatever limit you set. Read it with <code>(Get-ScheduledTask -TaskName '7DaysToDieServer').Settings.ExecutionTimeLimit</code>. If it isn&rsquo;t <code>PT0S</code>, remove the task and register it again with step 1.</p>
<h3 id="task-starts-and-the-server-isnt-running">Task starts and the server isn&rsquo;t running<a class="heading-anchor" href="https://looselydecoupled.com/start-7-days-to-die-server-at-boot-and-stop-it-cleanly/#task-starts-and-the-server-isnt-running"
     aria-label="Link to section: Task starts and the server isn’t running" data-pagefind-ignore></a>
</h3>
<p>The log in <code>-logfile</code> has the reason. Two causes seen on this server: a missing Visual C++ runtime, and a <code>ServerPort</code> outside 1024 to 65530.</p>
<h3 id="stop-gameserver-says-the-connection-was-refused"><code>Stop-GameServer</code> says the connection was refused<a class="heading-anchor" href="https://looselydecoupled.com/start-7-days-to-die-server-at-boot-and-stop-it-cleanly/#stop-gameserver-says-the-connection-was-refused"
     aria-label="Link to section: Stop-GameServer says the connection was refused" data-pagefind-ignore></a>
</h3>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Exception</span> <span class="console__token">calling</span> <span class="console__token">&#34;.ctor&#34;</span> <span class="console__token">with</span> <span class="console__token">&#34;2&#34;</span> <span class="console__token">argument(s):</span> <span class="console__token">&#34;No</span> <span class="console__token">connection</span> <span class="console__token">could</span> <span class="console__token">be</span> <span class="console__token">made</span> <span class="console__token">because</span> <span class="console__token">the</span> <span class="console__token">target</span> <span class="console__token">machine</span> <span class="console__token">actively</span> <span class="console__token">refused</span> <span class="console__token">it</span> <span class="console__token">127.0.0.1:8081&#34;</span></span></code></pre>
    </div>
  </div></div><p>Telnet is off, or on another port. Check <code>TelnetEnabled</code> and <code>TelnetPort</code> in <code>myserver.xml</code>. Until telnet works, <code>Stop-ScheduledTask</code> is the fallback, with the cost step 4 describes.</p>
<h3 id="last-result-267014-or-exit-code-1067-after-a-stop">Last result 267014, or exit code 1067, after a stop<a class="heading-anchor" href="https://looselydecoupled.com/start-7-days-to-die-server-at-boot-and-stop-it-cleanly/#last-result-267014-or-exit-code-1067-after-a-stop"
     aria-label="Link to section: Last result 267014, or exit code 1067, after a stop" data-pagefind-ignore></a>
</h3>
<p>Task Scheduler uses it for a task that was ended from outside, which is what <code>Stop-ScheduledTask</code> and the time limit do. The process itself exits with <code>1067</code> when Task Scheduler ends it. A telnet stop leaves <code>0</code> for both. Step 4 shows what the difference cost.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>OS</dt> <dd>Windows Server 2025 Datacenter (10.0.26100), virtual machine</dd></div>
    <div class="tested__row"><dt>PowerShell</dt> <dd>Windows PowerShell 5.1, elevated</dd></div>
    <div class="tested__row"><dt>Game</dt> <dd>7 Days to Die dedicated server (294420), V 3.2.0 (b10)</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
    <item>
      <title>Test AccessDenied in AWS PowerShell with a Role That Has No Permissions</title>
      <link>https://looselydecoupled.com/aws-access-denied-test-empty-iam-role/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/aws-access-denied-test-empty-iam-role/</guid>
      <pubDate>Sun, 27 Sep 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>AWS</category>
      <category>PowerShell</category>
      
      <description>Watch your script handle a real AccessDenied, raised with a throwaway role&#39;s temporary keys, without changing your own permissions or writing any data.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Create a role with no permissions that your account can assume, switch the
session to its temporary keys, and run the script. Every call it makes that needs a
permission is denied for real:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$identity</span>  <span class="p">=</span> <span class="nb">Get-STSCallerIdentity</span>
</span></span><span class="line"><span class="cl"><span class="nv">$partition</span> <span class="p">=</span> <span class="nv">$identity</span><span class="p">.</span><span class="py">Arn</span><span class="p">.</span><span class="py">Split</span><span class="p">(</span><span class="s1">&#39;:&#39;</span><span class="p">)[</span><span class="mf">1</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nv">$account</span>   <span class="p">=</span> <span class="nv">$identity</span><span class="p">.</span><span class="py">Account</span>
</span></span><span class="line"><span class="cl"><span class="nv">$trust</span> <span class="p">=</span> <span class="s1">&#39;{&#34;Version&#34;:&#34;2012-10-17&#34;,&#34;Statement&#34;:[{&#34;Effect&#34;:&#34;Allow&#34;,&#34;Principal&#34;:{&#34;AWS&#34;:&#34;arn:&#39;</span> <span class="p">+</span> <span class="nv">$partition</span> <span class="p">+</span> <span class="s1">&#39;:iam::&#39;</span> <span class="p">+</span> <span class="nv">$account</span> <span class="p">+</span> <span class="s1">&#39;:root&#34;},&#34;Action&#34;:&#34;sts:AssumeRole&#34;}]}&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">New-IAMRole</span> <span class="n">-RoleName</span> <span class="n">denytest</span> <span class="n">-AssumeRolePolicyDocument</span> <span class="nv">$trust</span>
</span></span><span class="line"><span class="cl"><span class="nv">$previous</span> <span class="p">=</span> <span class="nb">Get-AWSCredential</span>
</span></span><span class="line"><span class="cl"><span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="c"># A new role took about eight seconds to become assumable in testing.</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Start-Sleep</span> <span class="n">-Seconds</span> <span class="mf">8</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$delay</span> <span class="p">=</span> <span class="mf">1</span>
</span></span><span class="line"><span class="cl">    <span class="k">foreach</span> <span class="p">(</span><span class="nv">$attempt</span> <span class="k">in</span> <span class="mf">1</span><span class="p">.</span><span class="mf">.6</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nv">$keys</span> <span class="p">=</span> <span class="p">(</span><span class="nb">Use-STSRole</span> <span class="n">-RoleArn</span> <span class="s2">&#34;arn:</span><span class="nv">${partition}</span><span class="s2">:iam::</span><span class="nv">${account}</span><span class="s2">:role/denytest&#34;</span> <span class="n">-RoleSessionName</span> <span class="n">denytest</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span><span class="p">).</span><span class="py">Credentials</span>
</span></span><span class="line"><span class="cl">            <span class="k">break</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="c"># Retry only the denial a new role gives; rethrow anything else, and the last denial.</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="nv">$_</span><span class="p">.</span><span class="py">Exception</span><span class="p">.</span><span class="py">InnerException</span><span class="p">.</span><span class="py">ErrorCode</span> <span class="o">-ne</span> <span class="s1">&#39;AccessDenied&#39;</span> <span class="o">-or</span> <span class="nv">$attempt</span> <span class="o">-eq</span> <span class="mf">6</span><span class="p">)</span> <span class="p">{</span> <span class="k">throw</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="nb">Start-Sleep</span> <span class="n">-Seconds</span> <span class="nv">$delay</span>
</span></span><span class="line"><span class="cl">            <span class="nv">$delay</span> <span class="p">*=</span> <span class="mf">2</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Set-AWSCredential</span> <span class="n">-Credential</span> <span class="nv">$keys</span>
</span></span><span class="line"><span class="cl">    <span class="p">./</span><span class="nb">Your-Script</span><span class="p">.</span><span class="py">ps1</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">finally</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="c"># Restore your own credentials first; deleting the role needs them.</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nv">$previous</span><span class="p">)</span> <span class="p">{</span> <span class="nb">Set-AWSCredential</span> <span class="n">-Credential</span> <span class="nv">$previous</span> <span class="p">}</span> <span class="k">else</span> <span class="p">{</span> <span class="nb">Clear-AWSCredential</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Remove-IAMRole</span> <span class="n">-RoleName</span> <span class="n">denytest</span> <span class="n">-Force</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p>AWS.Tools wraps the AWS error in an <code>InvalidOperationException</code>; the
<code>AccessDenied</code> code is on <code>$_.Exception.InnerException</code>, not on
<code>$_.Exception</code>.</p>

</div>

<p>A script that calls AWS will one day run with credentials that can&rsquo;t do what
it asks: a new role missing one permission, a scheduled task under the wrong
profile. What it does then is usually untested, because the identity the
script was written with can do everything.</p>
<p>I hit this while writing <a href="https://looselydecoupled.com/custom-cloudwatch-metrics-powershell/">How to Publish a Custom CloudWatch Metric from
PowerShell</a>. Its common failures needed
the exact message <code>Write-CWMetricData</code> prints when the caller isn&rsquo;t allowed to
publish. Removing a permission
from my account to see one error was out, and so was publishing a real metric
to watch it fail. This is the trick I used instead, and the exception
details in this article come from those <code>Write-CWMetricData</code> runs.</p>
<p>A role with no permissions is a second identity that IAM denies for every
action that needs a permission. A call made with its keys comes back with the
genuine AccessDenied, and nothing is written or changed. The IAM Policy
Simulator says whether a call would be denied; it doesn&rsquo;t produce the exception
a catch block has to handle.</p>
<!-- style-check: procedure -->
<h2 id="install-the-iam-and-sts-modules">Install the IAM and STS modules<a class="heading-anchor" href="https://looselydecoupled.com/aws-access-denied-test-empty-iam-role/#install-the-iam-and-sts-modules"
     aria-label="Link to section: Install the IAM and STS modules" data-pagefind-ignore></a>
</h2>
<p><code>New-IAMRole</code> and <code>Remove-IAMRole</code> are in <code>AWS.Tools.IdentityManagement</code>;
<code>Get-STSCallerIdentity</code> and <code>Use-STSRole</code> are in <code>AWS.Tools.SecurityToken</code>.</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Install-Module</span> <span class="n">-Name</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="n">IdentityManagement</span><span class="p">,</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="py">SecurityToken</span> <span class="n">-Scope</span> <span class="n">CurrentUser</span> <span class="n">-Force</span>
</span></span><span class="line"><span class="cl"><span class="nb">Import-Module</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="n">IdentityManagement</span><span class="p">,</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="n">SecurityToken</span></span></span></code></pre></div></div>
  </div>
</div><h2 id="create-a-role-with-no-permissions">Create a role with no permissions<a class="heading-anchor" href="https://looselydecoupled.com/aws-access-denied-test-empty-iam-role/#create-a-role-with-no-permissions"
     aria-label="Link to section: Create a role with no permissions" data-pagefind-ignore></a>
</h2>
<p>The trust policy decides who can assume the role. The partition and account
come from your own ARN, so the same lines work in the standard, China and
GovCloud partitions. Naming your account&rsquo;s root lets any identity in the
account with <code>sts:AssumeRole</code> permission assume the role. Yours can if its
policy allows <code>sts:AssumeRole</code> on this role; an administrator&rsquo;s does. No
permission policy is attached, so the role is denied everything except calls
that need no permission, such as <code>Get-STSCallerIdentity</code>, and resources whose
own policy grants access to anyone.</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$identity</span>  <span class="p">=</span> <span class="nb">Get-STSCallerIdentity</span>
</span></span><span class="line"><span class="cl"><span class="nv">$partition</span> <span class="p">=</span> <span class="nv">$identity</span><span class="p">.</span><span class="py">Arn</span><span class="p">.</span><span class="py">Split</span><span class="p">(</span><span class="s1">&#39;:&#39;</span><span class="p">)[</span><span class="mf">1</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nv">$account</span>   <span class="p">=</span> <span class="nv">$identity</span><span class="p">.</span><span class="py">Account</span>
</span></span><span class="line"><span class="cl"><span class="nv">$trust</span> <span class="p">=</span> <span class="s1">&#39;{&#34;Version&#34;:&#34;2012-10-17&#34;,&#34;Statement&#34;:[{&#34;Effect&#34;:&#34;Allow&#34;,&#34;Principal&#34;:{&#34;AWS&#34;:&#34;arn:&#39;</span> <span class="p">+</span> <span class="nv">$partition</span> <span class="p">+</span> <span class="s1">&#39;:iam::&#39;</span> <span class="p">+</span> <span class="nv">$account</span> <span class="p">+</span> <span class="s1">&#39;:root&#34;},&#34;Action&#34;:&#34;sts:AssumeRole&#34;}]}&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">New-IAMRole</span> <span class="n">-RoleName</span> <span class="n">denytest</span> <span class="n">-AssumeRolePolicyDocument</span> <span class="nv">$trust</span></span></span></code></pre></div></div>
  </div>
</div><h2 id="assume-the-role-for-temporary-keys">Assume the role for temporary keys<a class="heading-anchor" href="https://looselydecoupled.com/aws-access-denied-test-empty-iam-role/#assume-the-role-for-temporary-keys"
     aria-label="Link to section: Assume the role for temporary keys" data-pagefind-ignore></a>
</h2>
<p><code>Use-STSRole</code> returns temporary credentials for the role; keep the
<code>Credentials</code> property. A new role isn&rsquo;t assumable straight away: in my test
the first attempt was denied and the role became assumable about eight seconds
later. Wait that long before the first call, which would otherwise be denied
for certain, then retry with a backoff to cover a slower day. The loop waits
1, 2, 4, 8 and 16 seconds between attempts, retries only <code>AccessDenied</code>, and
rethrows after the sixth attempt so a role you can&rsquo;t assume fails instead of
looping:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="c"># A new role took about eight seconds to become assumable in testing.</span>
</span></span><span class="line"><span class="cl"><span class="nb">Start-Sleep</span> <span class="n">-Seconds</span> <span class="mf">8</span>
</span></span><span class="line"><span class="cl"><span class="nv">$delay</span> <span class="p">=</span> <span class="mf">1</span>
</span></span><span class="line"><span class="cl"><span class="k">foreach</span> <span class="p">(</span><span class="nv">$attempt</span> <span class="k">in</span> <span class="mf">1</span><span class="p">.</span><span class="mf">.6</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$keys</span> <span class="p">=</span> <span class="p">(</span><span class="nb">Use-STSRole</span> <span class="n">-RoleArn</span> <span class="s2">&#34;arn:</span><span class="nv">${partition}</span><span class="s2">:iam::</span><span class="nv">${account}</span><span class="s2">:role/denytest&#34;</span> <span class="n">-RoleSessionName</span> <span class="n">denytest</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span><span class="p">).</span><span class="py">Credentials</span>
</span></span><span class="line"><span class="cl">        <span class="k">break</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="c"># Retry only the denial a new role gives; rethrow anything else, and the last denial.</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="nv">$_</span><span class="p">.</span><span class="py">Exception</span><span class="p">.</span><span class="py">InnerException</span><span class="p">.</span><span class="py">ErrorCode</span> <span class="o">-ne</span> <span class="s1">&#39;AccessDenied&#39;</span> <span class="o">-or</span> <span class="nv">$attempt</span> <span class="o">-eq</span> <span class="mf">6</span><span class="p">)</span> <span class="p">{</span> <span class="k">throw</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="nb">Start-Sleep</span> <span class="n">-Seconds</span> <span class="nv">$delay</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$delay</span> <span class="p">*=</span> <span class="mf">2</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><h2 id="catch-accessdenied-in-the-script">Catch <code>AccessDenied</code> in the script<a class="heading-anchor" href="https://looselydecoupled.com/aws-access-denied-test-empty-iam-role/#catch-accessdenied-in-the-script"
     aria-label="Link to section: Catch AccessDenied in the script" data-pagefind-ignore></a>
</h2>
<p>The cmdlet throws a <code>System.InvalidOperationException</code> whose
<code>InnerException</code> is the AWS exception. That inner exception is an
<code>Amazon.Runtime.AmazonServiceException</code> carrying <code>ErrorCode</code> <code>AccessDenied</code>,
<code>StatusCode</code> <code>Forbidden</code> and <code>ErrorType</code> <code>Sender</code>. Test the inner exception,
handle the denial, and rethrow anything else. <code>$datum</code> is the metric from your
own script:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Write-CWMetricData</span> <span class="n">-Namespace</span> <span class="s1">&#39;MyApp/Queue&#39;</span> <span class="n">-MetricData</span> <span class="nv">$datum</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$awsError</span> <span class="p">=</span> <span class="nv">$_</span><span class="p">.</span><span class="py">Exception</span><span class="p">.</span><span class="py">InnerException</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nv">$awsError</span> <span class="o">-is</span> <span class="p">[</span><span class="no">Amazon.Runtime.AmazonServiceException</span><span class="p">]</span> <span class="o">-and</span> <span class="nv">$awsError</span><span class="p">.</span><span class="py">ErrorCode</span> <span class="o">-eq</span> <span class="s1">&#39;AccessDenied&#39;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nb">Write-Warning</span> <span class="s2">&#34;No permission to publish the metric: </span><span class="p">$(</span><span class="nv">$awsError</span><span class="p">.</span><span class="n">Message</span><span class="p">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">throw</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><h2 id="run-the-script-as-the-role">Run the script as the role<a class="heading-anchor" href="https://looselydecoupled.com/aws-access-denied-test-empty-iam-role/#run-the-script-as-the-role"
     aria-label="Link to section: Run the script as the role" data-pagefind-ignore></a>
</h2>
<p><code>Set-AWSCredential -Credential</code> makes the role&rsquo;s keys the default for the
session, so the script runs unmodified. Every call in it that needs a
permission is denied. <code>Get-AWSCredential</code> with no arguments returns the
session&rsquo;s current credentials, so save them first and put them back after.
<code>Clear-AWSCredential</code> alone would only remove the role&rsquo;s keys, not restore what
was there before, and the <code>Remove-IAMRole</code> that follows needs your own access.
When nothing was set, clearing is right: later calls fall back to your default
profile or environment. The restore sits in <code>finally</code> because the script is
expected to fail: without it, an unhandled denial skips the restore, the
session stays on the role&rsquo;s keys, and the cleanup that follows is denied too.
The script&rsquo;s error still reaches you.</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$previous</span> <span class="p">=</span> <span class="nb">Get-AWSCredential</span>
</span></span><span class="line"><span class="cl"><span class="nb">Set-AWSCredential</span> <span class="n">-Credential</span> <span class="nv">$keys</span>
</span></span><span class="line"><span class="cl"><span class="k">try</span> <span class="p">{</span> <span class="p">./</span><span class="nb">Your-Script</span><span class="p">.</span><span class="py">ps1</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">finally</span> <span class="p">{</span> <span class="k">if</span> <span class="p">(</span><span class="nv">$previous</span><span class="p">)</span> <span class="p">{</span> <span class="nb">Set-AWSCredential</span> <span class="n">-Credential</span> <span class="nv">$previous</span> <span class="p">}</span> <span class="k">else</span> <span class="p">{</span> <span class="nb">Clear-AWSCredential</span> <span class="p">}</span> <span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p>To deny a single call instead, pass the keys to that cmdlet with
<code>-Credential $keys</code>.</p>
<h2 id="delete-the-role">Delete the role<a class="heading-anchor" href="https://looselydecoupled.com/aws-access-denied-test-empty-iam-role/#delete-the-role"
     aria-label="Link to section: Delete the role" data-pagefind-ignore></a>
</h2>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Remove-IAMRole</span> <span class="n">-RoleName</span> <span class="n">denytest</span> <span class="n">-Force</span></span></span></code></pre></div></div>
  </div>
</div><p>Run this with your own credentials restored. In a script, put it in the same
<code>finally</code> as the restore, after it, as the answer block at the top does. The
role is then deleted even when the script under test or the assume fails, and
the error still reaches you.</p>
<p>The temporary keys expire on their own, and the role they belonged to is gone.</p>
<!-- style-check: end procedure -->
<h2 id="expected-result">Expected result<a class="heading-anchor" href="https://looselydecoupled.com/aws-access-denied-test-empty-iam-role/#expected-result"
     aria-label="Link to section: Expected result" data-pagefind-ignore></a>
</h2>
<p>The script&rsquo;s handler runs instead of an unhandled error. Without a handler,
the call fails with the error an under-permissioned identity gets:</p>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Write-CWMetricData:</span> <span class="console__token">User:</span> <span class="console__token">arn:aws:sts::&lt;account-id&gt;:assumed-role/denytest/denytest</span> <span class="console__token">is</span> <span class="console__token">not</span> <span class="console__token">authorized</span> <span class="console__token">to</span> <span class="console__token">perform:</span> <span class="console__token">cloudwatch:PutMetricData</span> <span class="console__token">because</span> <span class="console__token">no</span> <span class="console__token">identity-based</span> <span class="console__token">policy</span> <span class="console__token">allows</span> <span class="console__token">the</span> <span class="console__token">cloudwatch:PutMetricData</span> <span class="console__token">action</span></span></code></pre>
    </div>
  </div></div><p>The message names the assumed role and session, the refused action and the
reason. Replace the account ID before you share it.</p>
<p>After the role is deleted, a second <code>Remove-IAMRole</code> confirms it&rsquo;s gone:</p>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Remove-IAMRole:</span> <span class="console__token">The</span> <span class="console__token">role</span> <span class="console__token">with</span> <span class="console__token">name</span> <span class="console__token">denytest</span> <span class="console__token">cannot</span> <span class="console__token">be</span> <span class="console__token">found.</span></span></code></pre>
    </div>
  </div></div><h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/aws-access-denied-test-empty-iam-role/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="use-stsrole-is-denied"><code>Use-STSRole</code> is denied<a class="heading-anchor" href="https://looselydecoupled.com/aws-access-denied-test-empty-iam-role/#use-stsrole-is-denied"
     aria-label="Link to section: Use-STSRole is denied" data-pagefind-ignore></a>
</h3>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">User:</span> <span class="console__token">arn:aws:iam::&lt;account-id&gt;:user/&lt;user-name&gt;</span> <span class="console__token">is</span> <span class="console__token">not</span> <span class="console__token">authorized</span> <span class="console__token">to</span> <span class="console__token">perform:</span> <span class="console__token">sts:AssumeRole</span> <span class="console__token">on</span> <span class="console__token">resource:</span> <span class="console__token">arn:aws:iam::&lt;account-id&gt;:role/denytest</span></span></code></pre>
    </div>
  </div></div><p>STS prints the same message whether the role was created seconds ago, doesn&rsquo;t
exist, or exists but won&rsquo;t let you in, so the message alone doesn&rsquo;t say which.
Check them in that order: let the backoff run after <code>New-IAMRole</code>, confirm
the role with <code>Get-IAMRole -RoleName denytest</code>, then confirm your own policy
allows <code>sts:AssumeRole</code> on it. The inner exception is an
<code>Amazon.SecurityToken.AmazonSecurityTokenServiceException</code> with <code>ErrorCode</code>
<code>AccessDenied</code>, wrapped the same way as CloudWatch&rsquo;s.</p>
<h3 id="errorcode-is-empty-in-the-catch-block"><code>ErrorCode</code> is empty in the catch block<a class="heading-anchor" href="https://looselydecoupled.com/aws-access-denied-test-empty-iam-role/#errorcode-is-empty-in-the-catch-block"
     aria-label="Link to section: ErrorCode is empty in the catch block" data-pagefind-ignore></a>
</h3>
<p><code>$_.Exception</code> is the <code>InvalidOperationException</code> wrapper, which carries no
AWS fields. Read <code>ErrorCode</code>, <code>StatusCode</code> and <code>Message</code> from
<code>$_.Exception.InnerException</code>. A typed
<code>catch [Amazon.CloudWatch.AmazonCloudWatchException]</code> does match the wrapped
error, so the type works as a filter.</p>
<h3 id="script-still-runs-with-full-access">Script still runs with full access<a class="heading-anchor" href="https://looselydecoupled.com/aws-access-denied-test-empty-iam-role/#script-still-runs-with-full-access"
     aria-label="Link to section: Script still runs with full access" data-pagefind-ignore></a>
</h3>
<p>The script sets its own credentials, with <code>-ProfileName</code> or
<code>Set-AWSCredential</code> inside it, which override the session default. If the
script takes a credential parameter, pass <code>$keys</code> to it; otherwise run its calls
with <code>-Credential $keys</code>.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>PowerShell</dt> <dd>7.6.6</dd></div>
    <div class="tested__row"><dt>AWS.Tools</dt> <dd>5.0.311</dd></div>
    <div class="tested__row"><dt>OS</dt> <dd>macOS 27.0</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
    <item>
      <title>Why Moving $null to the Left Side Broke My PowerShell Script</title>
      <link>https://looselydecoupled.com/powershell-null-left-side-comparison-array/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/powershell-null-left-side-comparison-array/</guid>
      <pubDate>Sat, 26 Sep 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>PowerShell</category>
      <category>Code quality</category>
      
      <description>The analyzer&#39;s fix is safe only where code meant a null check; this function relied on the array filter in ways only empty and nearly empty inputs reveal.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>PSScriptAnalyzer&rsquo;s <code>PSPossibleIncorrectComparisonWithNull</code> rule is right for
most code. When the left side of <code>-ne</code> is an array, PowerShell filters the array instead
of returning true or false. These two tests ask different questions:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="c"># Does $items hold at least one item that is not null?</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nv">$items</span> <span class="o">-ne</span> <span class="vm">$null</span><span class="p">)</span> <span class="p">{</span> <span class="s1">&#39;has items&#39;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c"># Is the variable itself something other than null?</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="vm">$null</span> <span class="o">-ne</span> <span class="nv">$items</span><span class="p">)</span> <span class="p">{</span> <span class="s1">&#39;not null&#39;</span> <span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p>An empty array fails the first test and passes the second. If the code you are
cleaning up relies on the first, state that meaning in a form the analyzer
accepts. It differs in one case: when the filter leaves exactly one item and
that item is falsy, such as <code>0</code>, <code>''</code>, <code>$false</code> or an empty array. That case
is covered below:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="kd">function</span><span class="w"> </span><span class="nb">Test-HasItem</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">param</span><span class="p">(</span><span class="nv">$Value</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="vm">@</span><span class="p">(</span><span class="nv">$Value</span> <span class="p">|</span> <span class="nb">Where-Object</span> <span class="p">{</span> <span class="vm">$null</span> <span class="o">-ne</span> <span class="nv">$_</span> <span class="p">}).</span><span class="py">Count</span> <span class="o">-gt</span> <span class="mf">0</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nb">Test-HasItem</span> <span class="nv">$items</span><span class="p">)</span> <span class="p">{</span> <span class="s1">&#39;has items&#39;</span> <span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div>
</div>

<p>I ran into this cleaning up old PowerShell that someone else wrote. The plan
was mechanical: run PSScriptAnalyzer and take its suggestions one at a time.
One of them came from the rule <code>PSPossibleIncorrectComparisonWithNull</code>:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$null should be on the left side of equality comparisons.</span></span></code></pre></div></div>
  </div>
</div><p>I flipped the comparisons, and the warning went away. So did part of what the
function did. The original author had written <code>$array -ne $null</code> on purpose,
and the function depended on the behavior that the rule exists to warn about.</p>
<p>That&rsquo;s why I wanted to write it up. Nothing here is a PowerShell bug, and the
rule gives good advice for most code. This code was the exception: the author
meant what they wrote, and fixing it broke it.</p>
<!-- style-check: procedure -->
<h2 id="reproduce-the-difference">Reproduce the difference<a class="heading-anchor" href="https://looselydecoupled.com/powershell-null-left-side-comparison-array/#reproduce-the-difference"
     aria-label="Link to section: Reproduce the difference" data-pagefind-ignore></a>
</h2>
<p>In Windows PowerShell 5.1 or PowerShell 7, run:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">$values</span> <span class="console__token">=</span> <span class="console__token">@()</span>
</span><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">if</span> <span class="console__token">($values</span> <span class="console__token">-ne</span> <span class="console__token">$null)</span> <span class="console__token">{</span> <span class="console__token">&#39;has</span> <span class="console__token">values&#39;</span> <span class="console__token">}</span> <span class="console__token">else</span> <span class="console__token">{</span> <span class="console__token">&#39;nothing</span> <span class="console__token">to</span> <span class="console__token">add&#39;</span> <span class="console__token">}</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">nothing</span> <span class="console__token">to</span> <span class="console__token">add</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">if</span> <span class="console__token">($null</span> <span class="console__token">-ne</span> <span class="console__token">$values)</span> <span class="console__token">{</span> <span class="console__token">&#39;has</span> <span class="console__token">values&#39;</span> <span class="console__token">}</span> <span class="console__token">else</span> <span class="console__token">{</span> <span class="console__token">&#39;nothing</span> <span class="console__token">to</span> <span class="console__token">add&#39;</span> <span class="console__token">}</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">has</span> <span class="console__token">values</span></span></code></pre>
    </div>
  </div></div><p>The same variable, compared with the same operator, gives opposite answers.</p>
<!-- style-check: end procedure -->
<h2 id="why-the-two-comparisons-disagree">Why the two comparisons disagree<a class="heading-anchor" href="https://looselydecoupled.com/powershell-null-left-side-comparison-array/#why-the-two-comparisons-disagree"
     aria-label="Link to section: Why the two comparisons disagree" data-pagefind-ignore></a>
</h2>
<p>An equality or matching operator with a collection on its left filters the
collection. It
returns every item for which the comparison is true, as an array.
<a href="https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_comparison_operators" rel="external noopener" target="_blank">about_Comparison_Operators<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/powershell-null-left-side-comparison-array/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
documents this for every equality and matching operator.</p>
<p>So <code>@() -ne $null</code> returns an empty array, and <code>if</code> converts an empty array to
<code>$false</code>. <code>$null -ne @()</code> has a scalar on the left, so it compares once and
returns <code>$true</code>, because an empty array isn&rsquo;t <code>$null</code>.</p>
<p>That&rsquo;s why the analyzer asks for <code>$null</code> on the left: most code that writes
<code>$x -ne $null</code> means &ldquo;is <code>$x</code> null?&rdquo;, and gets a filter instead when <code>$x</code>
happens to be an array. The rule&rsquo;s own
<a href="https://learn.microsoft.com/en-us/powershell/utility-modules/psscriptanalyzer/rules/possibleincorrectcomparisonwithnull" rel="external noopener" target="_blank">documentation<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/powershell-null-left-side-comparison-array/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
says the same. The rule is correct about that code. It can&rsquo;t tell that
somebody&rsquo;s code wanted the filter.</p>
<p>The filter has a second property, which matters later. <code>if</code> treats an array
that holds exactly one item as that item. <code>@(0) -ne $null</code> returns <code>@(0)</code>, and
<code>if (@(0))</code> is <code>$false</code> because <code>0</code> is.</p>
<h2 id="what-the-original-code-meant">What the original code meant<a class="heading-anchor" href="https://looselydecoupled.com/powershell-null-left-side-comparison-array/#what-the-original-code-meant"
     aria-label="Link to section: What the original code meant" data-pagefind-ignore></a>
</h2>
<p>The function built combinations. Each call took the rows built so far and a set
of values, and appended every value to every row. The two comparisons that the
analyzer flagged are highlighted:</p>
<div class="code-block" data-copyable data-copy-label="Add-ToAll.ps1">
  <div class="code-block__head"><span class="code-block__lang code-block__file">Add-ToAll.ps1</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="kd">function</span><span class="w"> </span><span class="nb">Add-ToAll</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">param</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="p">[</span><span class="nb">Parameter</span><span class="p">(</span><span class="na">Mandatory</span><span class="p">)]</span> <span class="nv">$currentResult</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">[</span><span class="nb">Parameter</span><span class="p">(</span><span class="na">Mandatory</span><span class="p">)]</span> <span class="nv">$valuesToAdd</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="nv">$returnValue</span> <span class="p">=</span> <span class="vm">@</span><span class="p">()</span>
</span></span><span class="line hl"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nv">$valuesToAdd</span> <span class="o">-ne</span> <span class="vm">$null</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">foreach</span> <span class="p">(</span><span class="nv">$value</span> <span class="k">in</span> <span class="nv">$valuesToAdd</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line hl"><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="nv">$currentResult</span> <span class="o">-ne</span> <span class="vm">$null</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="k">foreach</span> <span class="p">(</span><span class="nv">$result</span> <span class="k">in</span> <span class="nv">$currentResult</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                    <span class="nv">$newResult</span> <span class="p">=</span> <span class="vm">@</span><span class="p">(</span><span class="nv">$result</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">                    <span class="nv">$newResult</span> <span class="p">+=</span> <span class="p">,</span><span class="nv">$value</span>
</span></span><span class="line"><span class="cl">                    <span class="nv">$returnValue</span> <span class="p">+=</span> <span class="p">,</span><span class="nv">$newResult</span>
</span></span><span class="line"><span class="cl">                <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="nv">$returnValue</span> <span class="p">+=</span> <span class="p">,</span><span class="vm">@</span><span class="p">(,</span><span class="nv">$value</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$returnValue</span> <span class="p">=</span> <span class="nv">$currentResult</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="p">,</span><span class="nv">$returnValue</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p>Both comparisons use the filter on purpose:</p>
<ul>
<li><code>$valuesToAdd -ne $null</code> is false for an empty array, so there&rsquo;s nothing to
add and the rows come back unchanged.</li>
<li><code>$currentResult -ne $null</code> is false for an empty array, so there are no rows
yet and each value starts one.</li>
</ul>
<p>The first call passes <code>@()</code> as <code>$currentResult</code> to start the chain. With the
operands flipped, <code>$null -ne @()</code> is true, the inner loop runs over nothing,
and the first call returns an empty array. Every later call appends to no rows
and also returns nothing.</p>
<p>Each version, run on the same inputs, with its result as JSON:</p>
<div class="table-wrap" data-scrollable data-label="Table: Rows so far">
  <table>
    <thead>
      <tr><th scope="col">Rows so far</th><th scope="col">Values to add</th><th scope="col">Original returns</th><th scope="col">Flipped returns</th></tr>
    </thead>
    <tbody>
      <tr><td><code>@()</code></td><td><code>@()</code></td><td><code>[]</code></td><td><code>[]</code></td></tr>
      <tr><td><code>@()</code></td><td><code>@('A', 'B')</code></td><td><code>[[&quot;A&quot;],[&quot;B&quot;]]</code></td><td><code>[]</code></td></tr>
      <tr><td><code>@(@('X'), @('Y'))</code></td><td><code>@()</code></td><td><code>[[&quot;X&quot;],[&quot;Y&quot;]]</code></td><td><code>[]</code></td></tr>
      <tr><td><code>@(@('X'), @('Y'))</code></td><td><code>@($null)</code></td><td><code>[[&quot;X&quot;],[&quot;Y&quot;]]</code></td><td><code>[[&quot;X&quot;,null],[&quot;Y&quot;,null]]</code></td></tr>
      <tr><td><code>@(,@('X'))</code></td><td><code>'A'</code></td><td><code>[[&quot;X&quot;,&quot;A&quot;]]</code></td><td><code>[[&quot;X&quot;,&quot;A&quot;]]</code></td></tr>
      <tr><td><code>@(@('X'), @('Y'))</code></td><td><code>@(0)</code></td><td><code>[[&quot;X&quot;],[&quot;Y&quot;]]</code></td><td><code>[[&quot;X&quot;,0],[&quot;Y&quot;,0]]</code></td></tr>
    </tbody>
  </table>
</div>
<p>Two rows still agree: both inputs empty, and the ordinary case, a real value
added to rows that exist. A test suite built from typical inputs passes, and the change surfaces in
production, on the first empty input.</p>
<!-- style-check: procedure -->
<h2 id="pin-the-current-behavior-in-a-pester-test">Pin the current behavior in a Pester test<a class="heading-anchor" href="https://looselydecoupled.com/powershell-null-left-side-comparison-array/#pin-the-current-behavior-in-a-pester-test"
     aria-label="Link to section: Pin the current behavior in a Pester test" data-pagefind-ignore></a>
</h2>
<p>The analyzer&rsquo;s fix looked safe because nothing checked what the function
returned. Before you change inherited code, write a characterization test: run
the current code on chosen inputs and assert what it returns now, whether or
not that looks right. A refactor then has to keep every answer or change one
on purpose.</p>
<p>This test for Pester 5, the PowerShell test framework, turns the earlier table
into cases. Save the function as <code>Add-ToAll.ps1</code> in the same folder:</p>
<div class="code-block" data-copyable data-copy-label="Add-ToAll.Tests.ps1">
  <div class="code-block__head"><span class="code-block__lang code-block__file">Add-ToAll.Tests.ps1</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="n">BeforeAll</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="p">.</span> <span class="nv">$PSScriptRoot</span><span class="p">/</span><span class="nb">Add-ToAll</span><span class="p">.</span><span class="py">ps1</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">Describe</span> <span class="s1">&#39;Add-ToAll&#39;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">It</span> <span class="s1">&#39;&lt;Name&gt;&#39;</span> <span class="n">-TestCases</span> <span class="vm">@</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="vm">@</span><span class="p">{</span> <span class="n">Name</span> <span class="p">=</span> <span class="s1">&#39;both empty&#39;</span><span class="p">;</span> <span class="n">Json</span> <span class="p">=</span> <span class="s1">&#39;[]&#39;</span>
</span></span><span class="line"><span class="cl">           <span class="n">Rows</span> <span class="p">=</span> <span class="vm">@</span><span class="p">();</span> <span class="n">Values</span> <span class="p">=</span> <span class="vm">@</span><span class="p">()</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="vm">@</span><span class="p">{</span> <span class="n">Name</span> <span class="p">=</span> <span class="s1">&#39;start from empty&#39;</span><span class="p">;</span> <span class="n">Json</span> <span class="p">=</span> <span class="s1">&#39;[[&#34;A&#34;],[&#34;B&#34;]]&#39;</span>
</span></span><span class="line"><span class="cl">           <span class="n">Rows</span> <span class="p">=</span> <span class="vm">@</span><span class="p">();</span> <span class="n">Values</span> <span class="p">=</span> <span class="vm">@</span><span class="p">(</span><span class="s1">&#39;A&#39;</span><span class="p">,</span> <span class="s1">&#39;B&#39;</span><span class="p">)</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="vm">@</span><span class="p">{</span> <span class="n">Name</span> <span class="p">=</span> <span class="s1">&#39;nothing to add&#39;</span><span class="p">;</span> <span class="n">Json</span> <span class="p">=</span> <span class="s1">&#39;[[&#34;X&#34;],[&#34;Y&#34;]]&#39;</span>
</span></span><span class="line"><span class="cl">           <span class="n">Rows</span> <span class="p">=</span> <span class="vm">@</span><span class="p">(</span><span class="vm">@</span><span class="p">(</span><span class="s1">&#39;X&#39;</span><span class="p">),</span> <span class="vm">@</span><span class="p">(</span><span class="s1">&#39;Y&#39;</span><span class="p">));</span> <span class="n">Values</span> <span class="p">=</span> <span class="vm">@</span><span class="p">()</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="vm">@</span><span class="p">{</span> <span class="n">Name</span> <span class="p">=</span> <span class="s1">&#39;add null&#39;</span><span class="p">;</span> <span class="n">Json</span> <span class="p">=</span> <span class="s1">&#39;[[&#34;X&#34;],[&#34;Y&#34;]]&#39;</span>
</span></span><span class="line"><span class="cl">           <span class="n">Rows</span> <span class="p">=</span> <span class="vm">@</span><span class="p">(</span><span class="vm">@</span><span class="p">(</span><span class="s1">&#39;X&#39;</span><span class="p">),</span> <span class="vm">@</span><span class="p">(</span><span class="s1">&#39;Y&#39;</span><span class="p">));</span> <span class="n">Values</span> <span class="p">=</span> <span class="vm">@</span><span class="p">(</span><span class="vm">$null</span><span class="p">)</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="vm">@</span><span class="p">{</span> <span class="n">Name</span> <span class="p">=</span> <span class="s1">&#39;one value&#39;</span><span class="p">;</span> <span class="n">Json</span> <span class="p">=</span> <span class="s1">&#39;[[&#34;X&#34;,&#34;A&#34;]]&#39;</span>
</span></span><span class="line"><span class="cl">           <span class="n">Rows</span> <span class="p">=</span> <span class="vm">@</span><span class="p">(,</span><span class="vm">@</span><span class="p">(</span><span class="s1">&#39;X&#39;</span><span class="p">));</span> <span class="n">Values</span> <span class="p">=</span> <span class="s1">&#39;A&#39;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="vm">@</span><span class="p">{</span> <span class="n">Name</span> <span class="p">=</span> <span class="s1">&#39;a single zero&#39;</span><span class="p">;</span> <span class="n">Json</span> <span class="p">=</span> <span class="s1">&#39;[[&#34;X&#34;],[&#34;Y&#34;]]&#39;</span>
</span></span><span class="line"><span class="cl">           <span class="n">Rows</span> <span class="p">=</span> <span class="vm">@</span><span class="p">(</span><span class="vm">@</span><span class="p">(</span><span class="s1">&#39;X&#39;</span><span class="p">),</span> <span class="vm">@</span><span class="p">(</span><span class="s1">&#39;Y&#39;</span><span class="p">));</span> <span class="n">Values</span> <span class="p">=</span> <span class="vm">@</span><span class="p">(</span><span class="mf">0</span><span class="p">)</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$result</span> <span class="p">=</span> <span class="nb">Add-ToAll</span> <span class="n">-currentResult</span> <span class="nv">$Rows</span> <span class="n">-valuesToAdd</span> <span class="nv">$Values</span>
</span></span><span class="line"><span class="cl">        <span class="nb">ConvertTo-Json</span> <span class="n">-InputObject</span> <span class="nv">$result</span> <span class="n">-Compress</span> <span class="p">|</span> <span class="n">Should</span> <span class="n">-Be</span> <span class="nv">$Json</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p>The cases that matter are the ones the old comparison treated specially: an
empty array, an array of nulls and a single falsy value. They are where a
flipped comparison changes the result, and they are the inputs a typical test
suite leaves out.</p>
<p>Run <code>Invoke-Pester ./Add-ToAll.Tests.ps1</code> before you touch the function. Every
case passes, because each expected value was copied from the original&rsquo;s
output. Flip the two comparisons and four cases fail: start from empty,
nothing to add, add null and a single zero.</p>
<div class="admonition admonition--note" role="note">
  <p class="admonition__label">
    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/powershell-null-left-side-comparison-array/#i-info"/></svg>
    <span>Note</span>
  </p>
  <p>Windows PowerShell 5.1 ships with Pester 3.4, which runs this file
differently. Install Pester 5 first; the
<a href="https://pester.dev/docs/introduction/installation" rel="external noopener" target="_blank">Pester installation guide<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/powershell-null-left-side-comparison-array/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
covers replacing the built-in version.</p>
</div><p>Each case compares JSON text instead of the array itself. Pass the result to
<code>ConvertTo-Json</code> with <code>-InputObject</code>, not through the pipeline:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">@(,@(&#39;X&#39;,&#39;A&#39;))</span> <span class="console__token">|</span> <span class="console__token">ConvertTo-Json</span> <span class="console__token">-Compress</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">[&#34;X&#34;,&#34;A&#34;]</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">@(&#39;X&#39;,&#39;A&#39;)</span> <span class="console__token">|</span> <span class="console__token">ConvertTo-Json</span> <span class="console__token">-Compress</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">[&#34;X&#34;,&#34;A&#34;]</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">ConvertTo-Json</span> <span class="console__token">-InputObject</span> <span class="console__token">@(,@(&#39;X&#39;,&#39;A&#39;))</span> <span class="console__token">-Compress</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">[[&#34;X&#34;,&#34;A&#34;]]</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">ConvertTo-Json</span> <span class="console__token">-InputObject</span> <span class="console__token">@(&#39;X&#39;,&#39;A&#39;)</span> <span class="console__token">-Compress</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">[&#34;X&#34;,&#34;A&#34;]</span></span></code></pre>
    </div>
  </div></div><p>That output is PowerShell 7. Windows PowerShell 5.1 prints the first line as
<code>{&quot;value&quot;:[&quot;X&quot;,&quot;A&quot;],&quot;Count&quot;:2}</code>, which is one more reason to use
<code>-InputObject</code>.</p>
<p>The pipeline sends the items of an array, not the array, and that loses
information in two ways:</p>
<ul>
<li>A result with one row loses a level of nesting. One row, <code>[[&quot;X&quot;,&quot;A&quot;]]</code>, and
a flat row, <code>[&quot;X&quot;,&quot;A&quot;]</code>, serialize to the same text, so a refactor that
returned the wrong shape would pass.</li>
<li>An empty result sends nothing, so <code>ConvertTo-Json</code> writes nothing. With
<code>-InputObject</code>, the same result is written as <code>[]</code>.</li>
</ul>
<p>When you write the cases, two inputs look different and are not:</p>
<ul>
<li><code>@(@())</code> is an empty array, not an array that holds one empty array. The
array subexpression operator does not nest. Write <code>,@()</code> for the nested one.</li>
<li><code>@(@('X'))</code> is <code>@('X')</code>, for the same reason. Write <code>@(,@('X'))</code>.</li>
</ul>
<h2 id="make-the-intent-explicit">Make the intent explicit<a class="heading-anchor" href="https://looselydecoupled.com/powershell-null-left-side-comparison-array/#make-the-intent-explicit"
     aria-label="Link to section: Make the intent explicit" data-pagefind-ignore></a>
</h2>
<p>Replace each filtering comparison with a test that says what it checks. Keep
the function&rsquo;s structure and change only the two conditions:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="c"># True when $Value holds at least one item that is not null. An empty array</span>
</span></span><span class="line"><span class="cl"><span class="c"># and an array of nulls hold none, which is what `$Value -ne $null` tested.</span>
</span></span><span class="line"><span class="cl"><span class="kd">function</span><span class="w"> </span><span class="nb">Test-HasItem</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">param</span><span class="p">(</span><span class="nv">$Value</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="vm">@</span><span class="p">(</span><span class="nv">$Value</span> <span class="p">|</span> <span class="nb">Where-Object</span> <span class="p">{</span> <span class="vm">$null</span> <span class="o">-ne</span> <span class="nv">$_</span> <span class="p">}).</span><span class="py">Count</span> <span class="o">-gt</span> <span class="mf">0</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nb">Test-HasItem</span> <span class="nv">$valuesToAdd</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">foreach</span> <span class="p">(</span><span class="nv">$value</span> <span class="k">in</span> <span class="nv">$valuesToAdd</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="nb">Test-HasItem</span> <span class="nv">$currentResult</span><span class="p">)</span> <span class="p">{</span></span></span></code></pre></div></div>
  </div>
</div><p>PSScriptAnalyzer reports nothing for this version. Run the test again and
one case fails: a single zero. Given <code>@(0)</code>, the explicit version returns
<code>[[&quot;X&quot;,0],[&quot;Y&quot;,0]]</code>, and the original returned the rows unchanged.</p>
<p>The original treated <code>@(0)</code> as nothing to add, because an array with one item
converts to that item and <code>0</code> is false. The same is true of <code>@('')</code>,
<code>@($false)</code> and <code>@(,@())</code>, an array holding one empty array, and of any input
the filter reduces to one such item, such as <code>@($null, 0)</code>. That is the one-item rule from earlier, not a decision anyone
made, and the explicit version drops it. If your data can contain a single
zero, empty string, <code>$false</code> or empty array, decide which behavior is correct. Change that
case&rsquo;s expected value only if the new behavior is the one you want.</p>
<!-- style-check: end procedure -->
<h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/powershell-null-left-side-comparison-array/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<p><strong>The warning is on <code>-eq</code>, not <code>-ne</code>.</strong> The same filter applies.
<code>@($null, $null) -eq $null</code> returns two nulls, and an array with two items is
true in an <code>if</code>, even though both items are null.</p>
<p><strong>The left side is never an array.</strong> Then flipping the comparison changes
nothing, and it guards against the day it becomes one. Check where the value
comes from: a cmdlet that returns one object returns it bare, and one that
returns several returns an array.</p>
<p><strong>You want to keep the old line and silence the rule.</strong> Suppress it on that
function with a reason, so the next person reads why:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="p">[</span><span class="n">Diagnostics</span><span class="p">.</span><span class="py">CodeAnalysis</span><span class="p">.</span><span class="py">SuppressMessageAttribute</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;PSPossibleIncorrectComparisonWithNull&#39;</span><span class="p">,</span> <span class="s1">&#39;&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">Justification</span> <span class="p">=</span> <span class="s1">&#39;Empty array means nothing to add.&#39;</span><span class="p">)]</span></span></span></code></pre></div></div>
  </div>
</div><p>The attribute goes above the function&rsquo;s <code>param()</code> block. The explicit test is
still the better fix, because the reason then lives in the code rather than in
a comment about it.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>Windows PowerShell</dt> <dd>5.1 on Windows</dd></div>
    <div class="tested__row"><dt>PowerShell</dt> <dd>7 on macOS, 7.4.20 on Ubuntu 24.04</dd></div>
    <div class="tested__row"><dt>PSScriptAnalyzer</dt> <dd>1.23.0, 1.25.0</dd></div>
    <div class="tested__row"><dt>Pester</dt> <dd>5.9.1</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>September 2026</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
    <item>
      <title>Update a 7 Days to Die Dedicated Server Only When There Is a New Build</title>
      <link>https://looselydecoupled.com/update-7-days-to-die-server-only-on-new-build/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/update-7-days-to-die-server-only-on-new-build/</guid>
      <pubDate>Thu, 24 Sep 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Windows Server</category>
      <category>PowerShell</category>
      <category>Automation</category>
      
      <description>Compare the buildid in the appmanifest with SteamCMD&#39;s app_info_print and run app_update only when they differ. Success! and exit code 0 prove nothing.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Read the installed build number from <code>steamapps\appmanifest_294420.acf</code>, read the published one from SteamCMD&rsquo;s <code>app_info_print</code>, and run <code>app_update</code> only when they differ.</p>
<p>Check the result by reading the installed build again, because SteamCMD can print <code>Success!</code> and exit with code <code>0</code> without changing the build. It did on a server installed from a branch, until <code>-beta public</code> was added to the command.</p>
<p>In PowerShell on the server, this reads both numbers. It prints the build you have, and the one Steam publishes on the <code>public</code> branch:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$manifest</span> <span class="p">=</span> <span class="s1">&#39;C:\7DaysToDieServer\steamapps\appmanifest_294420.acf&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$installed</span> <span class="p">=</span> <span class="p">(</span><span class="nb">Select-String</span> <span class="n">-LiteralPath</span> <span class="nv">$manifest</span> <span class="n">-Pattern</span> <span class="s1">&#39;^\t&#34;buildid&#34;\s+&#34;(\d+)&#34;&#39;</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Select-Object</span> <span class="n">-First</span> <span class="mf">1</span><span class="p">).</span><span class="n">Matches</span><span class="p">[</span><span class="mf">0</span><span class="p">].</span><span class="n">Groups</span><span class="p">[</span><span class="mf">1</span><span class="p">].</span><span class="py">Value</span>
</span></span><span class="line"><span class="cl"><span class="nv">$arguments</span> <span class="p">=</span> <span class="s1">&#39;+login&#39;</span><span class="p">,</span> <span class="s1">&#39;anonymous&#39;</span><span class="p">,</span> <span class="s1">&#39;+app_info_update&#39;</span><span class="p">,</span> <span class="s1">&#39;1&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;+app_info_print&#39;</span><span class="p">,</span> <span class="s1">&#39;294420&#39;</span><span class="p">,</span> <span class="s1">&#39;+quit&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$info</span> <span class="p">=</span> <span class="p">(&amp;</span> <span class="s1">&#39;C:\steamcmd\steamcmd.exe&#39;</span> <span class="nv">$arguments</span> <span class="mf">2</span><span class="p">&gt;&amp;</span><span class="mf">1</span><span class="p">)</span> <span class="n">-join</span> <span class="s2">&#34;</span><span class="se">`n</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$pattern</span> <span class="p">=</span> <span class="s1">&#39;(?s)&#34;branches&#34;\s*\{.*?&#34;public&#34;\s*\{[^}]*?&#34;buildid&#34;\s+&#34;(\d+)&#34;&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$published</span> <span class="p">=</span> <span class="p">[</span><span class="no">regex</span><span class="p">]::</span><span class="n">Match</span><span class="p">(</span><span class="nv">$info</span><span class="p">,</span> <span class="nv">$pattern</span><span class="p">).</span><span class="n">Groups</span><span class="p">[</span><span class="mf">1</span><span class="p">].</span><span class="py">Value</span>
</span></span><span class="line"><span class="cl"><span class="s2">&#34;installed </span><span class="nv">$installed</span><span class="s2">, published </span><span class="nv">$published</span><span class="s2">&#34;</span></span></span></code></pre></div></div>
  </div>
</div><p>The game-specific parts are the app ID, <code>294420</code>, and the file that holds the build number. Step 4 has the whole update as one function.</p>

</div>

<p>The previous parts installed the server, configured it, and made it start at boot and stop cleanly. This part keeps it current without restarting it for nothing. A server that runs <code>app_update</code> every morning restarts on the days when Steam has published nothing, and its players lose the session for no reason.</p>
<!-- style-check: procedure -->
<h2 id="1-read-the-installed-build-from-appmanifest_294420acf">1. Read the installed build from appmanifest_294420.acf<a class="heading-anchor" href="https://looselydecoupled.com/update-7-days-to-die-server-only-on-new-build/#1-read-the-installed-build-from-appmanifest_294420acf"
     aria-label="Link to section: 1. Read the installed build from appmanifest_294420.acf" data-pagefind-ignore></a>
</h2>
<p>SteamCMD records what it installed in <code>steamapps\appmanifest_294420.acf</code>, inside the server&rsquo;s folder. The file is Valve&rsquo;s KeyValues text, and the installed build is the top-level <code>buildid</code> line:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">$manifest</span> <span class="console__token">=</span> <span class="console__token">&#39;C:\7DaysToDieServer\steamapps\appmanifest_294420.acf&#39;</span>
</span><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">Select-String</span> <span class="console__token">-Path</span> <span class="console__token">$manifest</span> <span class="console__token">-Pattern</span> <span class="console__token">&#39;buildid&#39;</span> <span class="console__token">|</span> <span class="console__token">ForEach-Object</span> <span class="console__token">Line</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line">    <span class="console__token">&#34;buildid&#34;</span>        <span class="console__token">&#34;24994542&#34;</span>
</span><span class="line">    <span class="console__token">&#34;TargetBuildID&#34;</span>        <span class="console__token">&#34;24994542&#34;</span></span></code></pre>
    </div>
  </div></div><p><code>TargetBuildID</code> is a different key. The pattern in the next function has quotes around <code>buildid</code>, so it doesn&rsquo;t match that line. It also requires one leading tab, which makes it the top-level key and not one nested deeper. The first function reads the number and returns nothing when the file or the line is missing. The second reads the branch the install came from, which the manifest records as <code>BetaKey</code>, and gives <code>public</code> when there is none:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="kd">function</span><span class="w"> </span><span class="nb">Get-InstalledBuild</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">param</span><span class="p">([</span><span class="no">string</span><span class="p">]</span><span class="nv">$ServerDir</span> <span class="p">=</span> <span class="s1">&#39;C:\7DaysToDieServer&#39;</span><span class="p">,</span> <span class="p">[</span><span class="no">string</span><span class="p">]</span><span class="nv">$AppId</span> <span class="p">=</span> <span class="s1">&#39;294420&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$manifest</span> <span class="p">=</span> <span class="nb">Join-Path</span> <span class="nv">$ServerDir</span> <span class="s2">&#34;steamapps\appmanifest_</span><span class="nv">$AppId</span><span class="s2">.acf&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">-not</span> <span class="p">(</span><span class="nb">Test-Path</span> <span class="n">-LiteralPath</span> <span class="nv">$manifest</span><span class="p">))</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$match</span> <span class="p">=</span> <span class="nb">Select-String</span> <span class="n">-LiteralPath</span> <span class="nv">$manifest</span> <span class="n">-Pattern</span> <span class="s1">&#39;^\t&#34;buildid&#34;\s+&#34;(\d+)&#34;&#39;</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">        <span class="nb">Select-Object</span> <span class="n">-First</span> <span class="mf">1</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nv">$match</span><span class="p">)</span> <span class="p">{</span> <span class="nv">$match</span><span class="p">.</span><span class="n">Matches</span><span class="p">[</span><span class="mf">0</span><span class="p">].</span><span class="n">Groups</span><span class="p">[</span><span class="mf">1</span><span class="p">].</span><span class="py">Value</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">function</span><span class="w"> </span><span class="nb">Get-InstalledBranch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">param</span><span class="p">([</span><span class="no">string</span><span class="p">]</span><span class="nv">$ServerDir</span> <span class="p">=</span> <span class="s1">&#39;C:\7DaysToDieServer&#39;</span><span class="p">,</span> <span class="p">[</span><span class="no">string</span><span class="p">]</span><span class="nv">$AppId</span> <span class="p">=</span> <span class="s1">&#39;294420&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$manifest</span> <span class="p">=</span> <span class="nb">Join-Path</span> <span class="nv">$ServerDir</span> <span class="s2">&#34;steamapps\appmanifest_</span><span class="nv">$AppId</span><span class="s2">.acf&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">-not</span> <span class="p">(</span><span class="nb">Test-Path</span> <span class="n">-LiteralPath</span> <span class="nv">$manifest</span><span class="p">))</span> <span class="p">{</span> <span class="k">return</span> <span class="s1">&#39;public&#39;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$match</span> <span class="p">=</span> <span class="nb">Select-String</span> <span class="n">-LiteralPath</span> <span class="nv">$manifest</span> <span class="n">-Pattern</span> <span class="s1">&#39;&#34;BetaKey&#34;\s+&#34;([^&#34;]+)&#34;&#39;</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">        <span class="nb">Select-Object</span> <span class="n">-First</span> <span class="mf">1</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nv">$match</span><span class="p">)</span> <span class="p">{</span> <span class="nv">$match</span><span class="p">.</span><span class="n">Matches</span><span class="p">[</span><span class="mf">0</span><span class="p">].</span><span class="n">Groups</span><span class="p">[</span><span class="mf">1</span><span class="p">].</span><span class="py">Value</span> <span class="p">}</span> <span class="k">else</span> <span class="p">{</span> <span class="s1">&#39;public&#39;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><h2 id="2-read-the-published-build-from-app_info_print">2. Read the published build from app_info_print<a class="heading-anchor" href="https://looselydecoupled.com/update-7-days-to-die-server-only-on-new-build/#2-read-the-published-build-from-app_info_print"
     aria-label="Link to section: 2. Read the published build from app_info_print" data-pagefind-ignore></a>
</h2>
<p><code>app_info_print</code> prints everything Steam has on the app, about 560 lines for this server. The part that matters is <code>branches</code>, which has one entry per branch, each with its own <code>buildid</code>:</p>
<div class="code-block" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">        &#34;branches&#34;
</span></span><span class="line"><span class="cl">        {
</span></span><span class="line"><span class="cl">            &#34;public&#34;
</span></span><span class="line"><span class="cl">            {
</span></span><span class="line"><span class="cl">                &#34;buildid&#34;        &#34;24994542&#34;
</span></span><span class="line"><span class="cl">                &#34;timeupdated&#34;        &#34;1788196235&#34;
</span></span><span class="line"><span class="cl">                &#34;timebuildupdated&#34;        &#34;1787927331&#34;
</span></span><span class="line"><span class="cl">            }
</span></span><span class="line"><span class="cl">            &#34;v3.2.0&#34;
</span></span><span class="line"><span class="cl">            {
</span></span><span class="line"><span class="cl">                &#34;buildid&#34;        &#34;24994542&#34;
</span></span><span class="line"><span class="cl">                &#34;description&#34;        &#34;Version 3.2.0 Stable&#34;
</span></span><span class="line"><span class="cl">                &#34;timeupdated&#34;        &#34;1788195715&#34;
</span></span><span class="line"><span class="cl">                &#34;timebuildupdated&#34;        &#34;1787927331&#34;
</span></span><span class="line"><span class="cl">            }
</span></span><span class="line"><span class="cl">            &#34;latest_experimental&#34;
</span></span><span class="line"><span class="cl">            {
</span></span><span class="line"><span class="cl">                &#34;buildid&#34;        &#34;25661908&#34;
</span></span><span class="line"><span class="cl">                &#34;description&#34;        &#34;Unstable build&#34;
</span></span><span class="line"><span class="cl">                &#34;timeupdated&#34;        &#34;1790898315&#34;</span></span></code></pre></div></div>
  </div>
</div><p><code>+app_info_update 1</code> makes SteamCMD fetch fresh data before it prints, and the whole lookup took about three seconds here. Take the <code>buildid</code> of the branch you run, which is <code>public</code> unless you chose a beta. The same text holds a <code>public</code> entry under each depot, with a <code>gid</code> and no <code>buildid</code>. A search from the first <code>public</code> therefore depends on where the sections happen to sit. The pattern here starts at <code>branches</code>:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="kd">function</span><span class="w"> </span><span class="nb">Get-PublishedBuild</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">param</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="p">[</span><span class="no">string</span><span class="p">]</span><span class="nv">$SteamCmd</span> <span class="p">=</span> <span class="s1">&#39;C:\steamcmd\steamcmd.exe&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">[</span><span class="no">string</span><span class="p">]</span><span class="nv">$AppId</span> <span class="p">=</span> <span class="s1">&#39;294420&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">[</span><span class="no">string</span><span class="p">]</span><span class="nv">$Branch</span> <span class="p">=</span> <span class="s1">&#39;public&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">-not</span> <span class="p">(</span><span class="nb">Test-Path</span> <span class="n">-LiteralPath</span> <span class="nv">$SteamCmd</span><span class="p">))</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$arguments</span> <span class="p">=</span> <span class="s1">&#39;+login&#39;</span><span class="p">,</span> <span class="s1">&#39;anonymous&#39;</span><span class="p">,</span> <span class="s1">&#39;+app_info_update&#39;</span><span class="p">,</span> <span class="s1">&#39;1&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s1">&#39;+app_info_print&#39;</span><span class="p">,</span> <span class="nv">$AppId</span><span class="p">,</span> <span class="s1">&#39;+quit&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$text</span> <span class="p">=</span> <span class="p">(&amp;</span> <span class="nv">$SteamCmd</span> <span class="nv">$arguments</span> <span class="mf">2</span><span class="p">&gt;&amp;</span><span class="mf">1</span><span class="p">)</span> <span class="n">-join</span> <span class="s2">&#34;</span><span class="se">`n</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$pattern</span> <span class="p">=</span> <span class="s1">&#39;(?s)&#34;branches&#34;\s*\{.*?&#34;&#39;</span> <span class="p">+</span> <span class="p">[</span><span class="no">regex</span><span class="p">]::</span><span class="n">Escape</span><span class="p">(</span><span class="nv">$Branch</span><span class="p">)</span> <span class="p">+</span>
</span></span><span class="line"><span class="cl">        <span class="s1">&#39;&#34;\s*\{[^}]*?&#34;buildid&#34;\s+&#34;(\d+)&#34;&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$match</span> <span class="p">=</span> <span class="p">[</span><span class="no">regex</span><span class="p">]::</span><span class="n">Match</span><span class="p">(</span><span class="nv">$text</span><span class="p">,</span> <span class="nv">$pattern</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nv">$match</span><span class="p">.</span><span class="n">Success</span><span class="p">)</span> <span class="p">{</span> <span class="nv">$match</span><span class="p">.</span><span class="n">Groups</span><span class="p">[</span><span class="mf">1</span><span class="p">].</span><span class="py">Value</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p>It returns nothing when SteamCMD isn&rsquo;t where you said, or when the text has no such branch, which is what a failed connection to Steam gives too. A branch that doesn&rsquo;t exist, <code>nonexistent</code> here, gave an empty result. Branch names are case-sensitive, and a branch that needs a password isn&rsquo;t covered.</p>
<h2 id="3-compare-the-two-builds-and-treat-a-missing-number-as-unknown">3. Compare the two builds, and treat a missing number as unknown<a class="heading-anchor" href="https://looselydecoupled.com/update-7-days-to-die-server-only-on-new-build/#3-compare-the-two-builds-and-treat-a-missing-number-as-unknown"
     aria-label="Link to section: 3. Compare the two builds, and treat a missing number as unknown" data-pagefind-ignore></a>
</h2>
<p>Run both functions:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">Get-InstalledBuild</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">24994542</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">Get-PublishedBuild</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">24994542</span></span></code></pre>
    </div>
  </div></div><p>Equal numbers mean there is nothing to do. When either is empty, you don&rsquo;t know, and the safe answer is to stop and say so, not to guess. The next function does that.</p>
<h2 id="4-update-only-when-they-differ">4. Update only when they differ<a class="heading-anchor" href="https://looselydecoupled.com/update-7-days-to-die-server-only-on-new-build/#4-update-only-when-they-differ"
     aria-label="Link to section: 4. Update only when they differ" data-pagefind-ignore></a>
</h2>
<p>This function does the whole job. When the builds match and the install is already on the branch you want, it returns at once and touches nothing, so a running server keeps running. When they differ it stops the server if one is running, runs <code>app_update</code>, reads the installed build again to check, and starts the server if it was running:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="kd">function</span><span class="w"> </span><span class="nb">Update-GameServer</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">param</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="p">[</span><span class="no">string</span><span class="p">]</span><span class="nv">$ServerDir</span> <span class="p">=</span> <span class="s1">&#39;C:\7DaysToDieServer&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">[</span><span class="no">string</span><span class="p">]</span><span class="nv">$SteamCmd</span> <span class="p">=</span> <span class="s1">&#39;C:\steamcmd\steamcmd.exe&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">[</span><span class="no">string</span><span class="p">]</span><span class="nv">$AppId</span> <span class="p">=</span> <span class="s1">&#39;294420&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">[</span><span class="no">string</span><span class="p">]</span><span class="nv">$Branch</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">[</span><span class="no">scriptblock</span><span class="p">]</span><span class="nv">$StopServer</span> <span class="p">=</span> <span class="p">{</span> <span class="nb">Stop-GameServer</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="p">[</span><span class="no">scriptblock</span><span class="p">]</span><span class="nv">$StartServer</span> <span class="p">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nb">Start-ScheduledTask</span> <span class="n">-TaskName</span> <span class="s1">&#39;7DaysToDieServer&#39;</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nb">Test-Path</span> <span class="n">-LiteralPath</span> <span class="nv">$ServerDir</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$ServerDir</span> <span class="p">=</span> <span class="p">(</span><span class="nb">Resolve-Path</span> <span class="n">-LiteralPath</span> <span class="nv">$ServerDir</span><span class="p">).</span><span class="py">ProviderPath</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">-not</span> <span class="nv">$Branch</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$Branch</span> <span class="p">=</span> <span class="nb">Get-InstalledBranch</span> <span class="n">-ServerDir</span> <span class="nv">$ServerDir</span> <span class="n">-AppId</span> <span class="nv">$AppId</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$installed</span> <span class="p">=</span> <span class="nb">Get-InstalledBuild</span> <span class="n">-ServerDir</span> <span class="nv">$ServerDir</span> <span class="n">-AppId</span> <span class="nv">$AppId</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$published</span> <span class="p">=</span> <span class="nb">Get-PublishedBuild</span> <span class="n">-SteamCmd</span> <span class="nv">$SteamCmd</span> <span class="n">-AppId</span> <span class="nv">$AppId</span> <span class="n">-Branch</span> <span class="nv">$Branch</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Installed build: </span><span class="p">$(</span><span class="k">if</span> <span class="p">(</span><span class="nv">$installed</span><span class="p">)</span> <span class="p">{</span> <span class="nv">$installed</span> <span class="p">}</span> <span class="k">else</span> <span class="p">{</span> <span class="s1">&#39;unknown&#39;</span> <span class="p">})</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Published build: </span><span class="p">$(</span><span class="k">if</span> <span class="p">(</span><span class="nv">$published</span><span class="p">)</span> <span class="p">{</span> <span class="nv">$published</span> <span class="p">}</span> <span class="k">else</span> <span class="p">{</span> <span class="s1">&#39;unknown&#39;</span> <span class="p">})</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">-not</span> <span class="nv">$installed</span> <span class="o">-or</span> <span class="o">-not</span> <span class="nv">$published</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">throw</span> <span class="s1">&#39;Could not read both builds. Nothing was changed.&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$installedBranch</span> <span class="p">=</span> <span class="nb">Get-InstalledBranch</span> <span class="n">-ServerDir</span> <span class="nv">$ServerDir</span> <span class="n">-AppId</span> <span class="nv">$AppId</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$sameBuild</span> <span class="p">=</span> <span class="nv">$installed</span> <span class="o">-eq</span> <span class="nv">$published</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nv">$sameBuild</span> <span class="o">-and</span> <span class="nv">$installedBranch</span> <span class="o">-eq</span> <span class="nv">$Branch</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="s1">&#39;Already on the published build. Nothing to do.&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$exe</span> <span class="p">=</span> <span class="nb">Join-Path</span> <span class="nv">$ServerDir</span> <span class="s1">&#39;7DaysToDieServer.exe&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$wasRunning</span> <span class="p">=</span> <span class="p">[</span><span class="no">bool</span><span class="p">](</span><span class="nb">Get-Process</span> <span class="n">7DaysToDieServer</span> <span class="n">-ErrorAction</span> <span class="n">SilentlyContinue</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">        <span class="nb">Where-Object</span> <span class="p">{</span> <span class="nv">$_</span><span class="p">.</span><span class="py">Path</span> <span class="o">-eq</span> <span class="nv">$exe</span> <span class="p">})</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$restart</span> <span class="p">=</span> <span class="nv">$wasRunning</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nv">$restart</span><span class="p">)</span> <span class="p">{</span> <span class="p">&amp;</span> <span class="nv">$StopServer</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$arguments</span> <span class="p">=</span> <span class="s1">&#39;+force_install_dir&#39;</span><span class="p">,</span> <span class="nv">$ServerDir</span><span class="p">,</span> <span class="s1">&#39;+login&#39;</span><span class="p">,</span> <span class="s1">&#39;anonymous&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s1">&#39;+app_update&#39;</span><span class="p">,</span> <span class="nv">$AppId</span><span class="p">,</span> <span class="s1">&#39;-beta&#39;</span><span class="p">,</span> <span class="nv">$Branch</span><span class="p">,</span> <span class="s1">&#39;validate&#39;</span><span class="p">,</span> <span class="s1">&#39;+quit&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="k">foreach</span> <span class="p">(</span><span class="nv">$attempt</span> <span class="k">in</span> <span class="mf">1</span><span class="p">,</span> <span class="mf">2</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="vm">$null</span> <span class="p">=</span> <span class="p">&amp;</span> <span class="nv">$SteamCmd</span> <span class="nv">$arguments</span> <span class="mf">2</span><span class="p">&gt;&amp;</span><span class="mf">1</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$after</span> <span class="p">=</span> <span class="nb">Get-InstalledBuild</span> <span class="n">-ServerDir</span> <span class="nv">$ServerDir</span> <span class="n">-AppId</span> <span class="nv">$AppId</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$now</span> <span class="p">=</span> <span class="nb">Get-InstalledBranch</span> <span class="n">-ServerDir</span> <span class="nv">$ServerDir</span> <span class="n">-AppId</span> <span class="nv">$AppId</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$onBranch</span> <span class="p">=</span> <span class="nv">$now</span> <span class="o">-eq</span> <span class="nv">$Branch</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="nv">$after</span> <span class="o">-eq</span> <span class="nv">$published</span> <span class="o">-and</span> <span class="nv">$onBranch</span><span class="p">)</span> <span class="p">{</span> <span class="k">break</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="c"># Steam may have published another build since the first lookup.</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$published</span> <span class="p">=</span> <span class="nb">Get-PublishedBuild</span> <span class="n">-SteamCmd</span> <span class="nv">$SteamCmd</span> <span class="n">-AppId</span> <span class="nv">$AppId</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">            <span class="n">-Branch</span> <span class="nv">$Branch</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="nv">$after</span> <span class="o">-eq</span> <span class="nv">$published</span> <span class="o">-and</span> <span class="nv">$onBranch</span><span class="p">)</span> <span class="p">{</span> <span class="k">break</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$note</span> <span class="p">=</span> <span class="k">if</span> <span class="p">(</span><span class="nv">$restart</span><span class="p">)</span> <span class="p">{</span> <span class="s1">&#39; The server was left stopped.&#39;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">-not</span> <span class="nv">$after</span> <span class="o">-or</span> <span class="o">-not</span> <span class="nv">$published</span> <span class="o">-or</span> <span class="nv">$after</span> <span class="o">-ne</span> <span class="nv">$published</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$shown</span> <span class="p">=</span> <span class="k">if</span> <span class="p">(</span><span class="nv">$after</span><span class="p">)</span> <span class="p">{</span> <span class="nv">$after</span> <span class="p">}</span> <span class="k">else</span> <span class="p">{</span> <span class="s1">&#39;unknown&#39;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$wanted</span> <span class="p">=</span> <span class="k">if</span> <span class="p">(</span><span class="nv">$published</span><span class="p">)</span> <span class="p">{</span> <span class="nv">$published</span> <span class="p">}</span> <span class="k">else</span> <span class="p">{</span> <span class="s1">&#39;unknown&#39;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="k">throw</span> <span class="s2">&#34;Update failed: installed build is </span><span class="nv">$shown</span><span class="s2">, expected </span><span class="nv">$wanted</span><span class="s2">.</span><span class="nv">$note</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$afterBranch</span> <span class="p">=</span> <span class="nb">Get-InstalledBranch</span> <span class="n">-ServerDir</span> <span class="nv">$ServerDir</span> <span class="n">-AppId</span> <span class="nv">$AppId</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nv">$afterBranch</span> <span class="o">-ne</span> <span class="nv">$Branch</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">throw</span> <span class="s2">&#34;Update failed: the install is on branch </span><span class="nv">$afterBranch</span><span class="s2">, not </span><span class="nv">$Branch</span><span class="s2">.</span><span class="nv">$note</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nv">$restart</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="p">&amp;</span> <span class="nv">$StartServer</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$deadline</span> <span class="p">=</span> <span class="p">(</span><span class="nb">Get-Date</span><span class="p">).</span><span class="py">AddSeconds</span><span class="p">(</span><span class="mf">60</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">do</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nv">$running</span> <span class="p">=</span> <span class="nb">Get-Process</span> <span class="n">7DaysToDieServer</span> <span class="n">-ErrorAction</span> <span class="n">SilentlyContinue</span>
</span></span><span class="line"><span class="cl">            <span class="nv">$started</span> <span class="p">=</span> <span class="p">[</span><span class="no">bool</span><span class="p">](</span><span class="nv">$running</span> <span class="p">|</span> <span class="nb">Where-Object</span> <span class="p">{</span> <span class="nv">$_</span><span class="p">.</span><span class="py">Path</span> <span class="o">-eq</span> <span class="nv">$exe</span> <span class="p">})</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="o">-not</span> <span class="nv">$started</span><span class="p">)</span> <span class="p">{</span> <span class="nb">Start-Sleep</span> <span class="n">-Seconds</span> <span class="mf">2</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span> <span class="k">until</span> <span class="p">(</span><span class="nv">$started</span> <span class="o">-or</span> <span class="p">(</span><span class="nb">Get-Date</span><span class="p">)</span> <span class="o">-gt</span> <span class="nv">$deadline</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="o">-not</span> <span class="nv">$started</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="k">throw</span> <span class="s2">&#34;Updated to build </span><span class="nv">$after</span><span class="s2">, but the server did not start.&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nv">$sameBuild</span><span class="p">)</span> <span class="p">{</span> <span class="k">return</span> <span class="s2">&#34;Switched to branch </span><span class="nv">$Branch</span><span class="s2">. The build is </span><span class="nv">$after</span><span class="s2">.&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Updated to build </span><span class="nv">$after</span><span class="s2">.&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p>Run it as <code>Update-GameServer</code>. <code>Stop-GameServer</code> is the function from the previous part, so define it first, and the default start command is that part&rsquo;s scheduled task. Change either with <code>-StopServer</code> and <code>-StartServer</code>. With a telnet password, read it once and pass it through <code>-StopServer</code>: <code>$telnet = Read-Host 'Telnet password' -AsSecureString</code>, then <code>Update-GameServer -StopServer { Stop-GameServer -Password $telnet }</code>.</p>
<p>What it does around the update:</p>
<ul>
<li>It resolves <code>-ServerDir</code> to a full path first, so a relative path such as <code>.\old-server</code> finds the running server too.</li>
<li>Without <code>-Branch</code> it uses the branch the manifest records, so a server on a beta stays on it. Passing <code>-Branch</code> moves a server to another one.</li>
<li>Two branches can have the same build. Then the function runs <code>app_update</code> and says <code>Switched to branch ...</code>. It still stops and starts a running server, because a build published in the meantime would change the files under it.</li>
<li>It runs <code>app_update</code> a second time if the first leaves the wrong build or the wrong branch. Moving from <code>public</code> to an older branch printed <code>Success!</code> and changed nothing until the second run.</li>
<li>A failed update stops the function with an error and leaves the server stopped on purpose, because a half-updated install that starts and reports success is worse.</li>
<li>If the build doesn&rsquo;t match, it asks Steam for the published build once more before it gives up, because Steam can publish a newer build between the first lookup and the update.</li>
<li>After the update it waits up to a minute for the server process, since <code>Start-ScheduledTask</code> only submits the request, and stops with an error if none appears.</li>
<li>A build number it can&rsquo;t read, before or after the update, counts as a failed update too, and the message says <code>unknown</code> for it.</li>
</ul>
<p>On a server that is current, with the server running:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">Update-GameServer</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Installed</span> <span class="console__token">build:</span> <span class="console__token">24994542</span>
</span><span class="line"><span class="console__token">Published</span> <span class="console__token">build:</span> <span class="console__token">24994542</span>
</span><span class="line"><span class="console__token">Already</span> <span class="console__token">on</span> <span class="console__token">the</span> <span class="console__token">published</span> <span class="console__token">build.</span> <span class="console__token">Nothing</span> <span class="console__token">to</span> <span class="console__token">do.</span></span></code></pre>
    </div>
  </div></div><p>The server&rsquo;s process ID didn&rsquo;t change, and the manifest wasn&rsquo;t touched.</p>
<h3 id="real-update-from-branch-v310-to-public">Real update from branch v3.1.0 to public<a class="heading-anchor" href="https://looselydecoupled.com/update-7-days-to-die-server-only-on-new-build/#real-update-from-branch-v310-to-public"
     aria-label="Link to section: Real update from branch v3.1.0 to public" data-pagefind-ignore></a>
</h3>
<p>To test an update that has something to download, I installed the previous stable version into a separate folder with <code>-beta v3.1.0</code>. That is build <code>24436799</code>. I started its server and ran the function against it:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">Update-GameServer</span> <span class="console__token">-ServerDir</span> <span class="console__token">C:\scratch\old-server</span> <span class="console__token">-Branch</span> <span class="console__token">public</span> <span class="console__token">`</span>
</span><span class="line"><span class="prompt">&gt;     </span><span class="console__token">-StartServer</span> <span class="console__token">{</span> <span class="console__token">&lt;start</span> <span class="console__token">command</span> <span class="console__token">for</span> <span class="console__token">that</span> <span class="console__token">folder&gt;</span> <span class="console__token">}</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Installed</span> <span class="console__token">build:</span> <span class="console__token">24436799</span>
</span><span class="line"><span class="console__token">Published</span> <span class="console__token">build:</span> <span class="console__token">24994542</span>
</span><span class="line"><span class="console__token">Stopped,</span> <span class="console__token">exit</span> <span class="console__token">code</span> <span class="console__token">0.</span>
</span><span class="line"><span class="console__token">Updated</span> <span class="console__token">to</span> <span class="console__token">build</span> <span class="console__token">24994542.</span></span></code></pre>
    </div>
  </div></div><p>The old server was stopped over telnet and exited with code <code>0</code>. The update downloaded about 8.4 GB in a little over two minutes, and the server started again on the new build. The stop line comes from the previous part&rsquo;s function.</p>
<h3 id="plain-app_update-exits-0-and-the-build-doesnt-change">Plain app_update exits 0 and the build doesn&rsquo;t change<a class="heading-anchor" href="https://looselydecoupled.com/update-7-days-to-die-server-only-on-new-build/#plain-app_update-exits-0-and-the-build-doesnt-change"
     aria-label="Link to section: Plain app_update exits 0 and the build doesn’t change" data-pagefind-ignore></a>
</h3>
<p>The command I started with was the plain <code>app_update 294420 validate</code>, which is what an install script usually has. On the <code>v3.1.0</code> install it printed <code>Success! App '294420' fully installed.</code>, exited with code <code>0</code>, and left the build at <code>24436799</code>. On this install SteamCMD had recorded the branch in the manifest as <code>BetaKey</code>, and a plain <code>app_update</code> stayed on it. The manifest read <code>v3.1.0</code> before, and <code>public</code> after <code>-beta public</code>.</p>
<p>Running <code>-beta public</code> on a server that was already on <code>public</code> changed nothing: the build stayed current.</p>
<p>Staying on its own branch is right for a server that should stay on one, which is why <code>-Branch</code> defaults to the manifest&rsquo;s <code>BetaKey</code>. It also means the exit code and the <code>Success!</code> line say nothing about whether the build you wanted landed. The function therefore reads the installed build again and compares it with the published one.</p>
<h3 id="validate-restores-the-shipped-config"><code>validate</code> restores the shipped config<a class="heading-anchor" href="https://looselydecoupled.com/update-7-days-to-die-server-only-on-new-build/#validate-restores-the-shipped-config"
     aria-label="Link to section: validate restores the shipped config" data-pagefind-ignore></a>
</h3>
<p><code>validate</code> checks every file against Steam&rsquo;s copy and restores any shipped file you edited. It put <code>serverconfig.xml</code> back to the shipped version when part 2 ran it, and again here. If you run the server from <code>myserver.xml</code>, as part 3 does, an update leaves your settings alone.</p>
<!-- style-check: end procedure -->
<h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/update-7-days-to-die-server-only-on-new-build/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="update-failed-installed-build-is-not-the-published-one">Update failed: installed build is not the published one<a class="heading-anchor" href="https://looselydecoupled.com/update-7-days-to-die-server-only-on-new-build/#update-failed-installed-build-is-not-the-published-one"
     aria-label="Link to section: Update failed: installed build is not the published one" data-pagefind-ignore></a>
</h3>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Update</span> <span class="console__token">failed:</span> <span class="console__token">installed</span> <span class="console__token">build</span> <span class="console__token">is</span> <span class="console__token">24436799,</span> <span class="console__token">expected</span> <span class="console__token">24994542.</span> <span class="console__token">The</span> <span class="console__token">server</span> <span class="console__token">was</span> <span class="console__token">left</span> <span class="console__token">stopped.</span></span></code></pre>
    </div>
  </div></div><p>The function ran <code>app_update</code> and the installed build didn&rsquo;t become the published one. I couldn&rsquo;t make a real update fail, so I got this message by wrapping SteamCMD in a script that answers <code>app_update</code> with an error. Read what SteamCMD prints by running its command yourself, with the arguments from the function. If the server was running, it stays stopped. Start it again once you have the installed build right, or after you decide to run on the old one.</p>
<h3 id="update-reports-success-and-the-build-is-the-old-one">Update reports success and the build is the old one<a class="heading-anchor" href="https://looselydecoupled.com/update-7-days-to-die-server-only-on-new-build/#update-reports-success-and-the-build-is-the-old-one"
     aria-label="Link to section: Update reports success and the build is the old one" data-pagefind-ignore></a>
</h3>
<p>The manifest&rsquo;s <code>BetaKey</code> still names the branch the install came from, so a plain <code>app_update</code> stays on it. Pass <code>-beta</code> with the branch you want, which the function does through <code>-Branch</code>, and read the installed build again.</p>
<h3 id="could-not-read-both-builds">Could not read both builds<a class="heading-anchor" href="https://looselydecoupled.com/update-7-days-to-die-server-only-on-new-build/#could-not-read-both-builds"
     aria-label="Link to section: Could not read both builds" data-pagefind-ignore></a>
</h3>
<p>The function stopped before changing anything, and a mistyped <code>-Branch</code> ends up here too. The installed build is unknown when the manifest file is missing, which happens when <code>-ServerDir</code> is wrong. The published build is unknown when SteamCMD isn&rsquo;t where you said, can&rsquo;t reach Steam, or has no such branch. Check with <code>Get-PublishedBuild -Branch &lt;name&gt;</code> and read the printed text.</p>
<h3 id="update-landed-and-the-server-didnt-start">Update landed and the server didn&rsquo;t start<a class="heading-anchor" href="https://looselydecoupled.com/update-7-days-to-die-server-only-on-new-build/#update-landed-and-the-server-didnt-start"
     aria-label="Link to section: Update landed and the server didn’t start" data-pagefind-ignore></a>
</h3>
<p>The update itself finished, and the function says so: <code>Updated to build &lt;build&gt;, but the server did not start.</code> Look at the task&rsquo;s last result with <code>Get-ScheduledTaskInfo</code> and at the server&rsquo;s log.</p>
<p><code>Update-GameServer</code> starts the server only if one was running when it began. A server that was stopped stays stopped, and gives no such error.</p>
<h2 id="what-i-didnt-test">What I didn&rsquo;t test<a class="heading-anchor" href="https://looselydecoupled.com/update-7-days-to-die-server-only-on-new-build/#what-i-didnt-test"
     aria-label="Link to section: What I didn’t test" data-pagefind-ignore></a>
</h2>
<p>I didn&rsquo;t see Steam publish a new build on <code>public</code> while this was running, so the update tested here was a move from an older branch to the current public build. I didn&rsquo;t test a branch that needs a password, or a path to SteamCMD that has spaces in it. It exercises the same download, stop and start steps, but a routine patch is smaller. The article doesn&rsquo;t schedule the check. And the stop and start commands are the previous parts&rsquo;, which I ran separately.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>OS</dt> <dd>Windows Server 2025 Datacenter (10.0.26100), virtual machine</dd></div>
    <div class="tested__row"><dt>PowerShell</dt> <dd>Windows PowerShell 5.1, elevated</dd></div>
    <div class="tested__row"><dt>Game</dt> <dd>7 Days to Die dedicated server (294420), V 3.2.0 (b10)</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
    <item>
      <title>Restore a 7 Days to Die Dedicated Server Backup Without Merging the Old World</title>
      <link>https://looselydecoupled.com/restore-7-days-to-die-world-without-merging/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/restore-7-days-to-die-world-without-merging/</guid>
      <pubDate>Thu, 24 Sep 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Windows Server</category>
      <category>PowerShell</category>
      <category>Automation</category>
      
      <description>A copied restore such as xcopy /E keeps files the backup lacks. Rename the live Saves folder aside and move the archive&#39;s in; the old world stays beside it.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Stop the server, rename the live <code>Saves</code> folder, and move the archive&rsquo;s <code>Saves</code> into its place. Do not copy the archive over the live folder. A copy that merges, such as <code>xcopy /E /I /Y</code>, replaces the files that are in both and leaves every file that exists only in the live folder. A restore meant to undo damage then keeps part of the damage. In testing, a stray file added to the live world survived an <code>xcopy /E /I /Y</code> restore, and was gone after the rename-and-move.</p>
<p>The function later in the article does the whole restore. The commands it is made of, run on the stopped server, stop at the first error. They also check that the archive has a <code>Saves</code> folder with files in it, and no <code>GeneratedWorlds</code> folder, before they touch the live one:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$dir</span> <span class="p">=</span> <span class="s1">&#39;C:\7DaysToDieServer&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$stamp</span> <span class="p">=</span> <span class="nb">Get-Date</span> <span class="n">-Format</span> <span class="s1">&#39;yyyyMMdd-HHmmss&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$live</span> <span class="p">=</span> <span class="nb">Join-Path</span> <span class="nv">$dir</span> <span class="s1">&#39;Saves&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$staging</span> <span class="p">=</span> <span class="nb">Join-Path</span> <span class="nv">$dir</span> <span class="s2">&#34;Saves.restoring-</span><span class="nv">$stamp</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Expand-Archive</span> <span class="n">-LiteralPath</span> <span class="s1">&#39;C:\backups\7dtd-backup-20260925-142839.zip&#39;</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-DestinationPath</span> <span class="nv">$staging</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl"><span class="nv">$saves</span> <span class="p">=</span> <span class="nb">Join-Path</span> <span class="nv">$staging</span> <span class="s1">&#39;Saves&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$files</span> <span class="p">=</span> <span class="vm">$null</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nb">Test-Path</span> <span class="n">-LiteralPath</span> <span class="nv">$saves</span> <span class="n">-PathType</span> <span class="n">Container</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$files</span> <span class="p">=</span> <span class="nb">Get-ChildItem</span> <span class="n">-LiteralPath</span> <span class="nv">$saves</span> <span class="n">-Recurse</span> <span class="o">-File</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">        <span class="n">-ErrorAction</span> <span class="n">SilentlyContinue</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">-not</span> <span class="nv">$files</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">throw</span> <span class="s1">&#39;The archive holds no files under Saves.&#39;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nb">Test-Path</span> <span class="n">-LiteralPath</span> <span class="p">(</span><span class="nb">Join-Path</span> <span class="nv">$staging</span> <span class="s1">&#39;GeneratedWorlds&#39;</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">throw</span> <span class="s1">&#39;The archive holds GeneratedWorlds. Restore both folders by hand.&#39;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="nb">Rename-Item</span> <span class="n">-LiteralPath</span> <span class="nv">$live</span> <span class="n">-NewName</span> <span class="s2">&#34;Saves.before-restore-</span><span class="nv">$stamp</span><span class="s2">&#34;</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl"><span class="nb">Move-Item</span> <span class="n">-LiteralPath</span> <span class="p">(</span><span class="nb">Join-Path</span> <span class="nv">$staging</span> <span class="s1">&#39;Saves&#39;</span><span class="p">)</span> <span class="n">-Destination</span> <span class="nv">$live</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl"><span class="nb">Remove-Item</span> <span class="n">-LiteralPath</span> <span class="nv">$staging</span> <span class="n">-Recurse</span></span></span></code></pre></div></div>
  </div>
</div><p>The old world stays beside the new one as <code>Saves.before-restore-&lt;stamp&gt;</code>. If the commands stop on an error, they leave a <code>Saves.restoring-&lt;stamp&gt;</code> folder to delete. The function also checks the archive before it stops anything, and checks the restored files against the archive before it starts the server again. If that check fails, it undoes the restore. The game-specific parts are the <code>Saves</code> folder and the archive&rsquo;s layout.</p>

</div>

<p>The previous parts install, configure and update the server. This part is the one you hope not to need: getting the world back from a backup. The backup itself is the <a href="https://looselydecoupled.com/backup-7-days-to-die-server-s3/">S3 backup article</a>, and this part starts from one of its zip files. Its <a href="https://looselydecoupled.com/backup-7-days-to-die-server-s3/#restore-a-backup">Restore a backup section</a> has the <code>Read-S3Object</code> download.</p>
<!-- style-check: procedure -->
<h2 id="1-see-why-xcopy-e-merges-instead-of-restoring">1. See why xcopy /E merges instead of restoring<a class="heading-anchor" href="https://looselydecoupled.com/restore-7-days-to-die-world-without-merging/#1-see-why-xcopy-e-merges-instead-of-restoring"
     aria-label="Link to section: 1. See why xcopy /E merges instead of restoring" data-pagefind-ignore></a>
</h2>
<p>Each archive the S3 backup article makes is a zip of the <code>Saves</code> folder and <code>serverconfig.xml</code>. On Windows PowerShell 5.1, <code>Compress-Archive</code> writes the entry names with backslashes, such as <code>Saves\W\G\main.ttw</code>.</p>
<p>To see the problem, I made a small folder shaped like a world, with <code>main.ttw</code> and <code>players.xml</code> in it, and archived it with <code>Compress-Archive</code>. Then I damaged the folder: I changed <code>players.xml</code> and added a file, <code>corrupt.dat</code>, that the archive doesn&rsquo;t have. Then I restored by copying:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">Expand-Archive</span> <span class="console__token">C:\demo\b.zip</span> <span class="console__token">-DestinationPath</span> <span class="console__token">C:\demo\x</span>
</span><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">xcopy</span> <span class="console__token">C:\demo\x\Saves</span> <span class="console__token">C:\demo\srv\Saves</span> <span class="console__token">/E</span> <span class="console__token">/I</span> <span class="console__token">/Y</span> <span class="console__token">|</span> <span class="console__token">Out-Null</span>
</span><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">Get-ChildItem</span> <span class="console__token">C:\demo\srv\Saves</span> <span class="console__token">-Recurse</span> <span class="console__token">-File</span> <span class="console__token">|</span> <span class="console__token">ForEach-Object</span> <span class="console__token">Name</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">corrupt.dat</span>
</span><span class="line"><span class="console__token">main.ttw</span>
</span><span class="line"><span class="console__token">players.xml</span></span></code></pre>
    </div>
  </div></div><p><code>players.xml</code> is back to the archive&rsquo;s copy, because it exists in both places. <code>corrupt.dat</code> is still there, because the archive has no copy to replace it with. The restore undid some of the damage and kept the rest, and nothing says so. A new file in a world&rsquo;s folder is a small example. What it stands for is anything the server wrote after the backup: a file that was damaged, one a newer game version added, one left by a crashed save.</p>
<h2 id="2-rename-the-live-world-folder-aside-and-move-the-archives-in">2. Rename the live world folder aside and move the archive&rsquo;s in<a class="heading-anchor" href="https://looselydecoupled.com/restore-7-days-to-die-world-without-merging/#2-rename-the-live-world-folder-aside-and-move-the-archives-in"
     aria-label="Link to section: 2. Rename the live world folder aside and move the archive’s in" data-pagefind-ignore></a>
</h2>
<p>This function stops the server if it is running, swaps the folders, checks the result, and starts the server again if it was running:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="kd">function</span><span class="w"> </span><span class="nb">Restore-GameWorld</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">param</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="p">[</span><span class="nb">Parameter</span><span class="p">(</span><span class="na">Mandatory</span><span class="p">)][</span><span class="no">string</span><span class="p">]</span><span class="nv">$ArchivePath</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">[</span><span class="no">string</span><span class="p">]</span><span class="nv">$ServerDir</span> <span class="p">=</span> <span class="s1">&#39;C:\7DaysToDieServer&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">[</span><span class="no">scriptblock</span><span class="p">]</span><span class="nv">$StopServer</span> <span class="p">=</span> <span class="p">{</span> <span class="nb">Stop-GameServer</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="p">[</span><span class="no">scriptblock</span><span class="p">]</span><span class="nv">$StartServer</span> <span class="p">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nb">Start-ScheduledTask</span> <span class="n">-TaskName</span> <span class="s1">&#39;7DaysToDieServer&#39;</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Add-Type</span> <span class="n">-AssemblyName</span> <span class="n">System</span><span class="p">.</span><span class="py">IO</span><span class="p">.</span><span class="py">Compression</span><span class="p">.</span><span class="py">FileSystem</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$zip</span> <span class="p">=</span> <span class="p">[</span><span class="no">IO.Compression.ZipFile</span><span class="p">]::</span><span class="n">OpenRead</span><span class="p">(</span><span class="nv">$ArchivePath</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$expected</span> <span class="p">=</span> <span class="vm">@</span><span class="p">{}</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$hasTerrain</span> <span class="p">=</span> <span class="vm">$false</span>
</span></span><span class="line"><span class="cl">        <span class="k">foreach</span> <span class="p">(</span><span class="nv">$entry</span> <span class="k">in</span> <span class="nv">$zip</span><span class="p">.</span><span class="n">Entries</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nv">$name</span> <span class="p">=</span> <span class="nv">$entry</span><span class="p">.</span><span class="py">FullName</span><span class="p">.</span><span class="py">Replace</span><span class="p">(</span><span class="s1">&#39;/&#39;</span><span class="p">,</span> <span class="s1">&#39;\&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="nv">$name</span> <span class="o">-like</span> <span class="s1">&#39;GeneratedWorlds\*&#39;</span><span class="p">)</span> <span class="p">{</span> <span class="nv">$hasTerrain</span> <span class="p">=</span> <span class="vm">$true</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="nv">$name</span> <span class="o">-like</span> <span class="s1">&#39;Saves\*&#39;</span> <span class="o">-and</span> <span class="nv">$entry</span><span class="p">.</span><span class="n">Name</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="nv">$expected</span><span class="p">[</span><span class="nv">$name</span><span class="p">]</span> <span class="p">=</span> <span class="nv">$entry</span><span class="p">.</span><span class="py">Length</span>
</span></span><span class="line"><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">finally</span> <span class="p">{</span> <span class="nv">$zip</span><span class="p">.</span><span class="py">Dispose</span><span class="p">()</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nv">$hasTerrain</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">throw</span> <span class="p">(</span><span class="s1">&#39;The archive holds GeneratedWorlds, which this function does not &#39;</span> <span class="p">+</span>
</span></span><span class="line"><span class="cl">            <span class="s1">&#39;restore. Restore both folders by hand, with the server stopped.&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nv">$expected</span><span class="p">.</span><span class="py">Count</span> <span class="o">-eq</span> <span class="mf">0</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$leaf</span> <span class="p">=</span> <span class="nb">Split-Path</span> <span class="nv">$ArchivePath</span> <span class="n">-Leaf</span>
</span></span><span class="line"><span class="cl">        <span class="k">throw</span> <span class="s2">&#34;The archive holds no files under Saves: </span><span class="nv">$leaf</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$exe</span> <span class="p">=</span> <span class="nb">Join-Path</span> <span class="nv">$ServerDir</span> <span class="s1">&#39;7DaysToDieServer.exe&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$running</span> <span class="p">=</span> <span class="vm">@</span><span class="p">(</span><span class="nb">Get-Process</span> <span class="n">7DaysToDieServer</span> <span class="n">-ErrorAction</span> <span class="n">SilentlyContinue</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$mine</span> <span class="p">=</span> <span class="vm">@</span><span class="p">(</span><span class="nv">$running</span> <span class="p">|</span> <span class="nb">Where-Object</span> <span class="p">{</span> <span class="nv">$_</span><span class="p">.</span><span class="py">Path</span> <span class="o">-eq</span> <span class="nv">$exe</span> <span class="p">})</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$wasRunning</span> <span class="p">=</span> <span class="nv">$mine</span><span class="p">.</span><span class="py">Count</span> <span class="o">-gt</span> <span class="mf">0</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nv">$running</span><span class="p">.</span><span class="py">Count</span> <span class="o">-gt</span> <span class="nv">$mine</span><span class="p">.</span><span class="n">Count</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">throw</span> <span class="p">(</span><span class="s1">&#39;A 7DaysToDieServer process is running from another folder. &#39;</span> <span class="p">+</span>
</span></span><span class="line"><span class="cl">            <span class="s1">&#39;Stop it first.&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$stamp</span> <span class="p">=</span> <span class="nb">Get-Date</span> <span class="n">-Format</span> <span class="s1">&#39;yyyyMMdd-HHmmss-fff&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$asideName</span> <span class="p">=</span> <span class="s2">&#34;Saves.before-restore-</span><span class="nv">$stamp</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$live</span> <span class="p">=</span> <span class="nb">Join-Path</span> <span class="nv">$ServerDir</span> <span class="s1">&#39;Saves&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$aside</span> <span class="p">=</span> <span class="nb">Join-Path</span> <span class="nv">$ServerDir</span> <span class="nv">$asideName</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$staging</span> <span class="p">=</span> <span class="nb">Join-Path</span> <span class="nv">$ServerDir</span> <span class="s2">&#34;Saves.restoring-</span><span class="nv">$stamp</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nv">$wasRunning</span><span class="p">)</span> <span class="p">{</span> <span class="p">&amp;</span> <span class="nv">$StopServer</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nb">Expand-Archive</span> <span class="n">-LiteralPath</span> <span class="nv">$ArchivePath</span> <span class="n">-DestinationPath</span> <span class="nv">$staging</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">            <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nb">Remove-Item</span> <span class="n">-LiteralPath</span> <span class="nv">$staging</span> <span class="n">-Recurse</span> <span class="n">-Force</span> <span class="n">-ErrorAction</span> <span class="n">SilentlyContinue</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="nv">$wasRunning</span><span class="p">)</span> <span class="p">{</span> <span class="p">&amp;</span> <span class="nv">$StartServer</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="k">throw</span> <span class="p">(</span><span class="s1">&#39;Could not unpack the archive, so nothing was restored: &#39;</span> <span class="p">+</span>
</span></span><span class="line"><span class="cl">            <span class="nv">$_</span><span class="p">.</span><span class="py">Exception</span><span class="p">.</span><span class="n">Message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$hadLive</span> <span class="p">=</span> <span class="nb">Test-Path</span> <span class="n">-LiteralPath</span> <span class="nv">$live</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nv">$hadLive</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nb">Rename-Item</span> <span class="n">-LiteralPath</span> <span class="nv">$live</span> <span class="n">-NewName</span> <span class="nv">$asideName</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nb">Remove-Item</span> <span class="n">-LiteralPath</span> <span class="nv">$staging</span> <span class="n">-Recurse</span> <span class="n">-Force</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">                <span class="n">-ErrorAction</span> <span class="n">SilentlyContinue</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="nv">$wasRunning</span><span class="p">)</span> <span class="p">{</span> <span class="p">&amp;</span> <span class="nv">$StartServer</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="k">throw</span> <span class="p">(</span><span class="s1">&#39;Could not move the live Saves aside, so nothing was restored: &#39;</span> <span class="p">+</span>
</span></span><span class="line"><span class="cl">                <span class="nv">$_</span><span class="p">.</span><span class="py">Exception</span><span class="p">.</span><span class="n">Message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$restored</span> <span class="p">=</span> <span class="nb">Join-Path</span> <span class="nv">$staging</span> <span class="s1">&#39;Saves&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$actual</span> <span class="p">=</span> <span class="vm">@</span><span class="p">{}</span>
</span></span><span class="line"><span class="cl">    <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nb">Move-Item</span> <span class="n">-LiteralPath</span> <span class="nv">$restored</span> <span class="n">-Destination</span> <span class="nv">$live</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$walk</span> <span class="p">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="k">param</span><span class="p">(</span><span class="nv">$folder</span><span class="p">,</span> <span class="nv">$prefix</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="k">foreach</span> <span class="p">(</span><span class="nv">$item</span> <span class="k">in</span> <span class="nb">Get-ChildItem</span> <span class="n">-LiteralPath</span> <span class="nv">$folder</span> <span class="n">-Force</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="nv">$name</span> <span class="p">=</span> <span class="s2">&#34;</span><span class="nv">$prefix</span><span class="s2">\</span><span class="p">$(</span><span class="nv">$item</span><span class="p">.</span><span class="n">Name</span><span class="p">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">                <span class="k">if</span> <span class="p">(</span><span class="nv">$item</span><span class="p">.</span><span class="n">PSIsContainer</span><span class="p">)</span> <span class="p">{</span> <span class="p">&amp;</span> <span class="nv">$walk</span> <span class="nv">$item</span><span class="p">.</span><span class="py">FullName</span> <span class="nv">$name</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">                <span class="k">else</span> <span class="p">{</span> <span class="nv">$actual</span><span class="p">[</span><span class="nv">$name</span><span class="p">]</span> <span class="p">=</span> <span class="nv">$item</span><span class="p">.</span><span class="py">Length</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">&amp;</span> <span class="nv">$walk</span> <span class="nv">$live</span> <span class="s1">&#39;Saves&#39;</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$missing</span> <span class="p">=</span> <span class="vm">@</span><span class="p">(</span><span class="nv">$expected</span><span class="p">.</span><span class="py">Keys</span> <span class="p">|</span> <span class="nb">Where-Object</span> <span class="p">{</span> <span class="nv">$actual</span><span class="p">[</span><span class="nv">$_</span><span class="p">]</span> <span class="o">-ne</span> <span class="nv">$expected</span><span class="p">[</span><span class="nv">$_</span><span class="p">]</span> <span class="p">})</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$extra</span> <span class="p">=</span> <span class="vm">@</span><span class="p">(</span><span class="nv">$actual</span><span class="p">.</span><span class="py">Keys</span> <span class="p">|</span> <span class="nb">Where-Object</span> <span class="p">{</span> <span class="o">-not</span> <span class="nv">$expected</span><span class="p">.</span><span class="py">ContainsKey</span><span class="p">(</span><span class="nv">$_</span><span class="p">)</span> <span class="p">})</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="nv">$missing</span> <span class="p">+</span> <span class="nv">$extra</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nv">$list</span> <span class="p">=</span> <span class="p">(</span><span class="nv">$missing</span> <span class="p">+</span> <span class="nv">$extra</span><span class="p">)</span> <span class="n">-join</span> <span class="s1">&#39;, &#39;</span>
</span></span><span class="line"><span class="cl">            <span class="k">throw</span> <span class="s2">&#34;The restored Saves doesn&#39;t match the archive: </span><span class="nv">$list</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$message</span> <span class="p">=</span> <span class="nv">$_</span><span class="p">.</span><span class="py">Exception</span><span class="p">.</span><span class="py">Message</span>
</span></span><span class="line"><span class="cl">        <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="nb">Test-Path</span> <span class="n">-LiteralPath</span> <span class="nv">$live</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="nv">$failed</span> <span class="p">=</span> <span class="s2">&#34;Saves.failed-restore-</span><span class="nv">$stamp</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">                <span class="nb">Rename-Item</span> <span class="n">-LiteralPath</span> <span class="nv">$live</span> <span class="n">-NewName</span> <span class="nv">$failed</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="nv">$hadLive</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="nb">Rename-Item</span> <span class="n">-LiteralPath</span> <span class="nv">$aside</span> <span class="n">-NewName</span> <span class="s1">&#39;Saves&#39;</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="k">throw</span> <span class="p">(</span><span class="s2">&#34;The restore failed (</span><span class="nv">$message</span><span class="s2">) and could not be undone: &#34;</span> <span class="p">+</span>
</span></span><span class="line"><span class="cl">                <span class="s2">&#34;</span><span class="p">$(</span><span class="nv">$_</span><span class="p">.</span><span class="py">Exception</span><span class="p">.</span><span class="n">Message</span><span class="p">)</span><span class="s2"> The old world is at </span><span class="nv">$aside</span><span class="s2">, and the &#34;</span> <span class="p">+</span>
</span></span><span class="line"><span class="cl">                <span class="s1">&#39;server was not started.&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="nb">Remove-Item</span> <span class="n">-LiteralPath</span> <span class="nv">$staging</span> <span class="n">-Recurse</span> <span class="n">-Force</span> <span class="n">-ErrorAction</span> <span class="n">SilentlyContinue</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="nv">$wasRunning</span><span class="p">)</span> <span class="p">{</span> <span class="p">&amp;</span> <span class="nv">$StartServer</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="k">throw</span> <span class="s2">&#34;The restore was undone. </span><span class="nv">$message</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">try</span> <span class="p">{</span> <span class="nb">Remove-Item</span> <span class="n">-LiteralPath</span> <span class="nv">$staging</span> <span class="n">-Recurse</span> <span class="n">-Force</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">catch</span> <span class="p">{</span> <span class="nb">Write-Warning</span> <span class="s2">&#34;Could not remove </span><span class="nv">${staging}</span><span class="s2">: </span><span class="p">$(</span><span class="nv">$_</span><span class="p">.</span><span class="py">Exception</span><span class="p">.</span><span class="n">Message</span><span class="p">)</span><span class="s2">&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nv">$wasRunning</span><span class="p">)</span> <span class="p">{</span> <span class="p">&amp;</span> <span class="nv">$StartServer</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Restored Saves from </span><span class="p">$(</span><span class="nb">Split-Path</span> <span class="nv">$ArchivePath</span> <span class="n">-Leaf</span><span class="p">)</span><span class="s2">.&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nv">$hadLive</span><span class="p">)</span> <span class="p">{</span> <span class="s2">&#34;The old Saves is at </span><span class="nv">$aside</span><span class="s2">.&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p>Run it as <code>Restore-GameWorld -ArchivePath C:\backups\7dtd-backup-20260925-142839.zip</code>. <code>Stop-GameServer</code> is the function from the part on stopping the server, so define it first. With a telnet password, read it once and pass it through <code>-StopServer</code>: <code>$telnet = Read-Host 'Telnet password' -AsSecureString</code>, then <code>Restore-GameWorld -ArchivePath &lt;zip&gt; -StopServer { Stop-GameServer -Password $telnet }</code>. The default start command is that part&rsquo;s scheduled task, which throws if the task is missing or can&rsquo;t start. The function expects <code>Saves</code> beside <code>7DaysToDieServer.exe</code>, which is where <code>UserDataFolder</code> puts it in the earlier part on configuring the server.</p>
<p>What each part is for:</p>
<ul>
<li>It reads the archive&rsquo;s file list first and stops if there are no files under <code>Saves</code>, before the server is touched. Windows PowerShell 5.1 leaves an empty folder out of a zip, so a backup of an empty <code>Saves</code> holds no world files. So does an archive that another tool made from the configuration alone. The S3 backup script stops when <code>Saves</code> is missing, so it doesn&rsquo;t write one. Restoring one of those would remove your world and put nothing back.</li>
<li>It stops with an error if any <code>7DaysToDieServer</code> process is running from another folder. That holds even while the server in <code>-ServerDir</code> is running too, because it can only stop the one that lives in <code>-ServerDir</code>.</li>
<li>It unpacks into a folder beside <code>Saves</code>, on the same drive, so that moving it into place is a rename and not a copy. If the archive can&rsquo;t be unpacked, as happened with an entry whose path was too long, it removes the partial copy and stops before anything is moved. It starts the server again if it had stopped it.</li>
<li>It treats removing its own unpacked folder, after a good restore, as housekeeping: if that fails, it warns and still starts the server, and you delete the <code>Saves.restoring-&lt;stamp&gt;</code> folder later.</li>
<li>It renames the live folder with a stamp that includes milliseconds, and never deletes it. Every restore leaves its own displaced folder, even two in the same second.</li>
<li>If the live folder can&rsquo;t be renamed, it removes the unpacked copy as best it can and stops with an error. Nothing was restored, and nothing was lost. If the function had stopped the server, it starts it again before it reports the error, because the old world is untouched.</li>
<li>It compares the restored folder with the archive, by file name and size, before the server starts. It builds the names from the items in each folder, not from full paths, because the same folder can come back as <code>RUSTAD~1</code> or <code>RustAdmin</code> and a prefix cut by length then goes wrong. The server rewrites files as soon as it starts, so this is the only moment the comparison means anything. If the comparison fails, the function renames the restored folder to <code>Saves.failed-restore-&lt;stamp&gt;</code>, puts the old folder back, and starts the server again if it had stopped it. Putting the old folder back can fail too, for example because another process has a file in the restored folder open. Then the function says where the old world is and does not start the server.</li>
</ul>
<p>The comparison catches missing and extra files and files of the wrong size. It doesn&rsquo;t catch a damaged archive. A zip with one byte flipped in the middle of a file passed it, and so did <code>Expand-Archive</code>. Keep more than one backup.
On the real server, with it running and <code>corrupt.dat</code> in the live world, the output was:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">Restore-GameWorld</span> <span class="console__token">-ArchivePath</span> <span class="console__token">$zip</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Stopped,</span> <span class="console__token">exit</span> <span class="console__token">code</span> <span class="console__token">0.</span>
</span><span class="line"><span class="console__token">Restored</span> <span class="console__token">Saves</span> <span class="console__token">from</span> <span class="console__token">7dtd-backup-20260925-142839.zip.</span>
</span><span class="line"><span class="console__token">The</span> <span class="console__token">old</span> <span class="console__token">Saves</span> <span class="console__token">is</span> <span class="console__token">at</span> <span class="console__token">C:\7DaysToDieServer\Saves.before-restore-20261003-170512-418.</span></span></code></pre>
    </div>
  </div></div><p>The server was stopped over telnet, the old folder was renamed, and the new one had exactly the archive&rsquo;s files. <code>corrupt.dat</code> was in <code>Saves.before-restore-*</code> and not in <code>Saves</code>. The server started again and logged its ready line.</p>
<h3 id="what-the-function-doesnt-restore">What the function doesn&rsquo;t restore<a class="heading-anchor" href="https://looselydecoupled.com/restore-7-days-to-die-world-without-merging/#what-the-function-doesnt-restore"
     aria-label="Link to section: What the function doesn’t restore" data-pagefind-ignore></a>
</h3>
<p>The archive&rsquo;s <code>serverconfig.xml</code> stays in the zip and isn&rsquo;t copied anywhere, so your <code>myserver.xml</code> and its settings stay as they are. A random-gen world also keeps its terrain in <code>GeneratedWorlds</code>, beside <code>Saves</code>. The S3 backup article tells you to add that folder to the archive yourself. The script it publishes archives only <code>Saves</code> and <code>serverconfig.xml</code>, so a backup made with it can&rsquo;t bring back a random-gen world&rsquo;s terrain. Change the backup first. This function restores <code>Saves</code> only. If the archive holds <code>GeneratedWorlds</code>, the function stops with an error, because it would otherwise restart the server on a restored save with the old terrain. Restore both folders by hand, with the server stopped.</p>
<h3 id="restore-with-the-server-stopped">Restore with the server stopped<a class="heading-anchor" href="https://looselydecoupled.com/restore-7-days-to-die-world-without-merging/#restore-with-the-server-stopped"
     aria-label="Link to section: Restore with the server stopped" data-pagefind-ignore></a>
</h3>
<p>The function only stops and starts the server when one is running. On a stopped server it swaps the folders and leaves the server stopped. Start it yourself afterwards, and look at the log for <code>StartGame done</code>.</p>
<!-- style-check: end procedure -->
<h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/restore-7-days-to-die-world-without-merging/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="live-folder-cant-be-moved-aside">Live folder can&rsquo;t be moved aside<a class="heading-anchor" href="https://looselydecoupled.com/restore-7-days-to-die-world-without-merging/#live-folder-cant-be-moved-aside"
     aria-label="Link to section: Live folder can’t be moved aside" data-pagefind-ignore></a>
</h3>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Could</span> <span class="console__token">not</span> <span class="console__token">move</span> <span class="console__token">the</span> <span class="console__token">live</span> <span class="console__token">Saves</span> <span class="console__token">aside,</span> <span class="console__token">so</span> <span class="console__token">nothing</span> <span class="console__token">was</span> <span class="console__token">restored:</span> <span class="console__token">Access</span> <span class="console__token">to</span> <span class="console__token">the</span> <span class="console__token">path</span> <span class="console__token">&#39;C:\7DaysToDieServer\Saves&#39;</span> <span class="console__token">is</span> <span class="console__token">denied.</span></span></code></pre>
    </div>
  </div></div><p>A file in the live folder is open. I reproduced it by holding one open from another process: the rename was denied, the function removed its unpacked copy, the live world was untouched, and no <code>Saves.before-restore-*</code> folder was created. Close whatever has the file open, which is usually the server, and run it again.</p>
<h3 id="archive-has-no-world-files">Archive has no world files<a class="heading-anchor" href="https://looselydecoupled.com/restore-7-days-to-die-world-without-merging/#archive-has-no-world-files"
     aria-label="Link to section: Archive has no world files" data-pagefind-ignore></a>
</h3>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">The</span> <span class="console__token">archive</span> <span class="console__token">holds</span> <span class="console__token">no</span> <span class="console__token">files</span> <span class="console__token">under</span> <span class="console__token">Saves:</span> <span class="console__token">7dtd-backup-config-only.zip</span></span></code></pre>
    </div>
  </div></div><p>The archive has no world files in it. With the server running, the function stopped before touching it: the process kept its ID and the live <code>Saves</code> was unchanged. Pick an older archive.</p>
<h3 id="old-world-folders-fill-the-disk">Old world folders fill the disk<a class="heading-anchor" href="https://looselydecoupled.com/restore-7-days-to-die-world-without-merging/#old-world-folders-fill-the-disk"
     aria-label="Link to section: Old world folders fill the disk" data-pagefind-ignore></a>
</h3>
<p>The old and new worlds sit side by side until you delete the old one. A new Navezgane world was 17.2 MB, and a played one is larger. Delete <code>Saves.before-restore-*</code> folders by hand once you have checked the restored world.</p>
<h2 id="what-i-didnt-test">What I didn&rsquo;t test<a class="heading-anchor" href="https://looselydecoupled.com/restore-7-days-to-die-world-without-merging/#what-i-didnt-test"
     aria-label="Link to section: What I didn’t test" data-pagefind-ignore></a>
</h2>
<p>I didn&rsquo;t download an archive from S3. The archive in the tests came from the same <code>Compress-Archive</code> call the backup script uses. I didn&rsquo;t restore a world someone had played in, so the damage in the tests is a changed file and an extra file. And I didn&rsquo;t test a restore that fails in the middle of the final move, which I couldn&rsquo;t cause.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>OS</dt> <dd>Windows Server 2025 Datacenter (10.0.26100), virtual machine</dd></div>
    <div class="tested__row"><dt>PowerShell</dt> <dd>Windows PowerShell 5.1, elevated</dd></div>
    <div class="tested__row"><dt>Game</dt> <dd>7 Days to Die dedicated server (294420), V 3.2.0 (b10)</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
    <item>
      <title>Configure a 7 Days to Die Dedicated Server and Open Its Ports</title>
      <link>https://looselydecoupled.com/configure-7-days-to-die-server-and-open-ports/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/configure-7-days-to-die-server-and-open-ports/</guid>
      <pubDate>Thu, 24 Sep 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Windows Server</category>
      <category>PowerShell</category>
      <category>Automation</category>
      
      <description>A 7 Days to Die server that starts on a clean Windows Server, with its saves where you choose and TCP 26900 and UDP 26900 to 26902 open in Windows Firewall.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Install the Visual C++ runtime, set the server&rsquo;s name, password and port in a copy of <code>serverconfig.xml</code> that you start the server from, and start the server once. The copy matters: a later <code>validate</code> restores the shipped file. Read the ports it listens on, then open exactly those in Windows Firewall. With the default <code>ServerPort</code> of <code>26900</code>, the server listens on TCP <code>26900</code>, and on UDP <code>26900</code> and <code>26902</code>, which is <code>ServerPort</code> and <code>ServerPort + 2</code>. The server&rsquo;s own message about a bad port also names <code>ServerPort + 1</code>, so the rules open UDP <code>26900</code> to <code>26902</code>. The port numbers and the config file are the game-specific parts.</p>
<p>In an elevated PowerShell on the server, once the server runs:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">New-NetFirewallRule</span> <span class="n">-DisplayName</span> <span class="s1">&#39;7DaysToDie-26900-TCP&#39;</span> <span class="n">-Direction</span> <span class="n">Inbound</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-Action</span> <span class="n">Allow</span> <span class="n">-Protocol</span> <span class="n">TCP</span> <span class="n">-LocalPort</span> <span class="mf">26900</span>
</span></span><span class="line"><span class="cl"><span class="nb">New-NetFirewallRule</span> <span class="n">-DisplayName</span> <span class="s1">&#39;7DaysToDie-26900-UDP&#39;</span> <span class="n">-Direction</span> <span class="n">Inbound</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-Action</span> <span class="n">Allow</span> <span class="n">-Protocol</span> <span class="n">UDP</span> <span class="n">-LocalPort</span> <span class="mf">26900</span><span class="p">-</span><span class="mf">26902</span></span></span></code></pre></div></div>
  </div>
</div><p>Run those once. Step 5 has a version that replaces a rule of the same name, so a rule you customized under that name is overwritten. Windows Firewall blocks inbound traffic until a rule allows it, and a network security group or router in front of the server is a separate firewall that these rules don&rsquo;t open. If the server runs but players can&rsquo;t join, these rules are the first check, and the group or router&rsquo;s port forwarding for the same ports is the second.</p>
<p>The thing most likely to stop you comes before any of that. On a clean Windows Server the server exits within seconds of starting, with <code>[EOS] Native library or one of its dependencies not found</code> in its log, because the Visual C++ runtime isn&rsquo;t installed. Step 1 installs it.</p>

</div>

<p>The first part of this series found the server&rsquo;s app ID, and the second installed it. The install leaves a server that can&rsquo;t start on a clean machine, and one that reads a config file with placeholder values. This part fixes both, and ends with the ports a player needs.</p>
<!-- style-check: procedure -->
<h2 id="1-install-the-c-runtime">1. Install the C++ runtime<a class="heading-anchor" href="https://looselydecoupled.com/configure-7-days-to-die-server-and-open-ports/#1-install-the-c-runtime"
     aria-label="Link to section: 1. Install the C&#43;&#43; runtime" data-pagefind-ignore></a>
</h2>
<p>The server needs Microsoft&rsquo;s Visual C++ runtime, and a clean Windows Server doesn&rsquo;t have it. SteamCMD puts the installer in the server&rsquo;s folder, under <code>_CommonRedist\vcredist\2022</code>, but nothing ran it here. The install script beside it, <code>installscript.vdf</code>, names a command that runs it, and the registry value that script checks, under <code>HKLM\Software\Valve\Steam\Apps\CommonRedist\vcredist\2022</code>, didn&rsquo;t exist. The folder comes from a second app that the install fetches, Steamworks Common Redistributables, app <code>228980</code>.</p>
<p>Run this in an elevated PowerShell on the server. It compares the registered x64 Visual C++ v14 runtime with the installer in the server&rsquo;s folder. If the registered one is missing or older, it checks that Microsoft Corporation signed the installer and runs it without prompts or a restart. Then it reads the registry again to confirm the runtime is current:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$key</span> <span class="p">=</span> <span class="s1">&#39;HKLM:\SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\X64&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$installer</span> <span class="p">=</span> <span class="s1">&#39;C:\7DaysToDieServer\_CommonRedist\vcredist\2022\VC_redist.x64.exe&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$have</span> <span class="p">=</span> <span class="nb">Get-ItemProperty</span> <span class="nv">$key</span> <span class="n">-ErrorAction</span> <span class="n">SilentlyContinue</span>
</span></span><span class="line"><span class="cl"><span class="nv">$registered</span> <span class="p">=</span> <span class="k">if</span> <span class="p">(</span><span class="nv">$have</span><span class="p">.</span><span class="py">Installed</span> <span class="o">-eq</span> <span class="mf">1</span><span class="p">)</span> <span class="p">{</span> <span class="p">[</span><span class="no">version</span><span class="p">]</span><span class="nv">$have</span><span class="p">.</span><span class="py">Version</span><span class="p">.</span><span class="py">TrimStart</span><span class="p">(</span><span class="s1">&#39;v&#39;</span><span class="p">)</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">$bundled</span> <span class="p">=</span> <span class="p">[</span><span class="no">version</span><span class="p">](</span><span class="nb">Get-Item</span> <span class="nv">$installer</span><span class="p">).</span><span class="py">VersionInfo</span><span class="p">.</span><span class="py">ProductVersion</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nv">$registered</span> <span class="o">-ge</span> <span class="nv">$bundled</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;The runtime is already installed.&#39;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$signature</span> <span class="p">=</span> <span class="nb">Get-AuthenticodeSignature</span> <span class="nv">$installer</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$signer</span> <span class="p">=</span> <span class="nv">$signature</span><span class="p">.</span><span class="py">SignerCertificate</span><span class="p">.</span><span class="py">GetNameInfo</span><span class="p">(</span><span class="s1">&#39;SimpleName&#39;</span><span class="p">,</span> <span class="vm">$false</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nv">$signature</span><span class="p">.</span><span class="py">Status</span> <span class="o">-ne</span> <span class="s1">&#39;Valid&#39;</span> <span class="o">-or</span> <span class="nv">$signer</span> <span class="o">-ne</span> <span class="s1">&#39;Microsoft Corporation&#39;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">throw</span> <span class="s2">&#34;Unexpected signature: </span><span class="p">$(</span><span class="nv">$signature</span><span class="p">.</span><span class="n">Status</span><span class="p">)</span><span class="s2">, signed by &#39;</span><span class="nv">$signer</span><span class="s2">&#39;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$run</span> <span class="p">=</span> <span class="nb">Start-Process</span> <span class="nv">$installer</span> <span class="n">-ArgumentList</span> <span class="s1">&#39;/install&#39;</span><span class="p">,</span> <span class="s1">&#39;/quiet&#39;</span><span class="p">,</span> <span class="s1">&#39;/norestart&#39;</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">        <span class="n">-Wait</span> <span class="n">-PassThru</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Installer exit code: </span><span class="p">$(</span><span class="nv">$run</span><span class="p">.</span><span class="n">ExitCode</span><span class="p">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$now</span> <span class="p">=</span> <span class="nb">Get-ItemProperty</span> <span class="nv">$key</span> <span class="n">-ErrorAction</span> <span class="n">SilentlyContinue</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$current</span> <span class="p">=</span> <span class="k">if</span> <span class="p">(</span><span class="nv">$now</span><span class="p">.</span><span class="py">Installed</span> <span class="o">-eq</span> <span class="mf">1</span><span class="p">)</span> <span class="p">{</span> <span class="p">[</span><span class="no">version</span><span class="p">]</span><span class="nv">$now</span><span class="p">.</span><span class="py">Version</span><span class="p">.</span><span class="py">TrimStart</span><span class="p">(</span><span class="s1">&#39;v&#39;</span><span class="p">)</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">-not</span> <span class="p">(</span><span class="nv">$current</span> <span class="o">-ge</span> <span class="nv">$bundled</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">throw</span> <span class="s1">&#39;The runtime is still missing or older after the installer ran.&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nv">$run</span><span class="p">.</span><span class="py">ExitCode</span> <span class="o">-eq</span> <span class="mf">3010</span><span class="p">)</span> <span class="p">{</span> <span class="s1">&#39;The installer asks for a restart.&#39;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p><code>/install /quiet /norestart</code> are options listed on <a href="https://learn.microsoft.com/en-us/cpp/windows/redistributing-visual-cpp-files" rel="external noopener" target="_blank">Microsoft&rsquo;s page on the redistributable packages<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/configure-7-days-to-die-server-and-open-ports/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>. That page also says a package isn&rsquo;t installed when a more recent version is on the machine, and returns an error, so the script compares versions first. A <code>Status</code> of <code>Valid</code> only says that some trusted publisher signed the file. That is why the script also checks the name on the certificate. The exit code was <code>0</code> here, with no restart pending. With <code>/quiet</code> the installer shows no error message, so the exit code and the second registry read are the only signs that it failed. An exit code of <code>3010</code> means a restart is needed.</p>
<p>The installer in the server&rsquo;s folder is byte for byte the file Microsoft&rsquo;s permalink serves. That permalink, <code>https://aka.ms/vc14/vc_redist.x64.exe</code>, is the one Microsoft&rsquo;s <a href="https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist" rel="external noopener" target="_blank">list of the latest supported Visual C++ Redistributable downloads<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/configure-7-days-to-die-server-and-open-ports/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a> gives for the x64 package. Download from it if your server folder has no <code>_CommonRedist</code>.</p>
<h2 id="2-set-the-servers-name-password-port-and-slots">2. Set the server&rsquo;s name, password, port and slots<a class="heading-anchor" href="https://looselydecoupled.com/configure-7-days-to-die-server-and-open-ports/#2-set-the-servers-name-password-port-and-slots"
     aria-label="Link to section: 2. Set the server’s name, password, port and slots" data-pagefind-ignore></a>
</h2>
<p>The server reads <code>serverconfig.xml</code> in its install folder. It is a flat list of <code>&lt;property name=&quot;...&quot; value=&quot;...&quot;/&gt;</code> lines, each with a comment after it, and it ships with placeholder values. Don&rsquo;t edit that file in place. The previous part showed <code>app_update</code> with <code>validate</code> restoring it to the shipped version, and the server takes any file name through <code>-configfile=</code>. Make a copy under your own name and edit the copy. This creates it beside the original:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Copy-Item</span> <span class="n">C:</span><span class="p">\</span><span class="n">7DaysToDieServer</span><span class="p">\</span><span class="n">serverconfig</span><span class="p">.</span><span class="py">xml</span> <span class="n">C:</span><span class="p">\</span><span class="n">7DaysToDieServer</span><span class="p">\</span><span class="n">myserver</span><span class="p">.</span><span class="n">xml</span></span></span></code></pre></div></div>
  </div>
</div><p>The settings to change first:</p>
<div class="table-wrap" data-scrollable data-label="Table: Property">
  <table>
    <thead>
      <tr><th scope="col">Property</th><th scope="col">Shipped value</th><th scope="col">What it sets</th></tr>
    </thead>
    <tbody>
      <tr><td><code>ServerName</code></td><td><code>My Game Host</code></td><td>The name in the server list</td></tr>
      <tr><td><code>ServerPassword</code></td><td>empty</td><td>The password to join. Empty means none</td></tr>
      <tr><td><code>ServerPort</code></td><td><code>26900</code></td><td>The game port, from 1024 to 65530. The other ports follow it</td></tr>
      <tr><td><code>ServerMaxPlayerCount</code></td><td><code>8</code></td><td>Players at once</td></tr>
    </tbody>
  </table>
</div>
<p>This function changes one value and leaves the rest of the file as it was:</p>
<div class="code-block" data-copyable data-copy-label="Set-ServerProperty.ps1">
  <div class="code-block__head"><span class="code-block__lang code-block__file">Set-ServerProperty.ps1</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="kd">function</span><span class="w"> </span><span class="nb">Set-ServerProperty</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">param</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="p">[</span><span class="nb">Parameter</span><span class="p">(</span><span class="na">Mandatory</span><span class="p">)][</span><span class="no">string</span><span class="p">]</span><span class="nv">$Path</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">[</span><span class="nb">Parameter</span><span class="p">(</span><span class="na">Mandatory</span><span class="p">)][</span><span class="no">string</span><span class="p">]</span><span class="nv">$Name</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">[</span><span class="nb">Parameter</span><span class="p">(</span><span class="na">Mandatory</span><span class="p">)][</span><span class="n">AllowEmptyString</span><span class="p">()][</span><span class="no">string</span><span class="p">]</span><span class="nv">$Value</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c"># XML turns a line break inside an attribute into a space.</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nv">$Value</span> <span class="o">-match</span> <span class="s1">&#39;[\r\n]&#39;</span><span class="p">)</span> <span class="p">{</span> <span class="k">throw</span> <span class="s2">&#34;The value can&#39;t contain a line break.&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c"># A property that is commented out, like UserDataFolder in the shipped</span>
</span></span><span class="line"><span class="cl">    <span class="c"># file, isn&#39;t a node, so a text edit would change the comment and</span>
</span></span><span class="line"><span class="cl">    <span class="c"># nothing else.</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$original</span> <span class="p">=</span> <span class="p">[</span><span class="no">IO.File</span><span class="p">]::</span><span class="n">ReadAllText</span><span class="p">(</span><span class="nv">$Path</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">-not</span> <span class="p">([</span><span class="no">xml</span><span class="p">]</span><span class="nv">$original</span><span class="p">).</span><span class="py">SelectSingleNode</span><span class="p">(</span><span class="s2">&#34;//property[@name=&#39;</span><span class="nv">$Name</span><span class="s2">&#39;]&#34;</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">throw</span> <span class="s2">&#34;No active property named </span><span class="nv">$Name</span><span class="s2"> in </span><span class="nv">$Path</span><span class="s2">.&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c"># Replace the value in the text, so the rest of the file stays as it was.</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$pattern</span> <span class="p">=</span> <span class="s1">&#39;(&lt;property\s+name=&#34;&#39;</span> <span class="p">+</span> <span class="p">[</span><span class="no">regex</span><span class="p">]::</span><span class="n">Escape</span><span class="p">(</span><span class="nv">$Name</span><span class="p">)</span> <span class="p">+</span> <span class="s1">&#39;&#34;\s+value=&#34;)[^&#34;]*(&#34;)&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">([</span><span class="no">regex</span><span class="p">]::</span><span class="n">Matches</span><span class="p">(</span><span class="nv">$original</span><span class="p">,</span> <span class="nv">$pattern</span><span class="p">).</span><span class="py">Count</span> <span class="o">-ne</span> <span class="mf">1</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">throw</span> <span class="s2">&#34;Expected one double-quoted </span><span class="nv">$Name</span><span class="s2"> line in </span><span class="nv">$Path</span><span class="s2">. Edit it by hand.&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$escaped</span> <span class="p">=</span> <span class="p">[</span><span class="no">Security.SecurityElement</span><span class="p">]::</span><span class="n">Escape</span><span class="p">(</span><span class="nv">$Value</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$text</span> <span class="p">=</span> <span class="p">[</span><span class="no">regex</span><span class="p">]::</span><span class="n">Replace</span><span class="p">(</span><span class="nv">$original</span><span class="p">,</span> <span class="nv">$pattern</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">param</span><span class="p">(</span><span class="nv">$m</span><span class="p">)</span> <span class="nv">$m</span><span class="p">.</span><span class="n">Groups</span><span class="p">[</span><span class="mf">1</span><span class="p">].</span><span class="py">Value</span> <span class="p">+</span> <span class="nv">$escaped</span> <span class="p">+</span> <span class="nv">$m</span><span class="p">.</span><span class="n">Groups</span><span class="p">[</span><span class="mf">2</span><span class="p">].</span><span class="py">Value</span>
</span></span><span class="line"><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$hasBom</span> <span class="p">=</span> <span class="p">[</span><span class="no">IO.File</span><span class="p">]::</span><span class="n">ReadAllBytes</span><span class="p">(</span><span class="nv">$Path</span><span class="p">)[</span><span class="mf">0</span><span class="p">]</span> <span class="o">-eq</span> <span class="n">0xEF</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="no">IO.File</span><span class="p">]::</span><span class="n">WriteAllText</span><span class="p">(</span><span class="nv">$Path</span><span class="p">,</span> <span class="nv">$text</span><span class="p">,</span> <span class="p">[</span><span class="no">Text.UTF8Encoding</span><span class="p">]::</span><span class="n">new</span><span class="p">(</span><span class="nv">$hasBom</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p>Run it on a stopped server, with your own values:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$config</span> <span class="p">=</span> <span class="s1">&#39;C:\7DaysToDieServer\myserver.xml&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Set-ServerProperty</span> <span class="n">-Path</span> <span class="nv">$config</span> <span class="n">-Name</span> <span class="n">ServerName</span> <span class="n">-Value</span> <span class="s1">&#39;Example Server&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Set-ServerProperty</span> <span class="n">-Path</span> <span class="nv">$config</span> <span class="n">-Name</span> <span class="n">ServerPassword</span> <span class="n">-Value</span> <span class="s1">&#39;&lt;password&gt;&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Set-ServerProperty</span> <span class="n">-Path</span> <span class="nv">$config</span> <span class="n">-Name</span> <span class="n">ServerMaxPlayerCount</span> <span class="n">-Value</span> <span class="mf">4</span></span></span></code></pre></div></div>
  </div>
</div><p>It changes one line per call and keeps the file&rsquo;s line endings and its comments. <code>-Value ''</code> clears a value, for example to remove the password again. It stops with an error and leaves the file alone when it can&rsquo;t find exactly one double-quoted line for the name, or when the value holds a line break. Saving the file through PowerShell&rsquo;s <code>[xml]</code> type is shorter, but it rewrites the layout. The comments that trail each property move to lines of their own, and a line-by-line comparison shows about 260 changed lines for one new value.</p>
<h2 id="3-set-userdatafolder-to-choose-where-the-saves-go">3. Set <code>UserDataFolder</code> to choose where the saves go<a class="heading-anchor" href="https://looselydecoupled.com/configure-7-days-to-die-server-and-open-ports/#3-set-userdatafolder-to-choose-where-the-saves-go"
     aria-label="Link to section: 3. Set UserDataFolder to choose where the saves go" data-pagefind-ignore></a>
</h2>
<p>By default the server writes its world under the profile of the account that runs it: <code>%APPDATA%\7DaysToDie\Saves</code> for the account that ran it here. Started as SYSTEM by a scheduled task, it wrote to <code>C:\Windows\System32\config\systemprofile\AppData\Roaming\7DaysToDie\Saves</code> instead. <code>UserDataFolder</code> sets one place whichever account starts it. The shipped file has it commented out, and so does your copy, which is why <code>Set-ServerProperty</code> can&rsquo;t change it. Replace the comment with an active property, pointing at the install folder so that <code>Saves</code> sits beside your config file:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$config</span> <span class="p">=</span> <span class="s1">&#39;C:\7DaysToDieServer\myserver.xml&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$find</span> <span class="p">=</span> <span class="s2">&#34;//property[@name=&#39;UserDataFolder&#39;]&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$text</span> <span class="p">=</span> <span class="p">[</span><span class="no">IO.File</span><span class="p">]::</span><span class="n">ReadAllText</span><span class="p">(</span><span class="nv">$config</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(([</span><span class="no">xml</span><span class="p">]</span><span class="nv">$text</span><span class="p">).</span><span class="py">SelectSingleNode</span><span class="p">(</span><span class="nv">$find</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">throw</span> <span class="s1">&#39;UserDataFolder is already set. Use Set-ServerProperty.&#39;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">$pattern</span> <span class="p">=</span> <span class="s1">&#39;&lt;!--\s*&lt;property name=&#34;UserDataFolder&#34;[^&gt;]*&gt;\s*--&gt;&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$active</span> <span class="p">=</span> <span class="s1">&#39;&lt;property name=&#34;UserDataFolder&#34; value=&#34;C:\7DaysToDieServer&#34;/&gt;&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$text</span> <span class="p">=</span> <span class="nv">$text</span> <span class="o">-replace</span> <span class="nv">$pattern</span><span class="p">,</span> <span class="nv">$active</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">-not</span> <span class="p">([</span><span class="no">xml</span><span class="p">]</span><span class="nv">$text</span><span class="p">).</span><span class="py">SelectSingleNode</span><span class="p">(</span><span class="nv">$find</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">throw</span> <span class="s1">&#39;The commented UserDataFolder line from the shipped file was not found.&#39;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="no">IO.File</span><span class="p">]::</span><span class="n">WriteAllText</span><span class="p">(</span><span class="nv">$config</span><span class="p">,</span> <span class="nv">$text</span><span class="p">)</span></span></span></code></pre></div></div>
  </div>
</div><p>This works on a copy of the shipped file. On a file that was edited, it stops with an error and writes nothing, either because <code>UserDataFolder</code> is already active or because the comment no longer matches. The server creates the folders it needs. After a first run it holds <code>Saves</code>, with <code>serveradmin.xml</code> beside the world, plus <code>RfsCache</code> and <code>titlestorage</code>. A new Navezgane world was 17.2 MB. The backup in the published S3 article looks for <code>Saves</code> beside the install&rsquo;s config, which is where this puts it. That script archives <code>serverconfig.xml</code>, so change its <code>$Config</code> line to <code>myserver.xml</code>, or the archive holds the shipped settings and not yours.</p>
<h2 id="4-start-the-server-and-read-its-ports">4. Start the server and read its ports<a class="heading-anchor" href="https://looselydecoupled.com/configure-7-days-to-die-server-and-open-ports/#4-start-the-server-and-read-its-ports"
     aria-label="Link to section: 4. Start the server and read its ports" data-pagefind-ignore></a>
</h2>
<p>This starts the server detached, with the flags a dedicated server needs, and waits for the game&rsquo;s own ready message. It writes a log in the install folder and doesn&rsquo;t change the config:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$dir</span> <span class="p">=</span> <span class="s1">&#39;C:\7DaysToDieServer&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$log</span> <span class="p">=</span> <span class="nb">Join-Path</span> <span class="nv">$dir</span> <span class="s1">&#39;output_log.txt&#39;</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nb">Test-Path</span> <span class="n">-LiteralPath</span> <span class="nv">$log</span><span class="p">)</span> <span class="p">{</span> <span class="nb">Remove-Item</span> <span class="n">-LiteralPath</span> <span class="nv">$log</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">$server</span> <span class="p">=</span> <span class="nb">Start-Process</span> <span class="p">(</span><span class="nb">Join-Path</span> <span class="nv">$dir</span> <span class="s1">&#39;7DaysToDieServer.exe&#39;</span><span class="p">)</span> <span class="n">-WorkingDirectory</span> <span class="nv">$dir</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-PassThru</span> <span class="n">-ArgumentList</span> <span class="s1">&#39;-logfile&#39;</span><span class="p">,</span> <span class="nv">$log</span><span class="p">,</span> <span class="s1">&#39;-quit&#39;</span><span class="p">,</span> <span class="s1">&#39;-batchmode&#39;</span><span class="p">,</span> <span class="s1">&#39;-nographics&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;-configfile=myserver.xml&#39;</span><span class="p">,</span> <span class="s1">&#39;-dedicated&#39;</span>
</span></span><span class="line"><span class="cl"><span class="vm">$null</span> <span class="p">=</span> <span class="nv">$server</span><span class="p">.</span><span class="py">Handle</span>
</span></span><span class="line"><span class="cl"><span class="nv">$ready</span> <span class="p">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="nb">Test-Path</span> <span class="nv">$log</span><span class="p">)</span> <span class="o">-and</span> <span class="p">(</span><span class="nb">Select-String</span> <span class="n">-Path</span> <span class="nv">$log</span> <span class="n">-Pattern</span> <span class="s1">&#39;StartGame done&#39;</span> <span class="n">-Quiet</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">$deadline</span> <span class="p">=</span> <span class="p">(</span><span class="nb">Get-Date</span><span class="p">).</span><span class="py">AddMinutes</span><span class="p">(</span><span class="mf">5</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">while</span> <span class="p">(</span><span class="o">-not</span> <span class="nv">$server</span><span class="p">.</span><span class="py">HasExited</span> <span class="o">-and</span> <span class="o">-not</span> <span class="p">(&amp;</span> <span class="nv">$ready</span><span class="p">)</span> <span class="o">-and</span> <span class="p">(</span><span class="nb">Get-Date</span><span class="p">)</span> <span class="o">-lt</span> <span class="nv">$deadline</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Start-Sleep</span> <span class="n">-Seconds</span> <span class="mf">2</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nv">$server</span><span class="p">.</span><span class="n">HasExited</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">throw</span> <span class="s2">&#34;The server exited with code </span><span class="p">$(</span><span class="nv">$server</span><span class="p">.</span><span class="n">ExitCode</span><span class="p">)</span><span class="s2">. Read </span><span class="nv">$log</span><span class="s2">.&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">-not</span> <span class="p">(&amp;</span> <span class="nv">$ready</span><span class="p">))</span> <span class="p">{</span> <span class="k">throw</span> <span class="s2">&#34;The server wasn&#39;t ready after five minutes. Read </span><span class="nv">$log</span><span class="s2">.&#34;</span> <span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p>It deletes the old log first, so that a <code>StartGame done</code> from an earlier run can&rsquo;t pass for this one. It stops if it can&rsquo;t delete it, for example because an earlier server still has it open. It stops with an error if the server exits or isn&rsquo;t ready after five minutes. It took about 40 seconds here. Then list every port the process listens on:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$tcp</span> <span class="p">=</span> <span class="nb">Get-NetTCPConnection</span> <span class="n">-State</span> <span class="n">Listen</span> <span class="n">-OwningProcess</span> <span class="nv">$server</span><span class="p">.</span><span class="py">Id</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">ForEach-Object</span> <span class="p">{</span> <span class="s1">&#39;tcp {0}:{1}&#39;</span> <span class="o">-f</span> <span class="nv">$_</span><span class="p">.</span><span class="n">LocalAddress</span><span class="p">,</span> <span class="nv">$_</span><span class="p">.</span><span class="py">LocalPort</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">$udp</span> <span class="p">=</span> <span class="nb">Get-NetUDPEndpoint</span> <span class="n">-OwningProcess</span> <span class="nv">$server</span><span class="p">.</span><span class="py">Id</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">ForEach-Object</span> <span class="p">{</span> <span class="s1">&#39;udp {0}:{1}&#39;</span> <span class="o">-f</span> <span class="nv">$_</span><span class="p">.</span><span class="n">LocalAddress</span><span class="p">,</span> <span class="nv">$_</span><span class="p">.</span><span class="py">LocalPort</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">$tcp</span> <span class="p">+</span> <span class="nv">$udp</span> <span class="p">|</span> <span class="nb">Sort-Object</span></span></span></code></pre></div></div>
  </div>
</div><p>On the default config the output was:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">tcp 0.0.0.0:26900
</span></span><span class="line"><span class="cl">tcp 127.0.0.1:49532
</span></span><span class="line"><span class="cl">tcp 127.0.0.1:8081
</span></span><span class="line"><span class="cl">udp :::26902
</span></span><span class="line"><span class="cl">udp 0.0.0.0:11000
</span></span><span class="line"><span class="cl">udp 0.0.0.0:26900
</span></span><span class="line"><span class="cl">udp 0.0.0.0:26902
</span></span><span class="line"><span class="cl">udp 0.0.0.0:57573</span></span></code></pre></div></div>
  </div>
</div><p>The game&rsquo;s ports are the ones on <code>ServerPort</code>: TCP <code>26900</code>, UDP <code>26900</code>, and UDP <code>26902</code>. The rest aren&rsquo;t game ports:</p>
<ul>
<li>Telnet is on <code>127.0.0.1:8081</code>, reachable only from the server itself.</li>
<li>The server&rsquo;s LAN announcer is on <code>udp 0.0.0.0:11000</code>. Its log line reads <code>[LANMasterServerAnnouncer] listening on 11000 and multicast group 239.192.0.1</code>.</li>
<li>The high ports change on every start, and Windows chooses them.</li>
</ul>
<p>I didn&rsquo;t connect a player, so this says what the server listens on, not which of those ports a client uses. Nothing listened on UDP <code>26901</code> or <code>26903</code>. The server&rsquo;s own error message for a port it can&rsquo;t use lists TCP and UDP on <code>ServerPort</code>, and UDP on <code>ServerPort + 1</code> and <code>ServerPort + 2</code>, as the ports that must be free. The rules in step 5 open UDP <code>26900</code> to <code>26902</code> for that reason, though nothing listened on <code>26901</code>.</p>
<p>Stop the server before you carry on. Its telnet port accepts <code>shutdown</code> from the server itself when no telnet password is set. The server saved its world, logged <code>SaveAndCleanupWorld</code> and exited with code <code>0</code> in four seconds:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$telnet</span> <span class="p">=</span> <span class="nb">New-Object</span> <span class="n">Net</span><span class="p">.</span><span class="py">Sockets</span><span class="p">.</span><span class="py">TcpClient</span><span class="p">(</span><span class="s1">&#39;127.0.0.1&#39;</span><span class="p">,</span> <span class="mf">8081</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">$stream</span> <span class="p">=</span> <span class="nv">$telnet</span><span class="p">.</span><span class="py">GetStream</span><span class="p">()</span>
</span></span><span class="line"><span class="cl"><span class="nv">$bytes</span> <span class="p">=</span> <span class="p">[</span><span class="no">Text.Encoding</span><span class="p">]::</span><span class="n">ASCII</span><span class="p">.</span><span class="py">GetBytes</span><span class="p">(</span><span class="s2">&#34;shutdown</span><span class="se">`r`n</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">$stream</span><span class="p">.</span><span class="py">Write</span><span class="p">(</span><span class="nv">$bytes</span><span class="p">,</span> <span class="mf">0</span><span class="p">,</span> <span class="nv">$bytes</span><span class="p">.</span><span class="n">Length</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">-not</span> <span class="nv">$server</span><span class="p">.</span><span class="py">WaitForExit</span><span class="p">(</span><span class="mf">90000</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">throw</span> <span class="s1">&#39;The server was still running after 90 seconds.&#39;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p>The snippet throws if the server is still running after 90 seconds. It sends no password, so it works only while no telnet password is set. The next part covers stopping it from a script that falls back to other methods.</p>
<h2 id="5-open-the-ports-in-the-windows-firewall">5. Open the ports in the Windows firewall<a class="heading-anchor" href="https://looselydecoupled.com/configure-7-days-to-die-server-and-open-ports/#5-open-the-ports-in-the-windows-firewall"
     aria-label="Link to section: 5. Open the ports in the Windows firewall" data-pagefind-ignore></a>
</h2>
<p>Windows Firewall blocks inbound traffic on every profile unless a rule allows it. On this server <code>Get-NetFirewallProfile</code> read <code>NotConfigured</code> for the default, which doesn&rsquo;t say what applies. The active store does:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">Get-NetFirewallProfile</span> <span class="console__token">-PolicyStore</span> <span class="console__token">ActiveStore</span> <span class="console__token">|</span>
</span><span class="line"><span class="prompt">&gt;     </span><span class="console__token">Select-Object</span> <span class="console__token">Name,</span> <span class="console__token">DefaultInboundAction</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Name</span>    <span class="console__token">DefaultInboundAction</span>
</span><span class="line"><span class="console__token">----</span>    <span class="console__token">--------------------</span>
</span><span class="line"><span class="console__token">Domain</span>                 <span class="console__token">Block</span>
</span><span class="line"><span class="console__token">Private</span>                <span class="console__token">Block</span>
</span><span class="line"><span class="console__token">Public</span>                 <span class="console__token">Block</span></span></code></pre>
    </div>
  </div></div><p>This adds one TCP rule and one UDP rule, and replaces any rule that already has the same name, so a stale rule can&rsquo;t block players. It changes the firewall of this machine and nothing else:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$rules</span> <span class="p">=</span> <span class="vm">@</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="vm">@</span><span class="p">{</span> <span class="n">Name</span> <span class="p">=</span> <span class="s1">&#39;7DaysToDie-26900-TCP&#39;</span><span class="p">;</span> <span class="n">Protocol</span> <span class="p">=</span> <span class="s1">&#39;TCP&#39;</span><span class="p">;</span> <span class="n">Port</span> <span class="p">=</span> <span class="s1">&#39;26900&#39;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="vm">@</span><span class="p">{</span> <span class="n">Name</span> <span class="p">=</span> <span class="s1">&#39;7DaysToDie-26900-UDP&#39;</span><span class="p">;</span> <span class="n">Protocol</span> <span class="p">=</span> <span class="s1">&#39;UDP&#39;</span><span class="p">;</span> <span class="n">Port</span> <span class="p">=</span> <span class="s1">&#39;26900-26902&#39;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">foreach</span> <span class="p">(</span><span class="nv">$rule</span> <span class="k">in</span> <span class="nv">$rules</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Get-NetFirewallRule</span> <span class="n">-DisplayName</span> <span class="nv">$rule</span><span class="p">.</span><span class="py">Name</span> <span class="n">-ErrorAction</span> <span class="n">SilentlyContinue</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">        <span class="nb">Remove-NetFirewallRule</span>
</span></span><span class="line"><span class="cl">    <span class="nb">New-NetFirewallRule</span> <span class="n">-DisplayName</span> <span class="nv">$rule</span><span class="p">.</span><span class="py">Name</span> <span class="n">-Direction</span> <span class="n">Inbound</span> <span class="n">-Action</span> <span class="n">Allow</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">        <span class="n">-Protocol</span> <span class="nv">$rule</span><span class="p">.</span><span class="py">Protocol</span> <span class="n">-LocalPort</span> <span class="nv">$rule</span><span class="p">.</span><span class="py">Port</span> <span class="p">|</span> <span class="nb">Out-Null</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p>Read them back:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">Get-NetFirewallRule</span> <span class="console__token">-DisplayName</span> <span class="console__token">&#39;7DaysToDie-*&#39;</span> <span class="console__token">|</span> <span class="console__token">ForEach-Object</span> <span class="console__token">{</span>
</span><span class="line"><span class="prompt">&gt;     </span><span class="console__token">$port</span> <span class="console__token">=</span> <span class="console__token">$_</span> <span class="console__token">|</span> <span class="console__token">Get-NetFirewallPortFilter</span>
</span><span class="line"><span class="prompt">&gt;     </span><span class="console__token">&#39;{0,-22}</span> <span class="console__token">{1}</span> <span class="console__token">{2}</span>  <span class="console__token">enabled={3}</span> <span class="console__token">action={4}&#39;</span> <span class="console__token">-f</span> <span class="console__token">$_.DisplayName,</span> <span class="console__token">$port.Protocol,</span>
</span><span class="line"><span class="prompt">&gt;         </span><span class="console__token">($port.LocalPort</span> <span class="console__token">-join</span> <span class="console__token">&#39;,&#39;),</span> <span class="console__token">$_.Enabled,</span> <span class="console__token">$_.Action</span>
</span><span class="line"><span class="prompt">&gt; </span><span class="console__token">}</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">7DaysToDie-26900-TCP</span>   <span class="console__token">TCP</span> <span class="console__token">26900</span>  <span class="console__token">enabled=True</span> <span class="console__token">action=Allow</span>
</span><span class="line"><span class="console__token">7DaysToDie-26900-UDP</span>   <span class="console__token">UDP</span> <span class="console__token">26900-26902</span>  <span class="console__token">enabled=True</span> <span class="console__token">action=Allow</span></span></code></pre>
    </div>
  </div></div><p>Each rule applies on every network profile and to every source address, because nothing in the loop narrows either. If you change <code>ServerPort</code>, the ports move with it. With <code>ServerPort</code> set to <code>26910</code> the server listened on TCP <code>26910</code> and on UDP <code>26910</code> and <code>26912</code>, so the rules need the new numbers.</p>
<!-- style-check: end procedure -->
<h2 id="ports-to-leave-closed">Ports to leave closed<a class="heading-anchor" href="https://looselydecoupled.com/configure-7-days-to-die-server-and-open-ports/#ports-to-leave-closed"
     aria-label="Link to section: Ports to leave closed" data-pagefind-ignore></a>
</h2>
<p>Don&rsquo;t open <code>8081</code> or <code>8080</code> to the internet. Telnet is a way to run server commands. Without a telnet password the server listens for it on <code>127.0.0.1</code> only. With <code>TelnetPassword</code> set, it listened on every interface, and enabling the web dashboard with <code>WebDashboardEnabled</code> opened TCP <code>8080</code> on every interface too. Both are for the server&rsquo;s own use, or for a rule limited with <code>-RemoteAddress</code> to your own address.</p>
<p>A firewall rule on the server doesn&rsquo;t open a cloud network security group or a router. If players can&rsquo;t connect with the rules above in place, those are the next places to look. I didn&rsquo;t test inbound connections from outside, so this article stops at the server&rsquo;s own firewall.</p>
<h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/configure-7-days-to-die-server-and-open-ports/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="server-exits-seconds-after-starting-with-an-eos-error">Server exits seconds after starting with an EOS error<a class="heading-anchor" href="https://looselydecoupled.com/configure-7-days-to-die-server-and-open-ports/#server-exits-seconds-after-starting-with-an-eos-error"
     aria-label="Link to section: Server exits seconds after starting with an EOS error" data-pagefind-ignore></a>
</h3>
<p>The server&rsquo;s log, in the file you passed as <code>-logfile</code>, holds the cause. These are three lines from it, in the order they appear, without their timestamps:</p>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Plugins:</span> <span class="console__token">Failed</span> <span class="console__token">to</span> <span class="console__token">load</span> <span class="console__token">&#39;C:/7DaysToDieServer/7DaysToDieServer_Data/Plugins\x86_64/BacktraceCrashpadWindows.dll&#39;</span> <span class="console__token">because</span> <span class="console__token">one</span> <span class="console__token">or</span> <span class="console__token">more</span> <span class="console__token">of</span> <span class="console__token">its</span> <span class="console__token">dependencies</span> <span class="console__token">could</span> <span class="console__token">not</span> <span class="console__token">be</span> <span class="console__token">loaded.</span>
</span><span class="line"><span class="console__token">ERR</span> <span class="console__token">[EOS]</span> <span class="console__token">Native</span> <span class="console__token">library</span> <span class="console__token">or</span> <span class="console__token">one</span> <span class="console__token">of</span> <span class="console__token">its</span> <span class="console__token">dependencies</span> <span class="console__token">not</span> <span class="console__token">found</span> <span class="console__token">(e.g.</span> <span class="console__token">no</span> <span class="console__token">Microsoft</span> <span class="console__token">Visual</span> <span class="console__token">C</span> <span class="console__token">Redistributables</span> <span class="console__token">2022)</span>
</span><span class="line"><span class="console__token">InvalidOperationException:</span> <span class="console__token">GamePrefs</span> <span class="console__token">is</span> <span class="console__token">being</span> <span class="console__token">accessed</span> <span class="console__token">before</span> <span class="console__token">it</span> <span class="console__token">is</span> <span class="console__token">ready.</span></span></code></pre>
    </div>
  </div></div><p>The process exited with code <code>0</code> and listened on no port. I reproduced it by uninstalling the runtime, and the server started again once step 1 reinstalled it. <code>vcruntime140.dll</code> and <code>msvcp140.dll</code> were missing from <code>System32</code>. SteamCMD had put the installer in <code>_CommonRedist</code> but not run it. The log also holds <code>ERROR: Shader ... not supported on this GPU</code> lines. A server with no GPU prints them on a healthy start too, so they aren&rsquo;t the cause. Search the log for <code>EOS</code>.</p>
<h3 id="server-exits-seconds-after-starting-with-an-invalid-port">Server exits seconds after starting with an invalid port<a class="heading-anchor" href="https://looselydecoupled.com/configure-7-days-to-die-server-and-open-ports/#server-exits-seconds-after-starting-with-an-invalid-port"
     aria-label="Link to section: Server exits seconds after starting with an invalid port" data-pagefind-ignore></a>
</h3>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">ERR</span> <span class="console__token">NET:</span> <span class="console__token">Starting</span> <span class="console__token">server</span> <span class="console__token">protocols</span> <span class="console__token">failed:</span> <span class="console__token">Invalid</span> <span class="console__token">ServerPort</span> <span class="console__token">65534,</span> <span class="console__token">must</span> <span class="console__token">be</span> <span class="console__token">within</span> <span class="console__token">1024</span> <span class="console__token">and</span> <span class="console__token">65530</span>
</span><span class="line"><span class="console__token">ERR</span> <span class="console__token">Make</span> <span class="console__token">sure</span> <span class="console__token">all</span> <span class="console__token">required</span> <span class="console__token">ports</span> <span class="console__token">are</span> <span class="console__token">unused:</span> <span class="console__token">65534/TCP,</span> <span class="console__token">65534/UDP,</span> <span class="console__token">65535/UDP,</span> <span class="console__token">65536/UDP</span></span></code></pre>
    </div>
  </div></div><p><code>ServerPort</code> must be from 1024 to 65530. With <code>65534</code>, and with <code>1000</code>, the server exited after about ten seconds with code <code>0</code>, and <code>65530</code> started normally. The second line is the server&rsquo;s own list of the ports it needs free: <code>ServerPort</code> for TCP and UDP, then UDP on the next two.</p>
<h3 id="set-serverproperty-says-no-active-property-named"><code>Set-ServerProperty</code> says <code>No active property named</code><a class="heading-anchor" href="https://looselydecoupled.com/configure-7-days-to-die-server-and-open-ports/#set-serverproperty-says-no-active-property-named"
     aria-label="Link to section: Set-ServerProperty says No active property named" data-pagefind-ignore></a>
</h3>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">No</span> <span class="console__token">active</span> <span class="console__token">property</span> <span class="console__token">named</span> <span class="console__token">UserDataFolder</span> <span class="console__token">in</span> <span class="console__token">C:\7DaysToDieServer\myserver.xml.</span></span></code></pre>
    </div>
  </div></div><p>Either the name is misspelled, or the property is commented out, as <code>UserDataFolder</code> is in the shipped file. Use the replacement in step 3 for a commented-out property. If the line uses single quotes, or the name appears twice, it stops with <code>Expected one double-quoted &lt;name&gt; line</code> and doesn&rsquo;t write.</p>
<h3 id="two-rules-with-the-same-name">Two rules with the same name<a class="heading-anchor" href="https://looselydecoupled.com/configure-7-days-to-die-server-and-open-ports/#two-rules-with-the-same-name"
     aria-label="Link to section: Two rules with the same name" data-pagefind-ignore></a>
</h3>
<p><code>New-NetFirewallRule</code> doesn&rsquo;t refuse a name that exists. Running it twice made two rules named the same, which is why step 5 removes a rule of that name before it adds one. To remove the extra rules and start again:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Get-NetFirewallRule</span> <span class="n">-DisplayName</span> <span class="s1">&#39;7DaysToDie-*&#39;</span> <span class="p">|</span> <span class="nb">Remove-NetFirewallRule</span></span></span></code></pre></div></div>
  </div>
</div><div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>OS</dt> <dd>Windows Server 2025 Datacenter (10.0.26100), virtual machine</dd></div>
    <div class="tested__row"><dt>PowerShell</dt> <dd>Windows PowerShell 5.1, elevated</dd></div>
    <div class="tested__row"><dt>Game</dt> <dd>7 Days to Die dedicated server (294420), V 3.2.0 (b10)</dd></div>
    <div class="tested__row"><dt>Runtime</dt> <dd>Microsoft Visual C&#43;&#43; v14 Redistributable (x64)</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
    <item>
      <title>Why $LASTEXITCODE Doesn&#39;t Work the Way You Expect in PowerShell</title>
      <link>https://looselydecoupled.com/powershell-lastexitcode-cmdlet-errors/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/powershell-lastexitcode-cmdlet-errors/</guid>
      <pubDate>Wed, 23 Sep 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>PowerShell</category>
      <category>Windows Server</category>
      <category>Automation</category>
      <category>AWS</category>
      
      <description>Cmdlets never set LASTEXITCODE, so after a failed cmdlet it still holds whatever the last native program returned, often zero.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p><code>$LASTEXITCODE</code> is not the success status of the last PowerShell cmdlet. Use it
for a native executable such as <code>aws.exe</code>, and capture it immediately:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="n">aws</span> <span class="p">-</span><span class="n">-version</span>
</span></span><span class="line"><span class="cl"><span class="nv">$Code</span> <span class="p">=</span> <span class="nv">$LASTEXITCODE</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nv">$Code</span> <span class="o">-ne</span> <span class="mf">0</span><span class="p">)</span> <span class="p">{</span> <span class="k">throw</span> <span class="s2">&#34;AWS CLI exited with code </span><span class="nv">$Code</span><span class="s2">&#34;</span> <span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p>For a cmdlet, use PowerShell error handling:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Get-Item</span> <span class="n">-LiteralPath</span> <span class="s1">&#39;C:\Data\settings.json&#39;</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span></span></span></code></pre></div></div>
  </div>
</div><p><code>-ErrorAction Stop</code> turns a cmdlet&rsquo;s non-terminating error into a terminating
one, which stops the script or reaches an enclosing <code>catch</code>.</p>

</div>

<p>While working on a backup script for my
<a href="https://looselydecoupled.com/backup-7-days-to-die-server-s3/">7 Days to Die game server</a>,
I thought it would be good to share this.</p>
<p>You replace an AWS CLI command with a PowerShell cmdlet and keep the success
check. The cmdlet prints an error. The script announces success anyway.</p>
<p>The check still runs. It&rsquo;s reading the result of a different operation.</p>
<!-- style-check: procedure -->
<h2 id="reproduce-a-failed-command-with-exit-code-zero">Reproduce a failed command with exit code zero<a class="heading-anchor" href="https://looselydecoupled.com/powershell-lastexitcode-cmdlet-errors/#reproduce-a-failed-command-with-exit-code-zero"
     aria-label="Link to section: Reproduce a failed command with exit code zero" data-pagefind-ignore></a>
</h2>
<p>Run this in PowerShell on Windows:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="n">cmd</span><span class="p">.</span><span class="py">exe</span> <span class="p">/</span><span class="n">d</span> <span class="p">/</span><span class="n">c</span> <span class="n">exit</span> <span class="mf">0</span>
</span></span><span class="line"><span class="cl"><span class="nb">Write-Error</span> <span class="s1">&#39;Demonstration: the upload failed&#39;</span> <span class="n">-ErrorAction</span> <span class="k">Continue</span>
</span></span><span class="line"><span class="cl"><span class="s2">&#34;Native exit code: </span><span class="nv">$LASTEXITCODE</span><span class="s2">&#34;</span></span></span></code></pre></div></div>
  </div>
</div><p>The error appears, followed by:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Native exit code: 0</span></span></code></pre></div></div>
  </div>
</div><p>The zero came from <code>cmd.exe</code>. <code>Write-Error</code> did not update it. The explicit
<code>Continue</code> makes this demonstration non-terminating even if your session normally
uses a stricter error preference.</p>
<p>The reverse is just as misleading:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="n">cmd</span><span class="p">.</span><span class="py">exe</span> <span class="p">/</span><span class="n">d</span> <span class="p">/</span><span class="n">c</span> <span class="n">exit</span> <span class="mf">7</span>
</span></span><span class="line"><span class="cl"><span class="nb">Get-Date</span> <span class="p">|</span> <span class="nb">Out-Null</span>
</span></span><span class="line"><span class="cl"><span class="s2">&#34;Native exit code: </span><span class="nv">$LASTEXITCODE</span><span class="s2">&#34;</span></span></span></code></pre></div></div>
  </div>
</div><p>This prints <code>7</code> even though <code>Get-Date</code> succeeded. These examples illustrate why
a value left in the variable is not evidence about a later cmdlet.</p>
<p>Microsoft defines the variable for native programs and PowerShell scripts;
script behavior also depends on invocation and the <code>exit</code> keyword. Ordinary
cmdlet success or failure does not make it a universal status register. See
<a href="https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_automatic_variables#lastexitcode" rel="external noopener" target="_blank">about_Automatic_Variables<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/powershell-lastexitcode-cmdlet-errors/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>.</p>
<h2 id="for-native-commands-check-each-result-at-the-boundary">For native commands, check each result at the boundary<a class="heading-anchor" href="https://looselydecoupled.com/powershell-lastexitcode-cmdlet-errors/#for-native-commands-check-each-result-at-the-boundary"
     aria-label="Link to section: For native commands, check each result at the boundary" data-pagefind-ignore></a>
</h2>
<p>This example assumes AWS CLI is installed, the source exists, and the active
AWS identity can access your chosen bucket:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$Source</span> <span class="p">=</span> <span class="s1">&#39;C:\Data\exports&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$Destination</span> <span class="p">=</span> <span class="s1">&#39;s3://YOUR-BUCKET/exports/&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="vm">$null</span> <span class="p">=</span> <span class="nb">Get-Command</span> <span class="n">aws</span> <span class="n">-CommandType</span> <span class="n">Application</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl"><span class="n">aws</span> <span class="n">s3</span> <span class="n">sync</span> <span class="nv">$Source</span> <span class="nv">$Destination</span>
</span></span><span class="line"><span class="cl"><span class="nv">$Code</span> <span class="p">=</span> <span class="nv">$LASTEXITCODE</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nv">$Code</span> <span class="o">-ne</span> <span class="mf">0</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">throw</span> <span class="s2">&#34;S3 sync did not complete cleanly; AWS CLI exit code </span><span class="nv">$Code</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="s1">&#39;Export transfer completed&#39;</span></span></span></code></pre></div></div>
  </div>
</div><p>Replace <code>YOUR-BUCKET</code> before running it. AWS documents nonzero outcomes including
failed S3 transfers and skipped files. A skipped file is enough to reject a
backup run as incomplete; inspect the command output for the cause. See the
<a href="https://docs.aws.amazon.com/cli/latest/userguide/cli-usage-returncodes.html" rel="external noopener" target="_blank">AWS CLI return-code reference<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/powershell-lastexitcode-cmdlet-errors/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>.</p>
<div class="admonition admonition--warning" role="note">
  <p class="admonition__label">
    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/powershell-lastexitcode-cmdlet-errors/#i-triangle-alert"/></svg>
    <span>Warning</span>
  </p>
  <p>Do not run three transfers and check only after the third. A later successful
native command replaces an earlier failure code with zero, so the script
reports success for a backup that partly failed. That is worse than no
success message at all: you now have positive evidence for something untrue,
and you will not look again until you need the data.</p>
</div><p>Capture and handle each result before moving on.</p>
<p>Also distinguish a process failure from a launch failure. If <code>aws</code> is not
found, no AWS CLI process returned an exit code, and the check reads whatever
an earlier native command left. The <code>Get-Command</code> line stops the script before
that can happen.</p>
<!-- style-check: end procedure -->
<h2 id="why-trycatch-sometimes-misses-a-cmdlet-error">Why try/catch sometimes misses a cmdlet error<a class="heading-anchor" href="https://looselydecoupled.com/powershell-lastexitcode-cmdlet-errors/#why-trycatch-sometimes-misses-a-cmdlet-error"
     aria-label="Link to section: Why try/catch sometimes misses a cmdlet error" data-pagefind-ignore></a>
</h2>
<p>PowerShell can report an error while continuing execution. For example:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Write-Error</span> <span class="s1">&#39;Demonstration failure&#39;</span> <span class="n">-ErrorAction</span> <span class="k">Continue</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;Still inside try&#39;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;Caught the error&#39;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p>The error is displayed and <code>Still inside try</code> prints. The catch block isn&rsquo;t
entered. Change that one invocation to <code>-ErrorAction Stop</code> and the catch block
runs instead.</p>
<p>Some errors already terminate execution; they don&rsquo;t need this promotion.
<code>-ErrorAction Stop</code> makes the intended behavior explicit for cmdlet errors that
would otherwise continue. Microsoft&rsquo;s <a href="https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_try_catch_finally" rel="external noopener" target="_blank">try/catch documentation<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/powershell-lastexitcode-cmdlet-errors/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
explains which errors those blocks handle.</p>
<h2 id="catch-a-write-s3object-failure-with--erroraction-stop">Catch a <code>Write-S3Object</code> failure with <code>-ErrorAction Stop</code><a class="heading-anchor" href="https://looselydecoupled.com/powershell-lastexitcode-cmdlet-errors/#catch-a-write-s3object-failure-with--erroraction-stop"
     aria-label="Link to section: Catch a Write-S3Object failure with -ErrorAction Stop" data-pagefind-ignore></a>
</h2>
<p>With <code>AWS.Tools.S3</code> installed and AWS access configured, use this for an
intentional upload of one existing file:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Import-Module</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="py">S3</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Write-S3Object</span> <span class="n">-BucketName</span> <span class="s1">&#39;YOUR-BUCKET&#39;</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">        <span class="o">-File</span> <span class="s1">&#39;C:\Data\exports\report.json&#39;</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">        <span class="n">-Key</span> <span class="s1">&#39;exports/report.json&#39;</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">        <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;Upload completed&#39;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Write-Warning</span> <span class="p">(</span><span class="s2">&#34;Upload failed: {0}&#34;</span> <span class="o">-f</span> <span class="nv">$_</span><span class="p">.</span><span class="py">Exception</span><span class="p">.</span><span class="n">Message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">throw</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p>The success message is reachable only after the upload returns normally. The
bare <code>throw</code> propagates the caught error to the caller. Logging and then
continuing would hide the failure from whatever invoked the script.</p>
<p>If you have no context to add and no recovery to attempt, remove the <code>try/catch</code>
and keep <code>-ErrorAction Stop</code>. A catch block that only rethrows isn&rsquo;t required.</p>
<h2 id="psnativecommanduseerroractionpreference-in-powershell-7"><code>$PSNativeCommandUseErrorActionPreference</code> in PowerShell 7<a class="heading-anchor" href="https://looselydecoupled.com/powershell-lastexitcode-cmdlet-errors/#psnativecommanduseerroractionpreference-in-powershell-7"
     aria-label="Link to section: $PSNativeCommandUseErrorActionPreference in PowerShell 7" data-pagefind-ignore></a>
</h2>
<p>PowerShell 7.4 added <code>$PSNativeCommandUseErrorActionPreference</code>. When it&rsquo;s
<code>$true</code>, nonzero native exit codes produce PowerShell errors governed by
<code>$ErrorActionPreference</code>; with <code>Stop</code>, those can reach <code>catch</code>. The default is
<code>$false</code>. Microsoft documents it in
<a href="https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_preference_variables#psnativecommanduseerroractionpreference" rel="external noopener" target="_blank">about_Preference_Variables<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/powershell-lastexitcode-cmdlet-errors/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>.</p>
<p>That doesn&rsquo;t make <code>-ErrorAction Stop</code> a parameter for <code>aws.exe</code>. Passed to a
native program, it&rsquo;s an argument for that program to interpret. Explicit
exit-code checks remain useful for scripts spanning Windows PowerShell 5.1 and
PowerShell 7, and for tools whose nonzero codes have special meanings.</p>
<p>If native error promotion is enabled with <code>Stop</code>, execution can leave the native
invocation before the following explicit check. Either path must preserve the
failure; don&rsquo;t catch it and then print success.</p>
<h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/powershell-lastexitcode-cmdlet-errors/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<p><strong>LASTEXITCODE is empty or stale.</strong> There may be no relevant native result in the
session. Choose the error mechanism for the operation you actually ran.</p>
<p><strong>Can I replace it with <code>$?</code> everywhere?</strong> <code>$?</code> describes the last command&rsquo;s
execution status and is overwritten by the next command. It isn&rsquo;t a durable
record of a multi-step job. Structured error handling is clearer for cmdlets.</p>
<p><strong>The task still reports success.</strong> Check whether a wrapper catches and suppresses
the error, then exits normally. At the outer script boundary, preserve a failure
through an unhandled terminating error or an intentional nonzero <code>exit</code> code.</p>
<p>A script needs to remember whether the work succeeded. That starts by listening
to the failure signal the command actually provides.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>OS</dt> <dd>Windows Server 2019 (10.0.17763)</dd></div>
    <div class="tested__row"><dt>PowerShell</dt> <dd>Windows PowerShell 5.1</dd></div>
    <div class="tested__row"><dt>PowerShell 7 section</dt> <dd>7.6.6 on macOS 27.0</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
    <item>
      <title>How to Install AWS Tools for PowerShell for All Users on Windows</title>
      <link>https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/</guid>
      <pubDate>Mon, 07 Sep 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>AWS</category>
      <category>S3</category>
      <category>PowerShell</category>
      <category>Windows Server</category>
      <category>Automation</category>
      
      <description>You end up with AWS.Tools in Program Files, where a task running as SYSTEM finds it; SYSTEM&#39;s module path never includes your Documents folder.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Open the PowerShell edition your automation uses <strong>as Administrator</strong>, then
install the modular S3 package for all users:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Install-Module</span> <span class="n">-Name</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="py">Installer</span> <span class="n">-MinimumVersion</span> <span class="mf">2.0</span><span class="p">.</span><span class="py">0</span> <span class="n">-MaximumVersion</span> <span class="mf">2.9</span><span class="p">.</span><span class="py">999</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-Repository</span> <span class="n">PSGallery</span> <span class="n">-Scope</span> <span class="n">AllUsers</span> <span class="n">-Force</span>
</span></span><span class="line"><span class="cl"><span class="nb">Import-Module</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="py">Installer</span> <span class="n">-MinimumVersion</span> <span class="mf">2.0</span><span class="p">.</span><span class="py">0</span> <span class="n">-MaximumVersion</span> <span class="mf">2.9</span><span class="p">.</span><span class="py">999</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl"><span class="nb">Install-AWSToolsModule</span> <span class="n">-Name</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="py">S3</span> <span class="n">-Version</span> <span class="s1">&#39;5.*&#39;</span> <span class="n">-Scope</span> <span class="n">AllUsers</span></span></span></code></pre></div></div>
  </div>
</div><p>Swap <code>AWS.Tools.S3</code> for the service module your script calls. An older 1.x
installer may already be there, and 1.x has no <code>-Version</code> parameter, so the
last line would fail with it. Without <code>-Force</code>, <code>Install-Module</code> only warns
that 1.x is already installed and leaves it. With <code>-Force</code> it installs 2.x
beside it, and the import line loads 2.x rather than 1.x. <code>-MaximumVersion 2.9.999</code> on both lines, as in AWS&rsquo;s own install command, keeps a future 3.x
out of a workflow written for 2.x.
Keep <code>-Version '5.*'</code>. Without it the installer takes the
latest major version, which can carry breaking changes, as AWS&rsquo;s
<a href="https://aws.amazon.com/blogs/developer/aws-tools-installer-v2-is-now-generally-available/" rel="external noopener" target="_blank">Installer v2 announcement<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
warns. With <code>-Name</code> it installs only the modules you name plus
<code>AWS.Tools.Common</code>, and leaves other AWS.Tools modules at their versions, so
name every module your tasks use together if they should move in step.</p>
<p>In Windows PowerShell 5.1, run this first:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$protocol</span> <span class="p">=</span> <span class="p">[</span><span class="no">Net.ServicePointManager</span><span class="p">]::</span><span class="n">SecurityProtocol</span>
</span></span><span class="line"><span class="cl"><span class="nv">$tls12</span> <span class="p">=</span> <span class="p">[</span><span class="no">Net.SecurityProtocolType</span><span class="p">]::</span><span class="n">Tls12</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nv">$protocol</span> <span class="o">-ne</span> <span class="s1">&#39;SystemDefault&#39;</span> <span class="o">-and</span> <span class="o">-not</span> <span class="p">(</span><span class="nv">$protocol</span> <span class="o">-band</span> <span class="nv">$tls12</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="no">Net.ServicePointManager</span><span class="p">]::</span><span class="n">SecurityProtocol</span> <span class="p">=</span> <span class="nv">$protocol</span> <span class="o">-bor</span> <span class="nv">$tls12</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="nb">Install-PackageProvider</span> <span class="n">-Name</span> <span class="n">NuGet</span> <span class="n">-MinimumVersion</span> <span class="mf">2.8</span><span class="p">.</span><span class="py">5</span><span class="p">.</span><span class="py">201</span> <span class="n">-Force</span> <span class="p">|</span> <span class="nb">Out-Null</span></span></span></code></pre></div></div>
  </div>
</div><p>The <code>if</code> adds TLS 1.2 only when the session has neither it nor the operating
system&rsquo;s default. It never removes a protocol, and it leaves the OS default
alone, as Microsoft&rsquo;s
<a href="https://learn.microsoft.com/en-us/dotnet/framework/network-programming/tls#recommendations" rel="external noopener" target="_blank">TLS guidance<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
recommends, since that default can negotiate newer versions. The gallery needs
TLS 1.2, as Microsoft&rsquo;s
<a href="https://devblogs.microsoft.com/powershell/powershell-gallery-tls-support/" rel="external noopener" target="_blank">PowerShell Gallery TLS post<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
explains, and so does the NuGet provider download, which is why the check
comes first. On the test server it changed nothing. The last line installs the
NuGet provider, and does nothing where it&rsquo;s already in place. If the gallery still can&rsquo;t be reached after both, TLS
isn&rsquo;t the cause; look at a proxy, DNS or a firewall. An install that reaches
the gallery and then fails is a different problem, such as the .NET or
PowerShellGet version the package requires.</p>
<p>Verify from a fresh session under the account that runs the task:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Import-Module</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="py">S3</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl"><span class="p">(</span><span class="nb">Get-Module</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="n">S3</span><span class="p">).</span><span class="n">ModuleBase</span></span></span></code></pre></div></div>
  </div>
</div><p>The path should start <code>$env:ProgramFiles\WindowsPowerShell\Modules\</code> for
Windows PowerShell, or <code>$env:ProgramFiles\PowerShell\Modules\</code> for PowerShell 7.
That&rsquo;s <code>C:\Program Files</code> for a 64-bit host and <code>C:\Program Files (x86)</code> for a
32-bit one.</p>
<div class="admonition admonition--note" role="note">
  <p class="admonition__label">
    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/#i-info"/></svg>
    <span>Note</span>
  </p>
  <p>Module availability and AWS authentication are separate problems that fail at
different points. A task that imports the module and then reports access
denied has finished with this article.</p>
</div>
</div>

<p>The script works in your terminal. Task Scheduler reports that <code>Write-S3Object</code>
isn&rsquo;t recognized.</p>
<p>Before rewriting the script, check where you installed its dependencies. A
module beneath your Documents folder is an installation for you. The scheduled
task may run as someone else, using a different PowerShell executable.</p>
<h2 id="choose-awstools-for-a-new-installation">Choose AWS.Tools for a new installation<a class="heading-anchor" href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/#choose-awstools-for-a-new-installation"
     aria-label="Link to section: Choose AWS.Tools for a new installation" data-pagefind-ignore></a>
</h2>
<p>AWS currently recommends <code>AWS.Tools</code>, with separate service modules such as
<code>AWS.Tools.S3</code> and a shared <code>AWS.Tools.Common</code> dependency. The installer manages
dependencies and coordinates service-module versions. Follow the
<a href="https://docs.aws.amazon.com/powershell/v5/userguide/ps-installing-awstools.html" rel="external noopener" target="_blank">AWS modular installation guide<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
for that workflow.</p>
<p>For a script that uploads to S3, install S3. Add <code>AWS.Tools.EC2</code> or another
service module when the script actually uses it.</p>
<p>The Windows installation options also include the large <code>AWSPowerShell.NetCore</code>
module and the legacy Windows-specific <code>AWSPowerShell</code> module. They are separate
packaging choices, not three prerequisites to install together. AWS lists them
in its <a href="https://docs.aws.amazon.com/powershell/v5/userguide/pstools-getting-set-up-windows.html" rel="external noopener" target="_blank">Windows setup overview<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>.</p>
<!-- style-check: procedure -->
<h2 id="use-the-same-powershell-host-as-the-scheduled-task">Use the same PowerShell host as the scheduled task<a class="heading-anchor" href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/#use-the-same-powershell-host-as-the-scheduled-task"
     aria-label="Link to section: Use the same PowerShell host as the scheduled task" data-pagefind-ignore></a>
</h2>
<p>Check the shell before installing:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$PSVersionTable</span> <span class="p">|</span> <span class="nb">Select-Object</span> <span class="n">PSVersion</span><span class="p">,</span> <span class="n">PSEdition</span>
</span></span><span class="line"><span class="cl"><span class="p">(</span><span class="nb">Get-Process</span> <span class="n">-Id</span> <span class="nv">$PID</span><span class="p">).</span><span class="py">Path</span>
</span></span><span class="line"><span class="cl"><span class="nv">$env:PSModulePath</span> <span class="n">-split</span> <span class="s1">&#39;;&#39;</span></span></span></code></pre></div></div>
  </div>
</div><p>For new automation, use a supported PowerShell 7 release. For an existing
Windows PowerShell 5.1 task, verify package and .NET requirements before changing
its dependencies. Do not assume a package&rsquo;s major-version upgrade is only a
packaging change.</p>
<p>Typical system-wide module locations differ:</p>
<div class="table-wrap" data-scrollable data-label="Table: Shell">
  <table>
    <thead>
      <tr><th scope="col">Shell</th><th scope="col">Usual AllUsers location</th></tr>
    </thead>
    <tbody>
      <tr><td>PowerShell 7 (<code>pwsh.exe</code>)</td><td><code>C:\Program Files\PowerShell\Modules</code></td></tr>
      <tr><td>Windows PowerShell 5.1 (<code>powershell.exe</code>)</td><td><code>C:\Program Files\WindowsPowerShell\Modules</code></td></tr>
    </tbody>
  </table>
</div>
<p>PowerShell discovers modules through <code>$env:PSModulePath</code>, and the sharing runs
one way: PowerShell 7 also searches the Windows PowerShell AllUsers folder, but
Windows PowerShell never searches PowerShell 7&rsquo;s. A successful import in one
edition is not proof that the other can load the same installation. Microsoft documents the defaults and
startup behavior in <a href="https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_psmodulepath" rel="external noopener" target="_blank">about_PSModulePath<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>.</p>
<p>Check the executable configured in Task Scheduler, including its architecture:
a 32-bit <code>powershell.exe</code> resolves <code>Program Files</code> to <code>Program Files (x86)</code>. Use
that host for the verification below.</p>
<h2 id="install-once-in-an-elevated-session">Install once in an elevated session<a class="heading-anchor" href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/#install-once-in-an-elevated-session"
     aria-label="Link to section: Install once in an elevated session" data-pagefind-ignore></a>
</h2>
<p>Run the commands in the answer box. Keep <code>-Scope AllUsers</code> on both installation
commands: installing the installer globally does not make the service package&rsquo;s
scope implicit.</p>
<p><code>Install-Module</code> comes from PowerShellGet. If it is missing, provision
PowerShellGet before running this workflow; an older installation may prompt
for a NuGet provider. Check that the repository is the one you intend to use:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Get-Command</span> <span class="nb">Install-Module</span>
</span></span><span class="line"><span class="cl"><span class="nb">Get-PSRepository</span> <span class="n">-Name</span> <span class="n">PSGallery</span></span></span></code></pre></div></div>
  </div>
</div><p>Review the Gallery prompt when it appears. A one-time installation does not
require changing the repository&rsquo;s trust policy permanently. The installer
itself comes from PSGallery, but <code>Install-AWSToolsModule</code> downloads the service
modules as one archive from <code>sdk-for-net.amazonwebservices.com</code>, which its help
says, and not from PSGallery. A server behind a proxy needs that host allowed
as well as the Gallery&rsquo;s own, which Microsoft lists under
<a href="https://learn.microsoft.com/en-us/powershell/gallery/getting-started#network-access-to-the-powershell-gallery" rel="external noopener" target="_blank">network access to the PowerShell Gallery<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>.
Microsoft&rsquo;s
<a href="https://learn.microsoft.com/en-us/powershell/module/powershellget/install-module" rel="external noopener" target="_blank">Install-Module documentation<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
explains scope, repository selection, and installation prompts.</p>
<p>The installer pulls in <code>AWS.Tools.Common</code> with S3. You do not need to install
every AWS module. The example also omits <code>-CleanUp</code>, which removes older module
versions; decide on cleanup after checking whether existing jobs pin them.</p>
<h2 id="verify-the-module-loads-as-the-task-account">Verify the module loads as the task account<a class="heading-anchor" href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/#verify-the-module-loads-as-the-task-account"
     aria-label="Link to section: Verify the module loads as the task account" data-pagefind-ignore></a>
</h2>
<p>Close the elevated session. You can&rsquo;t open a console as SYSTEM, and a
scheduled task keeps none of its output. Put these lines at the top of the
task&rsquo;s script, start it with <code>Start-ScheduledTask</code>, and read the log it writes:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="p">&amp;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="no">Security.Principal.WindowsIdentity</span><span class="p">]::</span><span class="n">GetCurrent</span><span class="p">().</span><span class="py">Name</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$env:PSModulePath</span> <span class="n">-split</span> <span class="s1">&#39;;&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="nb">Get-Module</span> <span class="n">-ListAvailable</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="n">Common</span><span class="p">,</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="py">S3</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">        <span class="nb">Select-Object</span> <span class="n">Name</span><span class="p">,</span> <span class="n">Version</span><span class="p">,</span> <span class="n">ModuleBase</span> <span class="p">|</span> <span class="nb">Format-Table</span> <span class="n">-AutoSize</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="nb">Import-Module</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="py">S3</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="nb">Get-Module</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="n">S3</span><span class="p">).</span><span class="py">ModuleBase</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="p">*&gt;</span> <span class="p">(</span><span class="nb">Join-Path</span> <span class="nv">$env:ProgramData</span> <span class="s1">&#39;aws-tools-check.log&#39;</span><span class="p">)</span></span></span></code></pre></div></div>
  </div>
</div><p>Run as SYSTEM, the first line prints <code>NT AUTHORITY\SYSTEM</code>. On the test
server SYSTEM&rsquo;s module path had no Documents folder in it at all. Where it has
one, it&rsquo;s SYSTEM&rsquo;s own profile, not yours, so a module you installed for
yourself is invisible to the task either way. The <code>-ListAvailable</code> call lists every copy the account can find; more than one
row per module means more than one installation. The last line is the copy that
actually loaded. These checks load code and resolve a command; they do not
contact S3.</p>
<p>For unattended work, put the explicit import in the script. That gives a
missing or incompatible dependency a clear failure point before any work starts.</p>
<!-- style-check: end procedure -->
<h2 id="where-awspowershellnetcore-still-fits">Where AWSPowerShell.NetCore still fits<a class="heading-anchor" href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/#where-awspowershellnetcore-still-fits"
     aria-label="Link to section: Where AWSPowerShell.NetCore still fits" data-pagefind-ignore></a>
</h2>
<p>An existing script may explicitly import <code>AWSPowerShell.NetCore</code>. If retaining
that package is part of maintaining the script, install it from the appropriate
elevated host:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Install-Module</span> <span class="n">-Name</span> <span class="n">AWSPowerShell</span><span class="p">.</span><span class="py">NetCore</span> <span class="n">-Repository</span> <span class="n">PSGallery</span> <span class="n">-Scope</span> <span class="n">AllUsers</span></span></span></code></pre></div></div>
  </div>
</div><p>That&rsquo;s an alternative to the modular path. Start a fresh session before
switching package families; loading overlapping AWS assemblies and command
names complicates diagnosis. AWS still documents
<a href="https://docs.aws.amazon.com/powershell/v5/userguide/ps-installing-awspowershellnetcore.html" rel="external noopener" target="_blank">AWSPowerShell.NetCore installation<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>,
while directing new installations to <code>AWS.Tools</code>.</p>
<h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="installation-fails-with-access-denied">Installation fails with access denied<a class="heading-anchor" href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/#installation-fails-with-access-denied"
     aria-label="Link to section: Installation fails with access denied" data-pagefind-ignore></a>
</h3>
<p>The installation session isn&rsquo;t elevated.
The ordinary task doesn&rsquo;t need administrator rights merely because an
administrator installed its dependencies.</p>
<h3 id="module-installed-but-the-task-cant-find-it">Module installed, but the task can&rsquo;t find it<a class="heading-anchor" href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/#module-installed-but-the-task-cant-find-it"
     aria-label="Link to section: Module installed, but the task can’t find it" data-pagefind-ignore></a>
</h3>
<p>Compare the task&rsquo;s
executable, account, module path, and <code>ModuleBase</code> with your interactive session.
Use the same verification commands in the task&rsquo;s diagnostic output.</p>
<h3 id="import-fails-because-script-execution-is-disabled">Import fails because script execution is disabled<a class="heading-anchor" href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/#import-fails-because-script-execution-is-disabled"
     aria-label="Link to section: Import fails because script execution is disabled" data-pagefind-ignore></a>
</h3>
<p>Inspect
<code>Get-ExecutionPolicy -List</code> and follow the machine&rsquo;s approved policy. Installation
scope and script-execution policy solve different problems.</p>
<h3 id="import-works-but-aws-reports-missing-credentials-or-access-denied">Import works, but AWS reports missing credentials or access denied<a class="heading-anchor" href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/#import-works-but-aws-reports-missing-credentials-or-access-denied"
     aria-label="Link to section: Import works, but AWS reports missing credentials or access denied" data-pagefind-ignore></a>
</h3>
<p>The installation is no longer the issue. Configure authentication for the task&rsquo;s
identity and verify its permissions. Your interactive profile isn&rsquo;t evidence
that another account has working AWS access.</p>
<p>The useful completion check is the task account loading the expected module in
the expected shell. An installer success message is only the first step.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>OS</dt> <dd>Windows Server 2019 (10.0.17763)</dd></div>
    <div class="tested__row"><dt>PowerShell</dt> <dd>Windows PowerShell 5.1</dd></div>
    <div class="tested__row"><dt>AWS Tools</dt> <dd>AWS.Tools.Installer 2.0.5, AWS.Tools.S3 5.0.304</dd></div>
    <div class="tested__row"><dt>Account</dt> <dd>SYSTEM, through a scheduled task</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
    <item>
      <title>AWS CLI sync vs. Write-S3Object: What Changes on the Second Run</title>
      <link>https://looselydecoupled.com/aws-cli-s3-sync-vs-write-s3object/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/aws-cli-s3-sync-vs-write-s3object/</guid>
      <pubDate>Mon, 07 Sep 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>AWS</category>
      <category>S3</category>
      <category>PowerShell</category>
      <category>Automation</category>
      
      <description>Write-S3Object is wrong for repeat runs into a versioned bucket, which keeps every copy; sync is wrong when a restored file keeps its size and old timestamp.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p><code>aws s3 sync</code> compares a source and destination before selecting transfers.
<code>Write-S3Object -Folder -Recurse</code> uploads every file in the folder hierarchy
on every run; recursion adds subfolders, not a destination comparison or
deletion pass.</p>
<p>Keep <code>sync</code> when your script depends on skipping files that already match. Use
<code>Write-S3Object</code> when uploading every selected file is the intended operation.
Changing tools also means changing error handling: native exit codes for the
CLI, PowerShell errors for the cmdlet.</p>

</div>

<p>The refactor looks harmless. Replace one external command with a PowerShell
cmdlet, run it, and confirm that the files appear in S3.</p>
<p>The first run proves little. The second run is where the behaviors separate.</p>
<h2 id="two-commands-that-can-produce-the-same-first-upload">Two commands that can produce the same first upload<a class="heading-anchor" href="https://looselydecoupled.com/aws-cli-s3-sync-vs-write-s3object/#two-commands-that-can-produce-the-same-first-upload"
     aria-label="Link to section: Two commands that can produce the same first upload" data-pagefind-ignore></a>
</h2>
<p>The following are alternatives, run from PowerShell. They assume an existing
source folder, a bucket you control, and configured AWS access. Replace the
bucket placeholder. The first needs AWS CLI; the second needs <code>AWS.Tools.S3</code>.</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$Source</span> <span class="p">=</span> <span class="s1">&#39;C:\Data\exports&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$Bucket</span> <span class="p">=</span> <span class="s1">&#39;YOUR-BUCKET&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="vm">$null</span> <span class="p">=</span> <span class="nb">Get-Command</span> <span class="n">aws</span> <span class="n">-CommandType</span> <span class="n">Application</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl"><span class="n">aws</span> <span class="n">s3</span> <span class="n">sync</span> <span class="nv">$Source</span> <span class="s2">&#34;s3://</span><span class="nv">$Bucket</span><span class="s2">/exports/&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$Code</span> <span class="p">=</span> <span class="nv">$LASTEXITCODE</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nv">$Code</span> <span class="o">-ne</span> <span class="mf">0</span><span class="p">)</span> <span class="p">{</span> <span class="k">throw</span> <span class="s2">&#34;S3 sync exited with code </span><span class="nv">$Code</span><span class="s2">&#34;</span> <span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$Source</span> <span class="p">=</span> <span class="s1">&#39;C:\Data\exports&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$Bucket</span> <span class="p">=</span> <span class="s1">&#39;YOUR-BUCKET&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">Import-Module</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="py">S3</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl"><span class="nb">Write-S3Object</span> <span class="n">-BucketName</span> <span class="nv">$Bucket</span> <span class="n">-Folder</span> <span class="nv">$Source</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-KeyPrefix</span> <span class="s1">&#39;exports/&#39;</span> <span class="n">-Recurse</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span></span></span></code></pre></div></div>
  </div>
</div><p>For a source containing <code>report.json</code> and <code>daily\summary.csv</code>, the intended keys
are <code>exports/report.json</code> and <code>exports/daily/summary.csv</code>. Keep the prefix fixed
when comparing repeated runs; a new prefix gives either command an empty
destination namespace.</p>
<p>AWS describes the cmdlet as an uploader for files, content, or folder trees.
<code>-Recurse</code> includes subfolders. It doesn&rsquo;t implement the CLI&rsquo;s sync algorithm.
See the <a href="https://docs.aws.amazon.com/powershell/v5/reference/items/Write-S3Object.html" rel="external noopener" target="_blank">Write-S3Object reference<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/aws-cli-s3-sync-vs-write-s3object/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>.</p>
<h2 id="what-each-command-does-on-a-repeat-run">What each command does on a repeat run<a class="heading-anchor" href="https://looselydecoupled.com/aws-cli-s3-sync-vs-write-s3object/#what-each-command-does-on-a-repeat-run"
     aria-label="Link to section: What each command does on a repeat run" data-pagefind-ignore></a>
</h2>
<div class="table-wrap" data-scrollable data-label="Table: Behavior">
  <table>
    <thead>
      <tr><th scope="col">Behavior</th><th scope="col"><code>aws s3 sync</code></th><th scope="col"><code>Write-S3Object -Folder -Recurse</code></th></tr>
    </thead>
    <tbody>
      <tr><td>Select transfers by comparing destination</td><td>Yes</td><td>No sync comparison</td></tr>
      <tr><td>Upload an unchanged selected file again</td><td>Usually skipped by comparison</td><td>Uploaded again</td></tr>
      <tr><td>Traverse subfolders</td><td>Built in</td><td>Requires <code>-Recurse</code></td></tr>
      <tr><td>Remove destination-only objects</td><td>Only with <code>--delete</code></td><td>No reconciliation of deletions</td></tr>
      <tr><td>Preview a sync plan</td><td><code>--dryrun</code></td><td>No equivalent sync plan</td></tr>
      <tr><td>Report failure to PowerShell</td><td>Native exit code</td><td>PowerShell error</td></tr>
    </tbody>
  </table>
</div>
<p>For local-to-S3 sync, the default upload criteria are a missing destination
object, a different size, or a local modification time newer than the S3
object&rsquo;s last-modified time. That time is when the object was uploaded. It isn&rsquo;t a
byte-for-byte scan of every file. A same-size file restored or copied with its
original, older timestamp is skipped, and S3 keeps the stale bytes. <code>--size-only</code> discards the time criterion;
<code>--exact-timestamps</code> applies to S3-to-local sync. These rules and directions are
documented in the <a href="https://docs.aws.amazon.com/cli/latest/reference/s3/sync.html" rel="external noopener" target="_blank">AWS CLI sync reference<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/aws-cli-s3-sync-vs-write-s3object/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>.</p>
<p>This is the migration question: did your script promise to upload a collection
of files, or did it promise to transfer only what its comparison considers
changed? Similar-looking final keys don&rsquo;t answer that question.</p>
<h2 id="deletions-are-a-separate-decision">Deletions are a separate decision<a class="heading-anchor" href="https://looselydecoupled.com/aws-cli-s3-sync-vs-write-s3object/#deletions-are-a-separate-decision"
     aria-label="Link to section: Deletions are a separate decision" data-pagefind-ignore></a>
</h2>
<p>Deleting a local file doesn&rsquo;t remove its S3 object in either example above.
For the CLI, <code>--delete</code> explicitly adds destination cleanup. Preview that choice
before applying it:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="n">aws</span> <span class="n">s3</span> <span class="n">sync</span> <span class="s1">&#39;C:\Data\exports&#39;</span> <span class="s1">&#39;s3://YOUR-BUCKET/exports/&#39;</span> <span class="p">-</span><span class="n">-delete</span> <span class="p">-</span><span class="n">-dryrun</span>
</span></span><span class="line"><span class="cl"><span class="nv">$Code</span> <span class="p">=</span> <span class="nv">$LASTEXITCODE</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nv">$Code</span> <span class="o">-ne</span> <span class="mf">0</span><span class="p">)</span> <span class="p">{</span> <span class="k">throw</span> <span class="s2">&#34;Sync preview exited with code </span><span class="nv">$Code</span><span class="s2">&#34;</span> <span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p>The preview reports proposed operations; it isn&rsquo;t proof that subsequent writes
or deletions will be authorized or succeed. Review the exact bucket and prefix
before removing <code>--dryrun</code>.</p>
<div class="admonition admonition--caution" role="note">
  <p class="admonition__label">
    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/aws-cli-s3-sync-vs-write-s3object/#i-octagon-alert"/></svg>
    <span>Caution</span>
  </p>
  <p>A destination prefix shared by unrelated jobs is a poor place for <code>--delete</code>.
A file absent from one job&rsquo;s source may still belong to another, and the
reconciliation will remove it. Give each source a deliberate namespace before
enabling deletion. In a versioned bucket the delete adds a delete marker, so
the object can be recovered until lifecycle rules expire its noncurrent
versions; in an unversioned bucket it&rsquo;s gone.</p>
</div><p>For backups, destination-only files create another issue: restoring all current
objects can resurrect files that were intentionally removed locally. Decide
whether you need a current mirror or recoverable snapshots before choosing how
to handle that case.</p>
<h2 id="repeated-uploads-in-a-versioned-bucket">Repeated uploads in a versioned bucket<a class="heading-anchor" href="https://looselydecoupled.com/aws-cli-s3-sync-vs-write-s3object/#repeated-uploads-in-a-versioned-bucket"
     aria-label="Link to section: Repeated uploads in a versioned bucket" data-pagefind-ignore></a>
</h2>
<p>In a bucket with versioning enabled, another successful overwrite creates
another object version. Each stored version is a complete object, not only the
changed bytes. AWS explains this in its
<a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/Versioning.html" rel="external noopener" target="_blank">S3 Versioning guide<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/aws-cli-s3-sync-vs-write-s3object/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>.</p>
<p>Consequently, repeatedly uploading an unchanged tree can create redundant
versions as well as extra transfer work and requests. How much that matters
depends on your data and schedule. A small configuration directory and a large
export tree need different tradeoffs.</p>
<p>Conversely, a fresh timestamped prefix intentionally stores a new set of keys.
There&rsquo;s no previous tree there for <code>sync</code> to reuse. For that design, uploading
every file may be exactly what you want.</p>
<p>Neither approach makes a live folder an atomic snapshot. If several related
files change during the transfer, the destination can contain a mixture of
moments. Use an application-consistent export or stable staging directory when
a restore requires the files to agree with each other.</p>
<h2 id="preserve-the-failure-contract-when-changing-tools">Preserve the failure contract when changing tools<a class="heading-anchor" href="https://looselydecoupled.com/aws-cli-s3-sync-vs-write-s3object/#preserve-the-failure-contract-when-changing-tools"
     aria-label="Link to section: Preserve the failure contract when changing tools" data-pagefind-ignore></a>
</h2>
<p>The CLI example checks its result immediately. A final check after several
native commands can miss an earlier failure. AWS&rsquo;s
<a href="https://docs.aws.amazon.com/cli/latest/userguide/cli-usage-returncodes.html" rel="external noopener" target="_blank">return-code documentation<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/aws-cli-s3-sync-vs-write-s3object/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
includes failed and skipped S3 transfers among nonzero outcomes.</p>
<p>For the cmdlet, add context and propagate the error if needed:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Write-S3Object</span> <span class="n">-BucketName</span> <span class="nv">$Bucket</span> <span class="n">-Folder</span> <span class="nv">$Source</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">        <span class="n">-KeyPrefix</span> <span class="s1">&#39;exports/&#39;</span> <span class="n">-Recurse</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Write-Warning</span> <span class="p">(</span><span class="s2">&#34;Export upload failed: {0}&#34;</span> <span class="o">-f</span> <span class="nv">$_</span><span class="p">.</span><span class="py">Exception</span><span class="p">.</span><span class="n">Message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">throw</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p>Don&rsquo;t leave an old <code>$LASTEXITCODE</code> check after the cmdlet. The separate
<a href="https://looselydecoupled.com/powershell-lastexitcode-cmdlet-errors/">PowerShell error-handling article</a>
shows why that can report success for a failed upload.</p>
<h2 id="check-a-migration-before-trusting-the-replacement">Check a migration before trusting the replacement<a class="heading-anchor" href="https://looselydecoupled.com/aws-cli-s3-sync-vs-write-s3object/#check-a-migration-before-trusting-the-replacement"
     aria-label="Link to section: Check a migration before trusting the replacement" data-pagefind-ignore></a>
</h2>
<p>Use a disposable source folder and dedicated destination prefix:</p>
<ol>
<li>Upload two files and record their destination keys.</li>
<li>Repeat without editing either file; inspect actual uploads or object versions.</li>
<li>Change one file&rsquo;s size and repeat; check which objects are written.</li>
<li>Remove one local file; inspect the destination-only object.</li>
<li>Use a missing source path and verify the overall script fails.</li>
<li>Use a <code>-SearchPattern</code> that matches nothing and confirm the script notices.
<code>Write-S3Object</code> uploads nothing and reports no error.</li>
</ol>
<p>Those checks exercise the behavior a syntax translation can overlook. They also
make your deletion and repeat-run expectations explicit.</p>
<h2 id="when-each-is-the-wrong-choice">When each is the wrong choice<a class="heading-anchor" href="https://looselydecoupled.com/aws-cli-s3-sync-vs-write-s3object/#when-each-is-the-wrong-choice"
     aria-label="Link to section: When each is the wrong choice" data-pagefind-ignore></a>
</h2>
<p><code>aws s3 sync</code> is the wrong choice when:</p>
<ul>
<li>A file can come back with its size and an older timestamp, as a restore or
a copy that keeps times does. The comparison skips it, and the destination
keeps the old bytes.</li>
<li>You add <code>--delete</code> on a prefix that other jobs also write to. The
reconciliation removes their objects because they are absent from your
source.</li>
<li>Every run writes to a fresh prefix, such as a dated snapshot. There&rsquo;s
nothing there to compare against, so the comparison buys nothing and the
script still depends on the AWS CLI.</li>
</ul>
<p><code>Write-S3Object -Folder -Recurse</code> is the wrong choice when:</p>
<ul>
<li>The script relies on skipping files that haven&rsquo;t changed. Every run
uploads the whole tree again, and in a versioned bucket each upload is
another complete version.</li>
<li>The destination has to mirror the source, deletions included. The cmdlet
never removes destination-only objects, and adding that means owning
comparison rules, key mapping, filters, deletions and partial failures
yourself.</li>
<li>You need to see what a run will do before it runs. There&rsquo;s no equivalent
of <code>--dryrun</code>.</li>
</ul>
<p>Choose based on the repeat run and the restore you need.</p>
]]></content:encoded>
    </item>
    
    <item>
      <title>Back Up a 7 Days to Die Dedicated Server to Amazon S3 with PowerShell</title>
      <link>https://looselydecoupled.com/backup-7-days-to-die-server-s3/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/backup-7-days-to-die-server-s3/</guid>
      <pubDate>Fri, 06 Feb 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>AWS</category>
      <category>S3</category>
      <category>Windows Server</category>
      <category>PowerShell</category>
      <category>Automation</category>
      
      <description>The world lands in S3 every morning as an archive that restores onto a fresh server, and the task&#39;s result says whether that morning&#39;s copy arrived.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>A daily scheduled task runs a PowerShell script as SYSTEM. The script zips
<code>Saves</code> and <code>serverconfig.xml</code>, writes the archive to S3 with
<code>Write-S3Object</code> as <code>STANDARD_IA</code>, and keeps three archives locally and
thirty days in the bucket. Every part is stock: <code>Compress-Archive</code>, AWS
Tools for PowerShell, Task Scheduler.</p>
<p>SYSTEM has its own profile, so install the modules and write the credentials
where it can reach them, not where your own session puts them.</p>

</div>

<p>I&rsquo;ve played 7 Days to Die since it came out, and years ago I set up my own
dedicated server, on the Windows Server 2019 VM it still runs on. For a long
time I updated and managed it by hand. Then one day I realized I knew how to
do it better, and started automating it.</p>
<p>The big part was updates. When it&rsquo;s time to play, the server should already
be up to date. There&rsquo;s nothing worse than getting ready to play and hitting
the update wait.</p>
<p>The goal is a server I can replace without a second thought, starting with a
move to Windows Server 2025, and backups are the first step. Steam can
reinstall the game on any machine, but it can&rsquo;t give back the world: the map,
the players&rsquo; bases and everything in their inventories. So every morning,
ahead of the update window, a scheduled task zips that part and copies it to
S3, where a new server can pick it up.</p>
<p>S3 because I already use it and like it. Nothing about the backup needs S3.
The zip, the schedule, the local retention and the logging work the same with
another drive or a share on a NAS or SAN, and
<a href="https://looselydecoupled.com/backup-7-days-to-die-server-s3/#use-a-different-destination">Use a different destination</a> covers what
changes. The VM runs outside AWS, which is why
credentials take a step of their own: there&rsquo;s no instance role to lean on.</p>
<h2 id="what-to-back-up">What to back up<a class="heading-anchor" href="https://looselydecoupled.com/backup-7-days-to-die-server-s3/#what-to-back-up"
     aria-label="Link to section: What to back up" data-pagefind-ignore></a>
</h2>
<p>Two things hold state. The game writes the world, the players and their
inventories under <code>Saves</code>, and the server&rsquo;s settings live in
<code>serverconfig.xml</code> beside it. On this server both are under
<code>C:\7DaysToDieServer</code>. A server installed with the defaults keeps <code>Saves</code>
under <code>%APPDATA%\7DaysToDie</code> of the account that runs the game instead,
unless <code>UserDataFolder</code> in <code>serverconfig.xml</code> moves it. The script looks for
<code>Saves</code> beside <code>serverconfig.xml</code> and stops when it isn&rsquo;t there; on a default
install, pass the full path to <code>Saves</code> as <code>-SavesDir</code> in step 5. Everything
else in the server directory is the Steam installation, which SteamCMD reproduces on demand, and it&rsquo;s most of the disk.
The backup takes those two and nothing else, so the zip comes out at about
23 MB.</p>
<p>This server runs Navezgane, the shipped map. A random-gen world also keeps its
terrain in <code>GeneratedWorlds</code>, beside <code>Saves</code>, and a new server can&rsquo;t rebuild
the world without it, so add that directory to the archive and the restore.</p>
<!-- style-check: procedure -->
<h2 id="1-install-aws-tools-where-system-can-see-them">1. Install AWS Tools where SYSTEM can see them<a class="heading-anchor" href="https://looselydecoupled.com/backup-7-days-to-die-server-s3/#1-install-aws-tools-where-system-can-see-them"
     aria-label="Link to section: 1. Install AWS Tools where SYSTEM can see them" data-pagefind-ignore></a>
</h2>
<p>The script reaches S3 through AWS Tools for PowerShell rather than the CLI.
<code>-Scope AllUsers</code> is the part that matters. A per-user install lands under
your own Documents folder, which SYSTEM cannot see, so the scheduled run fails
to import the modules while your own runs work.</p>
<p>On a fresh Server 2019 the gallery needs the NuGet provider first. The TLS
check before it adds TLS 1.2 only when the session has neither it nor the OS
default, so it never removes a protocol. On Server 2019 it changes nothing;
<a href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/">installing AWS Tools for all users</a>
explains when it matters:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$protocol</span> <span class="p">=</span> <span class="p">[</span><span class="no">Net.ServicePointManager</span><span class="p">]::</span><span class="n">SecurityProtocol</span>
</span></span><span class="line"><span class="cl"><span class="nv">$tls12</span> <span class="p">=</span> <span class="p">[</span><span class="no">Net.SecurityProtocolType</span><span class="p">]::</span><span class="n">Tls12</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nv">$protocol</span> <span class="o">-ne</span> <span class="s1">&#39;SystemDefault&#39;</span> <span class="o">-and</span> <span class="o">-not</span> <span class="p">(</span><span class="nv">$protocol</span> <span class="o">-band</span> <span class="nv">$tls12</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="no">Net.ServicePointManager</span><span class="p">]::</span><span class="n">SecurityProtocol</span> <span class="p">=</span> <span class="nv">$protocol</span> <span class="o">-bor</span> <span class="nv">$tls12</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="nb">Install-PackageProvider</span> <span class="n">-Name</span> <span class="n">NuGet</span> <span class="n">-MinimumVersion</span> <span class="mf">2.8</span><span class="p">.</span><span class="py">5</span><span class="p">.</span><span class="py">201</span> <span class="n">-Force</span> <span class="p">|</span> <span class="nb">Out-Null</span>
</span></span><span class="line"><span class="cl"><span class="nb">Install-Module</span> <span class="n">-Name</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="py">Installer</span> <span class="n">-Repository</span> <span class="n">PSGallery</span> <span class="n">-Scope</span> <span class="n">AllUsers</span> <span class="n">-Force</span>
</span></span><span class="line"><span class="cl"><span class="nb">Install-AWSToolsModule</span> <span class="n">-Name</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="n">S3</span><span class="p">,</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="py">SecurityToken</span> <span class="n">-Scope</span> <span class="n">AllUsers</span> <span class="n">-CleanUp</span> <span class="n">-Force</span></span></span></code></pre></div></div>
  </div>
</div><p>Confirm they landed in the machine path rather than your profile:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Get-Module</span> <span class="n">-ListAvailable</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="py">S3</span> <span class="p">|</span> <span class="nb">Select-Object</span> <span class="n">Name</span><span class="p">,</span> <span class="n">Version</span><span class="p">,</span> <span class="n">Path</span></span></span></code></pre></div></div>
  </div>
</div><p><code>Path</code> should start <code>C:\Program Files\WindowsPowerShell\Modules\</code>.</p>
<h2 id="2-give-system-its-own-aws-credentials">2. Give SYSTEM its own AWS credentials<a class="heading-anchor" href="https://looselydecoupled.com/backup-7-days-to-die-server-s3/#2-give-system-its-own-aws-credentials"
     aria-label="Link to section: 2. Give SYSTEM its own AWS credentials" data-pagefind-ignore></a>
</h2>
<p>The task runs as SYSTEM so it works whether or not anyone is signed in. SYSTEM
is a separate account with a separate profile, and the AWS Tools read
credentials from the profile of whoever is running them. Anything you run in
your own session writes to <code>%USERPROFILE%\.aws\</code>; SYSTEM never sees that.</p>
<p>A CloudFormation template creates both the bucket and a user who can reach only
that bucket. Save it as <code>backup-stack.yaml</code>:</p>
<!-- style-check: off -->
<div class="code-block" data-copyable data-copy-label="backup-stack.yaml">
  <div class="code-block__head"><span class="code-block__lang code-block__file">backup-stack.yaml</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">AWSTemplateFormatVersion</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;2010-09-09&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">Description</span><span class="p">:</span><span class="w"> </span><span class="p">&gt;-</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">  An S3 bucket for a game server&#39;s world backups and an IAM user that can do
</span></span></span><span class="line"><span class="cl"><span class="sd">  only what the backup and restore scripts do: list the bucket, write, read
</span></span></span><span class="line"><span class="cl"><span class="sd">  and delete its own archives. Archives expire after RetentionDays.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">Metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">AWSToolsMetrics</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">AWSAgentToolkit</span><span class="p">:</span><span class="w"> </span><span class="l">aws-cloudformation@2</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">Parameters</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">RetentionDays</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">Type</span><span class="p">:</span><span class="w"> </span><span class="l">Number</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">Default</span><span class="p">:</span><span class="w"> </span><span class="m">30</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">MinValue</span><span class="p">:</span><span class="w"> </span><span class="m">1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">Description</span><span class="p">:</span><span class="w"> </span><span class="l">Days an archive stays in the bucket before S3 deletes it.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">Resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">BackupBucket</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">Type</span><span class="p">:</span><span class="w"> </span><span class="l">AWS::S3::Bucket</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">DeletionPolicy</span><span class="p">:</span><span class="w"> </span><span class="l">Retain</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">UpdateReplacePolicy</span><span class="p">:</span><span class="w"> </span><span class="l">Retain</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">Metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">com.aws.cloudformation.Context</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">why</span><span class="p">:</span><span class="w"> </span><span class="l">Holds the server&#39;s backup archives; retained on stack deletion so deleting the stack never deletes backups.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">must</span><span class="p">:</span><span class="w"> </span><span class="l">Never public. The expiry rule keeps running when the server, and its own pruning, does not.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">Properties</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PublicAccessBlockConfiguration</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">BlockPublicAcls</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">BlockPublicPolicy</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">IgnorePublicAcls</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">RestrictPublicBuckets</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">BucketEncryption</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">ServerSideEncryptionConfiguration</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span>- <span class="nt">ServerSideEncryptionByDefault</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">SSEAlgorithm</span><span class="p">:</span><span class="w"> </span><span class="l">AES256</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">LifecycleConfiguration</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">Rules</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span>- <span class="nt">Id</span><span class="p">:</span><span class="w"> </span><span class="l">ExpireArchives</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">Status</span><span class="p">:</span><span class="w"> </span><span class="l">Enabled</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">ExpirationInDays</span><span class="p">:</span><span class="w"> </span>!<span class="l">Ref RetentionDays</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">AbortIncompleteMultipartUpload</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">DaysAfterInitiation</span><span class="p">:</span><span class="w"> </span><span class="m">3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">BackupBucketPolicy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">Type</span><span class="p">:</span><span class="w"> </span><span class="l">AWS::S3::BucketPolicy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">Metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">com.aws.cloudformation.Context</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">why</span><span class="p">:</span><span class="w"> </span><span class="l">Refuses any request that is not over HTTPS.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">Properties</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">Bucket</span><span class="p">:</span><span class="w"> </span>!<span class="l">Ref BackupBucket</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PolicyDocument</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">Version</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;2012-10-17&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">Statement</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span>- <span class="nt">Sid</span><span class="p">:</span><span class="w"> </span><span class="l">DenyInsecureTransport</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">Effect</span><span class="p">:</span><span class="w"> </span><span class="l">Deny</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">Principal</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;*&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">Action</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;s3:*&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">Resource</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span>- !<span class="l">GetAtt BackupBucket.Arn</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span>- !<span class="l">Sub &#39;${BackupBucket.Arn}/*&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">Condition</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">Bool</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span><span class="nt">&#39;aws:SecureTransport&#39;</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;false&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">BackupUser</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">Type</span><span class="p">:</span><span class="w"> </span><span class="l">AWS::IAM::User</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">Metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">com.aws.cloudformation.Context</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">why</span><span class="p">:</span><span class="w"> </span><span class="l">The identity the server&#39;s backup task and a restore run as. Its access key is created after the stack, so the secret never passes through CloudFormation.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">must</span><span class="p">:</span><span class="w"> </span><span class="kc">No</span><span class="w"> </span><span class="l">console access and no permission outside this one bucket.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">Properties</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">Policies</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">PolicyName</span><span class="p">:</span><span class="w"> </span><span class="l">BackupAndRestore</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">PolicyDocument</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">Version</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;2012-10-17&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">Statement</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span>- <span class="nt">Sid</span><span class="p">:</span><span class="w"> </span><span class="l">ListTheBucket</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span><span class="nt">Effect</span><span class="p">:</span><span class="w"> </span><span class="l">Allow</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span><span class="nt">Action</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;s3:ListBucket&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span><span class="nt">Resource</span><span class="p">:</span><span class="w"> </span>!<span class="l">GetAtt BackupBucket.Arn</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span>- <span class="nt">Sid</span><span class="p">:</span><span class="w"> </span><span class="l">WriteReadPruneArchives</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span><span class="nt">Effect</span><span class="p">:</span><span class="w"> </span><span class="l">Allow</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span><span class="nt">Action</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                  </span>- <span class="s1">&#39;s3:PutObject&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                  </span>- <span class="s1">&#39;s3:GetObject&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                  </span>- <span class="s1">&#39;s3:DeleteObject&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span><span class="nt">Resource</span><span class="p">:</span><span class="w"> </span>!<span class="l">Sub &#39;${BackupBucket.Arn}/*&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">Outputs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">BucketName</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">Description</span><span class="p">:</span><span class="w"> </span><span class="l">Goes on the first line of backup-config.txt.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">Value</span><span class="p">:</span><span class="w"> </span>!<span class="l">Ref BackupBucket</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">UserName</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">Description</span><span class="p">:</span><span class="w"> </span><span class="l">Create its access key with New-IAMAccessKey -UserName &lt;this&gt;.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">Value</span><span class="p">:</span><span class="w"> </span>!<span class="l">Ref BackupUser</span></span></span></code></pre></div></div>
  </div>
</div><!-- style-check: on -->
<p>It creates three things:</p>
<ul>
<li><strong>A private bucket.</strong> All public access is blocked, requests over plain HTTP
are refused, and an archive expires after <code>RetentionDays</code>. The expiry rule
keeps running when the script doesn&rsquo;t, and it also removes the fragments an
interrupted upload leaves, which are billed until removed. CloudFormation
leaves the bucket in place if you delete the stack.</li>
<li><strong>A user with no console access and no access key.</strong> The key comes from a
separate command, so the secret never passes through CloudFormation.</li>
<li><strong>A policy scoped to the one bucket.</strong> <code>ListBucket</code> acts on the bucket and
the object actions act on its contents, so the two resource lines differ.
Swapping them gets you <code>AccessDenied</code>.</li>
</ul>
<p><code>PutObject</code> writes the archive and <code>DeleteObject</code> is for the script&rsquo;s prune.
Reading each upload back to check it arrived is a listing, so <code>ListBucket</code>
covers that. <code>GetObject</code> is only for restoring, and it lets the same
credentials restore on a new machine. With it removed from the policy, a
download as SYSTEM failed with <code>is not authorized to perform: s3:GetObject</code>,
while listing the bucket still worked. The cost is that a stolen key can read
your old archives, which a write-only key could not.</p>
<p>Deploy it from your own session, with your own credentials. The backup user&rsquo;s
policy can&rsquo;t create a bucket or a user, and it shouldn&rsquo;t. The stack creates an
IAM user, so it needs <code>CAPABILITY_IAM</code>. The commands come from the
<code>AWS.Tools.CloudFormation</code> and <code>AWS.Tools.IdentityManagement</code> modules, which
you install the way step 1 installs the others:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$stackName</span> <span class="p">=</span> <span class="s1">&#39;game-server-backups&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$region</span> <span class="p">=</span> <span class="s1">&#39;us-west-2&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$template</span> <span class="p">=</span> <span class="nb">Get-Content</span> <span class="p">.\</span><span class="nb">backup-stack</span><span class="p">.</span><span class="py">yaml</span> <span class="n">-Raw</span>
</span></span><span class="line"><span class="cl"><span class="nb">New-CFNStack</span> <span class="n">-StackName</span> <span class="nv">$stackName</span> <span class="n">-TemplateBody</span> <span class="nv">$template</span> <span class="n">-Capability</span> <span class="n">CAPABILITY_IAM</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-Region</span> <span class="nv">$region</span>
</span></span><span class="line"><span class="cl"><span class="nb">Wait-CFNStack</span> <span class="n">-StackName</span> <span class="nv">$stackName</span> <span class="n">-Region</span> <span class="nv">$region</span> <span class="n">-Timeout</span> <span class="mf">300</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-Status</span> <span class="n">CREATE_COMPLETE</span><span class="p">,</span> <span class="n">ROLLBACK_COMPLETE</span>
</span></span><span class="line"><span class="cl"><span class="p">(</span><span class="nb">Get-CFNStack</span> <span class="n">-StackName</span> <span class="nv">$stackName</span> <span class="n">-Region</span> <span class="nv">$region</span><span class="p">).</span><span class="py">Outputs</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Select-Object</span> <span class="n">OutputKey</span><span class="p">,</span> <span class="n">OutputValue</span></span></span></code></pre></div></div>
  </div>
</div><p>The bucket is created in the stack&rsquo;s region, and the backup task calls the
region in SYSTEM&rsquo;s <code>config</code> file below, so use the same one in both places.</p>
<p>Add <code>-Parameter @{ ParameterKey = 'RetentionDays'; ParameterValue = '60' }</code> to
<code>New-CFNStack</code> to change the 30-day default. The script prunes too, at its own
<code>-S3KeepDays</code>, which is 30 unless you pass another number, so add the same
number to the task&rsquo;s arguments in step 5. Otherwise the script deletes archives
the bucket would have kept. The outputs are <code>BucketName</code>, which
step 3 uses, and <code>UserName</code>. I haven&rsquo;t run these cmdlets. The stack this
template makes was created through the CloudFormation API with
<code>CAPABILITY_IAM</code>, and it reached <code>CREATE_COMPLETE</code> with both outputs and the
default of 30 days.</p>
<p>Create the user&rsquo;s access key. The secret is shown once, so keep the output out
of any log:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">New-IAMAccessKey</span> <span class="n">-UserName</span> <span class="s1">&#39;&lt;UserName output&gt;&#39;</span></span></span></code></pre></div></div>
  </div>
</div><p>I didn&rsquo;t run that command either. The key isn&rsquo;t part of the stack, but deleting
the test stack with its key still in place removed the user and the key together
and left the bucket with its archives, so there is no separate step to undo it.</p>
<p>Then, from an elevated PowerShell, write that key where SYSTEM will look for
it: two ini-format files, written by hand. You need both. The second carries
the region, and without a region every call fails, however good the keys are.</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$systemAws</span> <span class="p">=</span> <span class="s1">&#39;C:\Windows\System32\config\systemprofile\.aws&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">New-Item</span> <span class="n">-ItemType</span> <span class="n">Directory</span> <span class="n">-Path</span> <span class="nv">$systemAws</span> <span class="n">-Force</span> <span class="p">|</span> <span class="nb">Out-Null</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="sh">@&#34;
</span></span></span><span class="line"><span class="cl"><span class="sh">[default]
</span></span></span><span class="line"><span class="cl"><span class="sh">aws_access_key_id = AKIA...
</span></span></span><span class="line"><span class="cl"><span class="sh">aws_secret_access_key = ...
</span></span></span><span class="line"><span class="cl"><span class="sh">&#34;@</span> <span class="p">|</span> <span class="nb">Set-Content</span> <span class="n">-Path</span> <span class="s2">&#34;</span><span class="nv">$systemAws</span><span class="s2">\credentials&#34;</span> <span class="n">-Encoding</span> <span class="n">ascii</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="sh">@&#34;
</span></span></span><span class="line"><span class="cl"><span class="sh">[default]
</span></span></span><span class="line"><span class="cl"><span class="sh">region = us-west-2
</span></span></span><span class="line"><span class="cl"><span class="sh">&#34;@</span> <span class="p">|</span> <span class="nb">Set-Content</span> <span class="n">-Path</span> <span class="s2">&#34;</span><span class="nv">$systemAws</span><span class="s2">\config&#34;</span> <span class="n">-Encoding</span> <span class="n">ascii</span></span></span></code></pre></div></div>
  </div>
</div><p>The directory is hidden, so <code>dir</code> does not show it; <code>Get-ChildItem -Force</code>
does.</p>
<p>Two shortcuts look like they would do this and do not:</p>
<ul>
<li><code>Set-AWSCredential -StoreAs</code> writes to the SDK&rsquo;s own encrypted store under
<code>AppData</code>, not to these files. That store belongs to the Windows account
that wrote it.</li>
<li><code>Set-DefaultAWSRegion</code> lasts only for the session that ran it, so the
region is gone by the time the task runs.</li>
</ul>
<p>If you would rather not write into SYSTEM&rsquo;s profile, <code>-ProfileLocation</code> points a
call at a credentials file anywhere on disk. You then have to pass it on
every call, which suits a script you are writing more than one you are copying.</p>
<h2 id="3-name-the-bucket-for-the-script">3. Name the bucket for the script<a class="heading-anchor" href="https://looselydecoupled.com/backup-7-days-to-die-server-s3/#3-name-the-bucket-for-the-script"
     aria-label="Link to section: 3. Name the bucket for the script" data-pagefind-ignore></a>
</h2>
<p>Write the <code>BucketName</code> output on the first line of <code>backup-config.txt</code>, beside
the script. The script reads it from there, so the script itself holds nothing
specific to your account. The examples in the rest of this article show
<code>7dtd-server-backups</code> where yours goes.</p>
<h2 id="4-save-the-script">4. Save the script<a class="heading-anchor" href="https://looselydecoupled.com/backup-7-days-to-die-server-s3/#4-save-the-script"
     aria-label="Link to section: 4. Save the script" data-pagefind-ignore></a>
</h2>
<p>Save this as <code>C:\Scripts\backup-s3.ps1</code>, next to <code>backup-config.txt</code>. Each
step that could leave you without a backup stops the run where it failed, and
the exit code says how far it got:</p>
<ul>
<li><code>0</code>: the backup is in the bucket and old copies are pruned.</li>
<li><code>1</code>: no backup could be confirmed in the bucket.</li>
<li><code>2</code>: the backup is in the bucket, but pruning failed.</li>
</ul>
<p>Task Scheduler shows that number as <strong>Last Run Result</strong>, so you can spot a
partial run without opening the log.</p>
<!-- style-check: off -->
<div class="code-block" data-copyable data-copy-label="backup-s3.ps1">
  <div class="code-block__head"><span class="code-block__lang code-block__file">backup-s3.ps1</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="cm">&lt;#
</span></span></span><span class="line"><span class="cl"><span class="cm">.</span><span class="sd">SYNOPSIS</span><span class="cm">
</span></span></span><span class="line"><span class="cl"><span class="cm">    Back up a 7 Days to Die dedicated server&#39;s saves to Amazon S3.
</span></span></span><span class="line"><span class="cl"><span class="cm">
</span></span></span><span class="line"><span class="cl"><span class="cm">.</span><span class="sd">DESCRIPTION</span><span class="cm">
</span></span></span><span class="line"><span class="cl"><span class="cm">    Zips Saves and serverconfig.xml, writes the zip to S3 as STANDARD_IA,
</span></span></span><span class="line"><span class="cl"><span class="cm">    keeps three zips locally and thirty days in the bucket, keeps thirty days
</span></span></span><span class="line"><span class="cl"><span class="cm">    of logs. One log per run under &lt;ServerDir&gt;\backup-logs.
</span></span></span><span class="line"><span class="cl"><span class="cm">
</span></span></span><span class="line"><span class="cl"><span class="cm">    S3 is reached through AWS Tools for PowerShell (AWS.Tools.S3 and
</span></span></span><span class="line"><span class="cl"><span class="cm">    AWS.Tools.SecurityToken), installed with -Scope AllUsers so the account the
</span></span></span><span class="line"><span class="cl"><span class="cm">    task runs as can import them. Runs as SYSTEM from Task Scheduler, so the
</span></span></span><span class="line"><span class="cl"><span class="cm">    credentials and region it uses are SYSTEM&#39;s, from:
</span></span></span><span class="line"><span class="cl"><span class="cm">    C:\Windows\System32\config\systemprofile\.aws\ (credentials and config).
</span></span></span><span class="line"><span class="cl"><span class="cm">
</span></span></span><span class="line"><span class="cl"><span class="cm">    Exit codes: 0 success, 1 the backup could not be confirmed in S3, 2 the
</span></span></span><span class="line"><span class="cl"><span class="cm">    backup is in S3 but a prune step failed.
</span></span></span><span class="line"><span class="cl"><span class="cm">#&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nb">CmdletBinding</span><span class="p">()]</span>
</span></span><span class="line"><span class="cl"><span class="k">param</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="no">string</span><span class="p">]</span><span class="nv">$ServerDir</span> <span class="p">=</span> <span class="s1">&#39;C:\7DaysToDieServer&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="no">string</span><span class="p">]</span><span class="nv">$SavesDir</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="no">string</span><span class="p">]</span><span class="nv">$ConfigFile</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="no">string</span><span class="p">]</span><span class="nv">$Region</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="no">int</span><span class="p">]</span><span class="nv">$LocalKeep</span> <span class="p">=</span> <span class="mf">3</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="no">int</span><span class="p">]</span><span class="nv">$S3KeepDays</span> <span class="p">=</span> <span class="mf">30</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="no">int</span><span class="p">]</span><span class="nv">$LogKeepDays</span> <span class="p">=</span> <span class="mf">30</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c"># Beside the script unless the caller names one.</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">-not</span> <span class="nv">$ConfigFile</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$ConfigFile</span> <span class="p">=</span> <span class="nb">Join-Path</span> <span class="p">(</span><span class="nb">Split-Path</span> <span class="n">-Parent</span> <span class="nv">$MyInvocation</span><span class="p">.</span><span class="py">MyCommand</span><span class="p">.</span><span class="n">Path</span><span class="p">)</span> <span class="s1">&#39;backup-config.txt&#39;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c"># Region only when asked for. Left out, the AWS Tools resolve it the way the</span>
</span></span><span class="line"><span class="cl"><span class="c"># CLI did: the profile&#39;s config, then AWS_REGION, then the SDK&#39;s default.</span>
</span></span><span class="line"><span class="cl"><span class="nv">$aws</span> <span class="p">=</span> <span class="vm">@</span><span class="p">{}</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nv">$Region</span><span class="p">)</span> <span class="p">{</span> <span class="nv">$aws</span><span class="p">.</span><span class="py">Region</span> <span class="p">=</span> <span class="nv">$Region</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">$BackupDir</span> <span class="p">=</span> <span class="nb">Join-Path</span> <span class="nv">$ServerDir</span> <span class="s1">&#39;backups&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$LogDir</span>    <span class="p">=</span> <span class="nb">Join-Path</span> <span class="nv">$ServerDir</span> <span class="s1">&#39;backup-logs&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$Timestamp</span> <span class="p">=</span> <span class="nb">Get-Date</span> <span class="n">-Format</span> <span class="s1">&#39;yyyyMMdd-HHmmss&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$LogFile</span>   <span class="p">=</span> <span class="nb">Join-Path</span> <span class="nv">$LogDir</span> <span class="s2">&#34;backup-</span><span class="nv">$Timestamp</span><span class="s2">.log&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$Prefix</span>    <span class="p">=</span> <span class="s1">&#39;7days-backups/&#39;</span>
</span></span><span class="line"><span class="cl"><span class="c"># Anchored, and [^/] so it cannot reach into a nested key: a listing is</span>
</span></span><span class="line"><span class="cl"><span class="c"># recursive where the CLI&#39;s `s3 ls` was not, and -like&#39;s * spans a slash.</span>
</span></span><span class="line"><span class="cl"><span class="nv">$KeyPattern</span> <span class="p">=</span> <span class="s1">&#39;^&#39;</span> <span class="p">+</span> <span class="p">[</span><span class="no">regex</span><span class="p">]::</span><span class="n">Escape</span><span class="p">(</span><span class="nv">$Prefix</span><span class="p">)</span> <span class="p">+</span> <span class="s1">&#39;7dtd-backup-[^/]+\.zip$&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">foreach</span> <span class="p">(</span><span class="nv">$dir</span> <span class="k">in</span> <span class="nv">$BackupDir</span><span class="p">,</span> <span class="nv">$LogDir</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">-not</span> <span class="p">(</span><span class="nb">Test-Path</span> <span class="nv">$dir</span><span class="p">))</span> <span class="p">{</span> <span class="nb">New-Item</span> <span class="n">-ItemType</span> <span class="n">Directory</span> <span class="n">-Path</span> <span class="nv">$dir</span> <span class="p">|</span> <span class="nb">Out-Null</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">function</span><span class="w"> </span><span class="nb">Write-Log</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">param</span><span class="p">([</span><span class="no">string</span><span class="p">]</span><span class="nv">$Message</span> <span class="p">=</span> <span class="s1">&#39;&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Add-Content</span> <span class="n">-Path</span> <span class="nv">$LogFile</span> <span class="n">-Value</span> <span class="nv">$Message</span> <span class="n">-Encoding</span> <span class="n">UTF8</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Write-Host</span> <span class="nv">$Message</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">function</span><span class="w"> </span><span class="nb">Stop-Run</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">param</span><span class="p">([</span><span class="no">string</span><span class="p">]</span><span class="nv">$Message</span><span class="p">,</span> <span class="p">[</span><span class="no">int</span><span class="p">]</span><span class="nv">$Code</span> <span class="p">=</span> <span class="mf">1</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Write-Log</span> <span class="s2">&#34;ERROR: </span><span class="nv">$Message</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">exit</span> <span class="nv">$Code</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">Write-Log</span> <span class="s1">&#39;========================================&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Write-Log</span> <span class="s1">&#39;7 Days to Die Server S3 Backup&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Write-Log</span> <span class="s2">&#34;Started: </span><span class="p">$(</span><span class="nb">Get-Date</span> <span class="n">-Format</span> <span class="s1">&#39;ddd MM/dd/yyyy HH:mm:ss.ff&#39;</span><span class="p">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Write-Log</span> <span class="s1">&#39;========================================&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Write-Log</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c"># Which account ran matters more than anything else when credentials fail.</span>
</span></span><span class="line"><span class="cl"><span class="nb">Write-Log</span> <span class="p">([</span><span class="no">Security.Principal.WindowsIdentity</span><span class="p">]::</span><span class="n">GetCurrent</span><span class="p">().</span><span class="n">Name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">-not</span> <span class="p">(</span><span class="nb">Test-Path</span> <span class="nv">$ConfigFile</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Stop-Run</span> <span class="s2">&#34;Config file not found at </span><span class="nv">$ConfigFile</span><span class="s2">. Write the bucket name on its first line&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">$Bucket</span> <span class="p">=</span> <span class="s2">&#34;</span><span class="p">$(</span><span class="nb">Get-Content</span> <span class="nv">$ConfigFile</span> <span class="n">-First</span> <span class="mf">1</span><span class="p">)</span><span class="s2">&#34;</span><span class="p">.</span><span class="py">Trim</span><span class="p">()</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">-not</span> <span class="nv">$Bucket</span><span class="p">)</span> <span class="p">{</span> <span class="nb">Stop-Run</span> <span class="s1">&#39;S3 bucket name not configured&#39;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="nb">Write-Log</span> <span class="s2">&#34;S3 Bucket: </span><span class="nv">$Bucket</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Write-Log</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Import-Module</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="n">S3</span><span class="p">,</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="py">SecurityToken</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Stop-Run</span> <span class="s2">&#34;AWS Tools for PowerShell not available to this account: </span><span class="p">$(</span><span class="nv">$_</span><span class="p">.</span><span class="py">Exception</span><span class="p">.</span><span class="n">Message</span><span class="p">)</span><span class="s2">. Install AWS.Tools.S3 and AWS.Tools.SecurityToken with -Scope AllUsers&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">Write-Log</span> <span class="s1">&#39;Testing AWS credentials...&#39;</span>
</span></span><span class="line"><span class="cl"><span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Get-STSCallerIdentity</span> <span class="nv">@aws</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span> <span class="p">|</span> <span class="nb">Out-Null</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Stop-Run</span> <span class="s2">&#34;AWS credentials or region not usable by this account: </span><span class="p">$(</span><span class="nv">$_</span><span class="p">.</span><span class="py">Exception</span><span class="p">.</span><span class="n">Message</span><span class="p">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="nb">Write-Log</span> <span class="s1">&#39;AWS credentials OK&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Write-Log</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">$BackupName</span> <span class="p">=</span> <span class="s2">&#34;7dtd-backup-</span><span class="nv">$Timestamp</span><span class="s2">.zip&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$BackupPath</span> <span class="p">=</span> <span class="nb">Join-Path</span> <span class="nv">$BackupDir</span> <span class="nv">$BackupName</span>
</span></span><span class="line"><span class="cl"><span class="nv">$Key</span>        <span class="p">=</span> <span class="s2">&#34;</span><span class="nv">$Prefix$BackupName</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$Saves</span>      <span class="p">=</span> <span class="k">if</span> <span class="p">(</span><span class="nv">$SavesDir</span><span class="p">)</span> <span class="p">{</span> <span class="nv">$SavesDir</span> <span class="p">}</span> <span class="k">else</span> <span class="p">{</span> <span class="nb">Join-Path</span> <span class="nv">$ServerDir</span> <span class="s1">&#39;Saves&#39;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">$Config</span>     <span class="p">=</span> <span class="nb">Join-Path</span> <span class="nv">$ServerDir</span> <span class="s1">&#39;serverconfig.xml&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">Write-Log</span> <span class="s1">&#39;Creating backup archive...&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Write-Log</span> <span class="s2">&#34;Destination: </span><span class="nv">$BackupPath</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Write-Log</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">-not</span> <span class="p">(</span><span class="nb">Test-Path</span> <span class="nv">$Saves</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Stop-Run</span> <span class="s2">&#34;Saves directory not found: </span><span class="nv">$Saves</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="nb">Write-Log</span> <span class="s2">&#34;Compressing: </span><span class="nv">$Saves</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$sources</span> <span class="p">=</span> <span class="vm">@</span><span class="p">(</span><span class="nv">$Saves</span><span class="p">,</span> <span class="nv">$Config</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Compress-Archive</span> <span class="n">-Path</span> <span class="nv">$sources</span> <span class="n">-DestinationPath</span> <span class="nv">$BackupPath</span> <span class="n">-CompressionLevel</span> <span class="n">Optimal</span> <span class="n">-Force</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Stop-Run</span> <span class="s2">&#34;Failed to create backup archive: </span><span class="p">$(</span><span class="nv">$_</span><span class="p">.</span><span class="py">Exception</span><span class="p">.</span><span class="n">Message</span><span class="p">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">$localSize</span> <span class="p">=</span> <span class="p">(</span><span class="nb">Get-Item</span> <span class="nv">$BackupPath</span><span class="p">).</span><span class="py">Length</span>
</span></span><span class="line"><span class="cl"><span class="nb">Write-Log</span> <span class="s2">&#34;Backup created: </span><span class="p">$([</span><span class="no">math</span><span class="p">]::</span><span class="n">Round</span><span class="p">(</span><span class="nv">$localSize</span> <span class="p">/</span> <span class="mf">1</span><span class="p">MB))</span><span class="s2"> MB&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Write-Log</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">Write-Log</span> <span class="s1">&#39;Uploading to S3...&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Write-Log</span> <span class="s2">&#34;S3 path: s3://</span><span class="nv">$Bucket</span><span class="s2">/</span><span class="nv">$Key</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Write-S3Object</span> <span class="n">-BucketName</span> <span class="nv">$Bucket</span> <span class="n">-Key</span> <span class="nv">$Key</span> <span class="o">-File</span> <span class="nv">$BackupPath</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">        <span class="n">-StorageClass</span> <span class="n">STANDARD_IA</span> <span class="nv">@aws</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Stop-Run</span> <span class="s2">&#34;Failed to upload backup to S3: </span><span class="p">$(</span><span class="nv">$_</span><span class="p">.</span><span class="py">Exception</span><span class="p">.</span><span class="n">Message</span><span class="p">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c"># Read it back: the cmdlet returning is not the same as the bytes being there.</span>
</span></span><span class="line"><span class="cl"><span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$remote</span> <span class="p">=</span> <span class="nb">Get-S3Object</span> <span class="n">-BucketName</span> <span class="nv">$Bucket</span> <span class="n">-Key</span> <span class="nv">$Key</span> <span class="nv">@aws</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Stop-Run</span> <span class="s2">&#34;Upload returned but the object could not be read back: </span><span class="p">$(</span><span class="nv">$_</span><span class="p">.</span><span class="py">Exception</span><span class="p">.</span><span class="n">Message</span><span class="p">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="vm">$null</span> <span class="o">-eq</span> <span class="nv">$remote</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Stop-Run</span> <span class="s2">&#34;Upload returned but the object is not in the bucket: </span><span class="nv">$Key</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nv">$remote</span><span class="p">.</span><span class="py">Size</span> <span class="o">-ne</span> <span class="nv">$localSize</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Stop-Run</span> <span class="s2">&#34;Upload size mismatch: local </span><span class="nv">$localSize</span><span class="s2"> bytes, S3 </span><span class="p">$(</span><span class="nv">$remote</span><span class="p">.</span><span class="n">Size</span><span class="p">)</span><span class="s2"> bytes&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="nb">Write-Log</span> <span class="s2">&#34;Upload successful: </span><span class="p">$(</span><span class="nv">$remote</span><span class="p">.</span><span class="n">Size</span><span class="p">)</span><span class="s2"> bytes, </span><span class="p">$(</span><span class="nv">$remote</span><span class="p">.</span><span class="n">StorageClass</span><span class="p">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Write-Log</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c"># From here the backup is in the bucket. A failure below is reported, not</span>
</span></span><span class="line"><span class="cl"><span class="c"># fatal: exit 2 says &#34;backed up, but look at the log&#34;.</span>
</span></span><span class="line"><span class="cl"><span class="nv">$pruneFailed</span> <span class="p">=</span> <span class="vm">$false</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">Write-Log</span> <span class="s1">&#39;Cleaning up old local backups...&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Get-ChildItem</span> <span class="nv">$BackupDir</span> <span class="n">-Filter</span> <span class="s1">&#39;7dtd-backup-*.zip&#39;</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Sort-Object</span> <span class="n">LastWriteTime</span> <span class="n">-Descending</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Select-Object</span> <span class="n">-Skip</span> <span class="nv">$LocalKeep</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">ForEach-Object</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$file</span> <span class="p">=</span> <span class="nv">$_</span>
</span></span><span class="line"><span class="cl">        <span class="nb">Write-Log</span> <span class="s2">&#34;Deleting old backup: </span><span class="p">$(</span><span class="nv">$file</span><span class="p">.</span><span class="n">Name</span><span class="p">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nb">Remove-Item</span> <span class="nv">$file</span><span class="p">.</span><span class="py">FullName</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nb">Write-Log</span> <span class="s2">&#34;WARNING: could not delete </span><span class="p">$(</span><span class="nv">$file</span><span class="p">.</span><span class="n">Name</span><span class="p">)</span><span class="s2">: </span><span class="p">$(</span><span class="nv">$_</span><span class="p">.</span><span class="py">Exception</span><span class="p">.</span><span class="n">Message</span><span class="p">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">            <span class="nv">$pruneFailed</span> <span class="p">=</span> <span class="vm">$true</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">Write-Log</span> <span class="s2">&#34;Cleaning up old S3 backups (older than </span><span class="nv">$S3KeepDays</span><span class="s2"> days)...&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c"># Both sides in UTC. LastModified&#39;s Kind isn&#39;t guaranteed, and comparing a</span>
</span></span><span class="line"><span class="cl"><span class="c"># local time against a UTC one shifts the retention boundary by the</span>
</span></span><span class="line"><span class="cl"><span class="c"># machine&#39;s offset.</span>
</span></span><span class="line"><span class="cl"><span class="nv">$cutoff</span> <span class="p">=</span> <span class="p">[</span><span class="no">datetime</span><span class="p">]::</span><span class="n">UtcNow</span><span class="p">.</span><span class="py">AddDays</span><span class="p">(-</span><span class="nv">$S3KeepDays</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$old</span> <span class="p">=</span> <span class="nb">Get-S3Object</span> <span class="n">-BucketName</span> <span class="nv">$Bucket</span> <span class="n">-Prefix</span> <span class="nv">$Prefix</span> <span class="nv">@aws</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">        <span class="nb">Where-Object</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nv">$_</span><span class="p">.</span><span class="py">Key</span> <span class="o">-match</span> <span class="nv">$KeyPattern</span> <span class="o">-and</span>
</span></span><span class="line"><span class="cl">            <span class="nv">$_</span><span class="p">.</span><span class="py">LastModified</span><span class="p">.</span><span class="py">ToUniversalTime</span><span class="p">()</span> <span class="o">-lt</span> <span class="nv">$cutoff</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Write-Log</span> <span class="s2">&#34;WARNING: could not list S3 backups: </span><span class="p">$(</span><span class="nv">$_</span><span class="p">.</span><span class="py">Exception</span><span class="p">.</span><span class="n">Message</span><span class="p">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$pruneFailed</span> <span class="p">=</span> <span class="vm">$true</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$old</span> <span class="p">=</span> <span class="vm">@</span><span class="p">()</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">foreach</span> <span class="p">(</span><span class="nv">$object</span> <span class="k">in</span> <span class="nv">$old</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Write-Log</span> <span class="s2">&#34;Deleting old S3 backup: </span><span class="p">$(</span><span class="nv">$object</span><span class="p">.</span><span class="n">Key</span><span class="p">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nb">Remove-S3Object</span> <span class="n">-BucketName</span> <span class="nv">$Bucket</span> <span class="n">-Key</span> <span class="nv">$object</span><span class="p">.</span><span class="py">Key</span> <span class="nv">@aws</span> <span class="n">-Force</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span> <span class="p">|</span> <span class="nb">Out-Null</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nb">Write-Log</span> <span class="s2">&#34;WARNING: could not delete </span><span class="p">$(</span><span class="nv">$object</span><span class="p">.</span><span class="n">Key</span><span class="p">)</span><span class="s2">: </span><span class="p">$(</span><span class="nv">$_</span><span class="p">.</span><span class="py">Exception</span><span class="p">.</span><span class="n">Message</span><span class="p">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="nv">$pruneFailed</span> <span class="p">=</span> <span class="vm">$true</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">Get-ChildItem</span> <span class="nv">$LogDir</span> <span class="n">-Filter</span> <span class="s1">&#39;backup-*.log&#39;</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Where-Object</span> <span class="p">{</span> <span class="nv">$_</span><span class="p">.</span><span class="py">LastWriteTime</span> <span class="o">-lt</span> <span class="p">(</span><span class="nb">Get-Date</span><span class="p">).</span><span class="py">AddDays</span><span class="p">(-</span><span class="nv">$LogKeepDays</span><span class="p">)</span> <span class="o">-and</span> <span class="nv">$_</span><span class="p">.</span><span class="py">FullName</span> <span class="o">-ne</span> <span class="nv">$LogFile</span> <span class="p">}</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Remove-Item</span> <span class="n">-ErrorAction</span> <span class="n">SilentlyContinue</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">Write-Log</span>
</span></span><span class="line"><span class="cl"><span class="nb">Write-Log</span> <span class="s1">&#39;========================================&#39;</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nv">$pruneFailed</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Write-Log</span> <span class="s2">&#34;Backup completed with warnings: </span><span class="p">$(</span><span class="nb">Get-Date</span> <span class="n">-Format</span> <span class="s1">&#39;ddd MM/dd/yyyy HH:mm:ss.ff&#39;</span><span class="p">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Write-Log</span> <span class="s1">&#39;========================================&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="n">exit</span> <span class="mf">2</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="nb">Write-Log</span> <span class="s2">&#34;Backup completed: </span><span class="p">$(</span><span class="nb">Get-Date</span> <span class="n">-Format</span> <span class="s1">&#39;ddd MM/dd/yyyy HH:mm:ss.ff&#39;</span><span class="p">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Write-Log</span> <span class="s1">&#39;========================================&#39;</span>
</span></span><span class="line"><span class="cl"><span class="n">exit</span> <span class="mf">0</span></span></span></code></pre></div></div>
  </div>
</div><!-- style-check: on -->
<p>A few lines look odd until you know why they are there:</p>
<ul>
<li>The log function uses <code>Write-Host</code>, not <code>Write-Output</code>, so log lines do not
leak into whatever the script returns.</li>
<li>After the upload, the script fetches the object&rsquo;s details and compares its
size with the local file. That confirms an object with that key is in the
bucket. The upload cmdlet returning without an error does not prove that on
its own. It checks length, not content; S3 already verifies the
transfer&rsquo;s checksum.</li>
</ul>
<h2 id="5-register-the-task">5. Register the task<a class="heading-anchor" href="https://looselydecoupled.com/backup-7-days-to-die-server-s3/#5-register-the-task"
     aria-label="Link to section: 5. Register the task" data-pagefind-ignore></a>
</h2>
<p>From an elevated PowerShell:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Unblock-File</span> <span class="n">C:</span><span class="p">\</span><span class="n">Scripts</span><span class="p">\</span><span class="nb">backup-s3</span><span class="p">.</span><span class="py">ps1</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">$action</span> <span class="p">=</span> <span class="nb">New-ScheduledTaskAction</span> <span class="n">-Execute</span> <span class="s1">&#39;powershell.exe&#39;</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-Argument</span> <span class="s1">&#39;-NoProfile -ExecutionPolicy RemoteSigned -File &#34;C:\Scripts\backup-s3.ps1&#34;&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$trigger</span> <span class="p">=</span> <span class="nb">New-ScheduledTaskTrigger</span> <span class="n">-Daily</span> <span class="n">-At</span> <span class="mf">11</span><span class="err">:</span><span class="mf">00</span>
</span></span><span class="line"><span class="cl"><span class="nb">Register-ScheduledTask</span> <span class="n">-TaskName</span> <span class="s1">&#39;7DaysToDieServerS3Backup&#39;</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-Action</span> <span class="nv">$action</span> <span class="n">-Trigger</span> <span class="nv">$trigger</span> <span class="n">-User</span> <span class="s1">&#39;SYSTEM&#39;</span> <span class="n">-RunLevel</span> <span class="n">Highest</span></span></span></code></pre></div></div>
  </div>
</div><p>A script that arrives by download or inside a zip carries the mark of the
web. <code>RemoteSigned</code> refuses to run it until <code>Unblock-File</code> clears the mark.
<code>Bypass</code> would also work, but it would hide the next blocked file too. Steps
1 and 2 exist because of <code>-User 'SYSTEM'</code>. The VM&rsquo;s clock is UTC, so
11:00 is early morning Pacific, ahead of the server&rsquo;s own update window. Pick
an hour when nobody is playing.</p>
<p>Start the first run from the scheduler, not by hand. A run from your own
session uses your credentials, so it tests the script but not the task:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Start-ScheduledTask</span> <span class="n">7DaysToDieServerS3Backup</span>
</span></span><span class="line"><span class="cl"><span class="nb">Start-Sleep</span> <span class="mf">30</span>
</span></span><span class="line"><span class="cl"><span class="p">(</span><span class="nb">Get-ScheduledTask</span> <span class="n">7DaysToDieServerS3Backup</span> <span class="p">|</span> <span class="nb">Get-ScheduledTaskInfo</span><span class="p">).</span><span class="n">LastTaskResult</span></span></span></code></pre></div></div>
  </div>
</div><p><code>0</code>, and a new log under <code>C:\7DaysToDieServer\backup-logs</code> whose identity
line reads <code>NT AUTHORITY\SYSTEM</code>.</p>
<!-- style-check: end procedure -->
<h2 id="expected-result">Expected result<a class="heading-anchor" href="https://looselydecoupled.com/backup-7-days-to-die-server-s3/#expected-result"
     aria-label="Link to section: Expected result" data-pagefind-ignore></a>
</h2>
<p>Each run writes one log. This one is from a morning when both prunes had
work to do: a fourth archive had built up on disk, and one in the bucket had
reached thirty days.</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">========================================
</span></span><span class="line"><span class="cl">7 Days to Die Server S3 Backup
</span></span><span class="line"><span class="cl">Started: Fri 09/25/2026 14:28:39.98
</span></span><span class="line"><span class="cl">========================================
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">NT AUTHORITY\SYSTEM
</span></span><span class="line"><span class="cl">S3 Bucket: 7dtd-server-backups
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Testing AWS credentials...
</span></span><span class="line"><span class="cl">AWS credentials OK
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Creating backup archive...
</span></span><span class="line"><span class="cl">Destination: C:\7DaysToDieServer\backups\7dtd-backup-20260925-142839.zip
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Compressing: C:\7DaysToDieServer\Saves
</span></span><span class="line"><span class="cl">Backup created: 23 MB
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Uploading to S3...
</span></span><span class="line"><span class="cl">S3 path: s3://7dtd-server-backups/7days-backups/7dtd-backup-20260925-142839.zip
</span></span><span class="line"><span class="cl">Upload successful: 23917749 bytes, STANDARD_IA
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Cleaning up old local backups...
</span></span><span class="line"><span class="cl">Deleting old backup: 7dtd-backup-20260924-145409.zip
</span></span><span class="line"><span class="cl">Cleaning up old S3 backups (older than 30 days)...
</span></span><span class="line"><span class="cl">Deleting old S3 backup: 7days-backups/7dtd-backup-20260826-110000.zip
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">========================================
</span></span><span class="line"><span class="cl">Backup completed: Fri 09/25/2026 14:28:45.92
</span></span><span class="line"><span class="cl">========================================</span></span></code></pre></div></div>
  </div>
</div><p>That&rsquo;s six seconds, start to finish. The account name near the top is there
because when a run fails on credentials, the first thing to find out is which
account ran it.</p>
<p>To list what&rsquo;s in the bucket:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Get-S3Object</span> <span class="n">-BucketName</span> <span class="n">7dtd-server-backups</span> <span class="n">-Prefix</span> <span class="n">7days-backups</span><span class="p">/</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Select-Object</span> <span class="n">Key</span><span class="p">,</span> <span class="n">Size</span><span class="p">,</span> <span class="n">LastModified</span></span></span></code></pre></div></div>
  </div>
</div><p>Expect one archive for each morning the machine was on, and about thirty
days of them.</p>
<h2 id="restore-a-backup">Restore a backup<a class="heading-anchor" href="https://looselydecoupled.com/backup-7-days-to-die-server-s3/#restore-a-backup"
     aria-label="Link to section: Restore a backup" data-pagefind-ignore></a>
</h2>
<p>Each archive is a plain zip holding <code>Saves</code> and <code>serverconfig.xml</code>, so a
restore is a download and an <code>Expand-Archive</code>. Run this in your own session
if its credentials can read the bucket, and you want to be watching. The backup
user&rsquo;s credentials in SYSTEM&rsquo;s profile can read it too, so a scheduled task
that runs as SYSTEM can do the download on a machine that has only those.</p>
<ol>
<li>
<p>Stop the server.</p>
</li>
<li>
<p>Move the current saves out of the way, so the restored tree stands alone
and the old one is still there if you need it:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$saves</span> <span class="p">=</span> <span class="s1">&#39;C:\7DaysToDieServer\Saves&#39;</span>   <span class="c"># or the path you passed as -SavesDir</span>
</span></span><span class="line"><span class="cl"><span class="nb">Rename-Item</span> <span class="nv">$saves</span> <span class="n">Saves</span><span class="p">.</span><span class="nb">before-restore</span></span></span></code></pre></div></div>
  </div>
</div></li>
<li>
<p>Download the archive you want and expand it into a temporary directory:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$name</span> <span class="p">=</span> <span class="s1">&#39;7dtd-backup-20260925-142839.zip&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$zip</span>  <span class="p">=</span> <span class="nb">Join-Path</span> <span class="nv">$env:TEMP</span> <span class="nv">$name</span>
</span></span><span class="line"><span class="cl"><span class="nv">$dir</span>  <span class="p">=</span> <span class="nb">Join-Path</span> <span class="nv">$env:TEMP</span> <span class="s1">&#39;7dtd-restore&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Read-S3Object</span> <span class="n">-BucketName</span> <span class="n">7dtd-server-backups</span> <span class="n">-Key</span> <span class="s2">&#34;7days-backups/</span><span class="nv">$name</span><span class="s2">&#34;</span> <span class="o">-File</span> <span class="nv">$zip</span>
</span></span><span class="line"><span class="cl"><span class="nb">Expand-Archive</span> <span class="nv">$zip</span> <span class="n">-DestinationPath</span> <span class="nv">$dir</span> <span class="n">-Force</span></span></span></code></pre></div></div>
  </div>
</div></li>
<li>
<p>Move <code>Saves</code> into place:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Move-Item</span> <span class="p">(</span><span class="nb">Join-Path</span> <span class="nv">$dir</span> <span class="s1">&#39;Saves&#39;</span><span class="p">)</span> <span class="nv">$saves</span></span></span></code></pre></div></div>
  </div>
</div></li>
<li>
<p>Only if you want the old settings back, copy the old <code>serverconfig.xml</code>
over the current one:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Copy-Item</span> <span class="p">(</span><span class="nb">Join-Path</span> <span class="nv">$dir</span> <span class="s1">&#39;serverconfig.xml&#39;</span><span class="p">)</span> <span class="n">C:</span><span class="p">\</span><span class="n">7DaysToDieServer</span><span class="p">\</span> <span class="n">-Force</span></span></span></code></pre></div></div>
  </div>
</div></li>
<li>
<p>Start the server.</p>
</li>
</ol>
<p>Do a practice restore on a scratch machine before you need one for real.
Until you&rsquo;ve done that, you don&rsquo;t know that the backup works.</p>
<h2 id="use-a-different-destination">Use a different destination<a class="heading-anchor" href="https://looselydecoupled.com/backup-7-days-to-die-server-s3/#use-a-different-destination"
     aria-label="Link to section: Use a different destination" data-pagefind-ignore></a>
</h2>
<p>S3 appears in steps 1 to 3, the script&rsquo;s setup checks, its upload and its
S3 prune. Everything else works with any destination. To write to another
drive or a share instead:</p>
<ul>
<li>
<p>Skip steps 1 to 3. In the script, delete everything between the line that
logs the account name and <code>$BackupName</code>: the <code>backup-config.txt</code> check, the
module import and the credentials test. Left in, the missing config file
stops every run before the copy.</p>
</li>
<li>
<p>Replace everything from <code>Uploading to S3...</code> through <code>Upload successful</code>
with a copy, checked the same way:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$Destination</span> <span class="p">=</span> <span class="s1">&#39;\\nas\backups\7dtd&#39;</span>
</span></span><span class="line"><span class="cl"><span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Copy-Item</span> <span class="nv">$BackupPath</span> <span class="n">-Destination</span> <span class="nv">$Destination</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$remote</span> <span class="p">=</span> <span class="nb">Get-Item</span> <span class="p">(</span><span class="nb">Join-Path</span> <span class="nv">$Destination</span> <span class="nv">$BackupName</span><span class="p">)</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Stop-Run</span> <span class="s2">&#34;Failed to copy backup to </span><span class="nv">${Destination}</span><span class="s2">: </span><span class="p">$(</span><span class="nv">$_</span><span class="p">.</span><span class="py">Exception</span><span class="p">.</span><span class="n">Message</span><span class="p">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nv">$remote</span><span class="p">.</span><span class="py">Length</span> <span class="o">-ne</span> <span class="nv">$localSize</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Stop-Run</span> <span class="s2">&#34;Copy size mismatch: local </span><span class="nv">$localSize</span><span class="s2"> bytes, destination </span><span class="p">$(</span><span class="nv">$remote</span><span class="p">.</span><span class="n">Length</span><span class="p">)</span><span class="s2"> bytes&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="nb">Write-Log</span> <span class="s2">&#34;Copy successful: </span><span class="p">$(</span><span class="nv">$remote</span><span class="p">.</span><span class="n">Length</span><span class="p">)</span><span class="s2"> bytes&#34;</span></span></span></code></pre></div></div>
  </div>
</div></li>
<li>
<p>Replace the S3 prune with a date-based <code>Get-ChildItem</code> prune on the
destination, like the one the script uses for its log files.</p>
</li>
</ul>
<p>A share needs one more decision, because the task runs as SYSTEM. On a
domain, SYSTEM reaches the network as the computer account (<code>SERVER$</code>), so
grant that account write access on the share. Outside a domain, SYSTEM has
no identity a NAS will accept. Run the task as a local user that can write to
the share instead.</p>
<p>A second disk in the same machine protects you from a failed drive. It
doesn&rsquo;t help if you lose the whole machine.</p>
<h2 id="why-it-is-built-this-way">Why it is built this way<a class="heading-anchor" href="https://looselydecoupled.com/backup-7-days-to-die-server-s3/#why-it-is-built-this-way"
     aria-label="Link to section: Why it is built this way" data-pagefind-ignore></a>
</h2>
<p><strong>A zip a day, not a sync.</strong> A sync keeps one current copy of each file, so
a save that corrupts on Tuesday overwrites Monday&rsquo;s good copy on Tuesday
night. A dated zip each day gives you thirty separate restore points without
setting up versioning. At 23 MB a day, a whole month is under a gigabyte.</p>
<p><strong><code>STANDARD_IA</code>, because the retention matches.</strong> Infrequent Access charges
for at least 30 days of storage per object. Objects pruned at exactly 30 days
pay that minimum and nothing more, at a lower per-gigabyte rate than
Standard. A restore reads one object once, so the retrieval charge stays
small.</p>
<p><strong>Retention in two places.</strong> The script prunes the bucket at 30 days, and so
does the lifecycle rule. On 2026-09-10 the VM was switched off, and it stayed
off for twelve days. The script didn&rsquo;t run, so its prune didn&rsquo;t either, but
the lifecycle rule kept deleting. When the machine came back, the bucket held
only the archives from the last thirty days, about twelve short of thirty. If
retention lived only in the script, old archives would pile up whenever the
script stopped running.</p>
<p><strong>Three exit codes, not two.</strong> Once the upload succeeds, the backup exists,
and a later failure shouldn&rsquo;t be reported the same way as an earlier one.
Exit 2 means &ldquo;backed up, check the log&rdquo;. Task Scheduler still shows it as a
non-zero result, so you notice it.</p>
<h2 id="what-the-batch-file-it-replaced-got-wrong">What the batch file it replaced got wrong<a class="heading-anchor" href="https://looselydecoupled.com/backup-7-days-to-die-server-s3/#what-the-batch-file-it-replaced-got-wrong"
     aria-label="Link to section: What the batch file it replaced got wrong" data-pagefind-ignore></a>
</h2>
<p>A batch file, <code>backup-s3.bat</code>, did this job from 2026-02-06 to 2026-09-22.
It called PowerShell only for <code>Compress-Archive</code> and a date comparison, and
it never missed a morning the machine was on. If you run something like it,
it probably has the same three defects:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="BAT">
  <div class="code-block__head"><span class="code-block__lang">BAT</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bat" data-lang="bat"><span class="line"><span class="cl"><span class="k">set</span> <span class="nv">TIMESTAMP</span><span class="p">=</span><span class="nv">%date:~-4,4%%date:~-10,2%%date:~-7,2%</span>-<span class="nv">%time:~0,2%%time:~3,2%%time:~6,2%</span></span></span></code></pre></div></div>
  </div>
</div><ul>
<li>The timestamp cut <code>%date%</code> up by character position. That only works with
the en-US format, <code>Tue 09/22/2026</code>. With any other system locale, the
filenames come out scrambled.</li>
<li>The S3 prune ran <code>aws s3 rm</code> in a loop without checking any results, and
the file always ended with <code>exit /b 0</code>. If the <code>s3:DeleteObject</code>
permission had been missing, nobody would have noticed, because the
lifecycle rule would have done the deleting instead.</li>
<li>Each log carried ninety lines of the CLI&rsquo;s progress bar around the one
line that mattered.</li>
</ul>
<p>None of the three ever caused a failure. The AWS CLI went too, the day after
the switch. <code>aws s3 cp</code> reports failure only through an exit code, which has
to be checked after every call. A script that misses one check reports
success for an upload that didn&rsquo;t happen. <code>Write-S3Object</code> throws an error
instead, and one <code>try</code>/<code>catch</code> handles it.</p>
<h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/backup-7-days-to-die-server-s3/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="aws-tools-for-powershell-not-available-but-the-module-imports-fine-for-you"><code>AWS Tools for PowerShell not available</code>, but the module imports fine for you<a class="heading-anchor" href="https://looselydecoupled.com/backup-7-days-to-die-server-s3/#aws-tools-for-powershell-not-available-but-the-module-imports-fine-for-you"
     aria-label="Link to section: AWS Tools for PowerShell not available, but the module imports fine for you" data-pagefind-ignore></a>
</h3>
<p>The modules were installed per-user instead of with <code>-Scope AllUsers</code>, so
they&rsquo;re under your Documents folder, where SYSTEM can&rsquo;t see them.
<code>Get-Module -ListAvailable AWS.Tools.S3</code> shows the path.</p>
<h3 id="aws-credentials-or-region-not-usable-by-this-account-but-the-same-call-works-for-you"><code>AWS credentials or region not usable by this account</code>, but the same call works for you<a class="heading-anchor" href="https://looselydecoupled.com/backup-7-days-to-die-server-s3/#aws-credentials-or-region-not-usable-by-this-account-but-the-same-call-works-for-you"
     aria-label="Link to section: AWS credentials or region not usable by this account, but the same call works for you" data-pagefind-ignore></a>
</h3>
<p>The task isn&rsquo;t running as you, so it doesn&rsquo;t use your credentials. Go back to
step 2. The account name at the top of the log shows who ran it. The message
mentions the region because a missing region fails at the same point as a
missing key. It&rsquo;s easy to spend an hour in the credentials file when the
problem is in <code>config</code>.</p>
<h3 id="task-arguments-come-out-mangled">Task arguments come out mangled<a class="heading-anchor" href="https://looselydecoupled.com/backup-7-days-to-die-server-s3/#task-arguments-come-out-mangled"
     aria-label="Link to section: Task arguments come out mangled" data-pagefind-ignore></a>
</h3>
<p>On this server, <code>schtasks /Change /TR</code> with a quoted path inside the string
produced <code>-File &quot; C:\Scripts\backup-s3.ps1\</code>, with a leading space and a stray
backslash. <code>New-ScheduledTaskAction</code> sets <code>Execute</code> and <code>Arguments</code> as
separate values, so the quoting survives. Use <code>Set-ScheduledTask -Action</code> to
apply it to an existing task.</p>
<h3 id="after-an-outage-the-result-reads-the-operator-or-administrator-has-refused-the-request">After an outage, the result reads <code>The operator or administrator has refused the request</code><a class="heading-anchor" href="https://looselydecoupled.com/backup-7-days-to-die-server-s3/#after-an-outage-the-result-reads-the-operator-or-administrator-has-refused-the-request"
     aria-label="Link to section: After an outage, the result reads The operator or administrator has refused the request" data-pagefind-ignore></a>
</h3>
<p>The machine was off at 11:00. On boot, the scheduler recorded event 153,
&ldquo;Missed task start rejected&rdquo;, because the task&rsquo;s &ldquo;run as soon as possible
after a scheduled start is missed&rdquo; setting is off. Nothing failed, and the
next 11:00 run goes ahead normally. Turn the setting on if you want a
catch-up run, at the cost of a backup taken while the server is still
starting.</p>
<h3 id="compress-archive-fails-on-one-large-file"><code>Compress-Archive</code> fails on one large file<a class="heading-anchor" href="https://looselydecoupled.com/backup-7-days-to-die-server-s3/#compress-archive-fails-on-one-large-file"
     aria-label="Link to section: Compress-Archive fails on one large file" data-pagefind-ignore></a>
</h3>
<p><code>Compress-Archive</code> can&rsquo;t add a single file over 2 GB. The limit comes from
the .NET API underneath it, in both Windows PowerShell 5.1 and PowerShell 7.
Nothing under <code>Saves</code> comes near that on this server. If one of your files
does, use <code>tar.exe</code>, which ships with Windows Server 2019 and later and has
no such limit.</p>
<h3 id="saves-are-captured-mid-write">Saves are captured mid-write<a class="heading-anchor" href="https://looselydecoupled.com/backup-7-days-to-die-server-s3/#saves-are-captured-mid-write"
     aria-label="Link to section: Saves are captured mid-write" data-pagefind-ignore></a>
</h3>
<p>The server keeps running and writing while the archive is taken, so a
restored save can be a few minutes stale. If that matters for your world,
stop the server before the backup, or accept the gap.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>OS</dt> <dd>Windows Server 2019 (10.0.17763), VM</dd></div>
    <div class="tested__row"><dt>PowerShell</dt> <dd>Windows PowerShell 5.1</dd></div>
    <div class="tested__row"><dt>AWS Tools</dt> <dd>AWS.Tools.S3 and AWS.Tools.SecurityToken 5.0.304</dd></div>
    <div class="tested__row"><dt>Task</dt> <dd>daily 11:00 UTC as SYSTEM; batch 2026-02-06 to 2026-09-22, PowerShell from 2026-09-22</dd></div>
    <div class="tested__row"><dt>Saves</dt> <dd>about 23 MB zipped</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>September 2026</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
    <item>
      <title>How VBScript&#39;s Friction Produced PowerShell&#39;s Object Pipeline</title>
      <link>https://looselydecoupled.com/vbscript-primitive-glue-retrospective/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/vbscript-primitive-glue-retrospective/</guid>
      <pubDate>Fri, 26 Dec 2025 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>VBScript</category>
      <category>PowerShell</category>
      <category>History</category>
      <category>Windows Server</category>
      <category>DevOps</category>
      
      <description>The friction that moved Windows automation from VBScript&#39;s text to PowerShell&#39;s objects also moved cloud tooling from scripts to declarative templates.</description>
      <content:encoded><![CDATA[<p>If you started in the cloud era, infrastructure as code is JSON, HCL and YAML:
declare a state, and the provider makes it so. In the Windows environments I
managed in the early 2000s, state wasn&rsquo;t declared. It was coerced, one
script at a time, and the language was VBScript.</p>
<p>My claim: VBScript&rsquo;s friction is what produced PowerShell&rsquo;s object pipeline,
and the same friction is what moved cloud tooling from imperative scripts to
declarative templates. The history is worth a few minutes because it predicts
what the next tool will fix.</p>
<h2 id="on-error-resume-next"><code>On Error Resume Next</code><a class="heading-anchor" href="https://looselydecoupled.com/vbscript-primitive-glue-retrospective/#on-error-resume-next"
     aria-label="Link to section: On Error Resume Next" data-pagefind-ignore></a>
</h2>
<p>In 2003, mapping a printer by security group or reading the BIOS version of
500 servers had no command. It had Notepad, and a <code>.vbs</code> file run by Windows
Script Host.</p>
<p>VBScript had no safety rails. The line that opened most production scripts
was:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="VBScript">
  <div class="code-block__head"><span class="code-block__lang">VBScript</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-vb" data-lang="vb"><span class="line"><span class="cl"><span class="k">On</span> <span class="k">Error</span> <span class="k">Resume</span> <span class="k">Next</span></span></span></code></pre></div></div>
  </div>
</div><p>It tells the interpreter to ignore any failing line and carry on. Distributed
systems talk about failing fast; a VBScript failed silently and left the
system in whatever state the last successful line produced. Nobody wrote it
out of carelessness. Without it, one missing printer stopped that user&rsquo;s logon
script at that line, and every mapping after it was skipped. Structured error
handling in VBScript was awkward enough that swallowing errors was the
pragmatic choice.</p>
<h2 id="wmi-as-the-engine-vbscript-as-the-crank">WMI as the engine, VBScript as the crank<a class="heading-anchor" href="https://looselydecoupled.com/vbscript-primitive-glue-retrospective/#wmi-as-the-engine-vbscript-as-the-crank"
     aria-label="Link to section: WMI as the engine, VBScript as the crank" data-pagefind-ignore></a>
</h2>
<p>The lasting legacy of that era isn&rsquo;t VBScript&rsquo;s syntax. It&rsquo;s that VBScript
was how a generation of administrators met WMI, Windows Management
Instrumentation, Microsoft&rsquo;s implementation of the DMTF&rsquo;s WBEM standard. WMI
exposed the operating system as a queryable database: disks, services and
processes as rows, with a SQL-like language to select them.</p>
<p>The database was good. The interface to it was this:</p>
<div class="code-block" data-copyable data-copy-label="VBScript">
  <div class="code-block__head"><span class="code-block__lang">VBScript</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-vb" data-lang="vb"><span class="line"><span class="cl"><span class="c">&#39; Free space per logical disk
</span></span></span><span class="line"><span class="cl"><span class="n">strComputer</span> <span class="o">=</span> <span class="s">&#34;.&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">Set</span> <span class="n">objWMIService</span> <span class="o">=</span> <span class="n">GetObject</span><span class="p">(</span><span class="s">&#34;winmgmts:\\&#34;</span> <span class="o">&amp;</span> <span class="n">strComputer</span> <span class="o">&amp;</span> <span class="s">&#34;\root\cimv2&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">Set</span> <span class="n">colItems</span> <span class="o">=</span> <span class="n">objWMIService</span><span class="p">.</span><span class="n">ExecQuery</span><span class="p">(</span><span class="s">&#34;Select * from Win32_LogicalDisk&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">For</span> <span class="k">Each</span> <span class="n">objItem</span> <span class="ow">in</span> <span class="n">colItems</span>
</span></span><span class="line"><span class="cl">    <span class="n">Wscript</span><span class="p">.</span><span class="n">Echo</span> <span class="s">&#34;Disk: &#34;</span> <span class="o">&amp;</span> <span class="n">objItem</span><span class="p">.</span><span class="n">Name</span> <span class="o">&amp;</span> <span class="s">&#34; Free Space: &#34;</span> <span class="o">&amp;</span> <span class="n">objItem</span><span class="p">.</span><span class="n">FreeSpace</span>
</span></span><span class="line"><span class="cl"><span class="k">Next</span></span></span></code></pre></div></div>
  </div>
</div><p>Seven lines to read one property, and every line is ceremony: a COM moniker,
a query string, a loop and string concatenation to print. The verbosity forced
administrators to learn loops, variable types and COM objects, which is the
one thing VBScript did for the profession. It was a high barrier, and it
separated the people who clicked from the people who scripted.</p>
<h2 id="from-parsing-text-to-passing-objects">From parsing text to passing objects<a class="heading-anchor" href="https://looselydecoupled.com/vbscript-primitive-glue-retrospective/#from-parsing-text-to-passing-objects"
     aria-label="Link to section: From parsing text to passing objects" data-pagefind-ignore></a>
</h2>
<p>Every VBScript that read another program&rsquo;s output parsed text. Jeffrey
Snover&rsquo;s Monad Manifesto (2002), the design document behind PowerShell 1.0
(2006), names that practice as the problem. Text output is a contract nobody
declared, so a script is coupled to the exact formatting of whatever it
reads. When &ldquo;Free Space&rdquo; became &ldquo;FreeSpace&rdquo;, the script broke.</p>
<p>PowerShell separated the data from its presentation. The same query, in the
modern cmdlet:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="c"># Objects, not text</span>
</span></span><span class="line"><span class="cl"><span class="nb">Get-CimInstance</span> <span class="n">Win32_LogicalDisk</span> <span class="p">|</span> <span class="nb">Select-Object</span> <span class="n">DeviceID</span><span class="p">,</span> <span class="n">FreeSpace</span></span></span></code></pre></div></div>
  </div>
</div><p>Side by side, the two snippets show a change of model, not of syntax.
VBScript was imperative: you told the computer how to connect, how to iterate
and how to print. PowerShell is declarative at the call site: you ask for the
object and the pipeline handles the rest. Formatting happens once, at the end,
by a formatter that knows the object&rsquo;s type.</p>
<h2 id="why-this-matters-for-cloud-work">Why this matters for cloud work<a class="heading-anchor" href="https://looselydecoupled.com/vbscript-primitive-glue-retrospective/#why-this-matters-for-cloud-work"
     aria-label="Link to section: Why this matters for cloud work" data-pagefind-ignore></a>
</h2>
<p>Cloud tooling made the same move, a decade later. The first generation of AWS
automation was imperative scripts around the CLI: create an instance, wait,
attach a volume, tag it. CloudFormation (2011) and Terraform (2014) replaced
that with a declared end state and an engine that computes the steps.</p>
<p>VBScript was the imperative-scripts phase of Windows administration. It was
hard to debug and didn&rsquo;t scale past the person who wrote it. It was also
necessary: it&rsquo;s how administration got from the GUI to anything automated at
all, and the pain of it is what made the next model&rsquo;s advantages legible.</p>
<h2 id="what-would-change-my-mind">What would change my mind<a class="heading-anchor" href="https://looselydecoupled.com/vbscript-primitive-glue-retrospective/#what-would-change-my-mind"
     aria-label="Link to section: What would change my mind" data-pagefind-ignore></a>
</h2>
<p>The argument rests on one causal claim: that text-parsing friction, not some
other pressure, is what produced the object pipeline. The Monad Manifesto
supports it in Snover&rsquo;s own words, which is the best evidence available short
of the design meetings. The account could be a retrospective tidying of
decisions made for other reasons, such as .NET adoption inside Microsoft. If
it is, the lineage still holds but the lesson doesn&rsquo;t.</p>
<p>I still keep a folder of old <code>.vbs</code> scripts. I do not run them. They are a
record of what the tooling cost before someone decided the cost was the
problem. The next time a CloudFormation template takes an afternoon to debug,
the alternative was <code>On Error Resume Next</code>.</p>
]]></content:encoded>
    </item>
    
    <item>
      <title>What Does This PowerShell Script Do?</title>
      <link>https://looselydecoupled.com/what-does-this-powershell-script-do/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/what-does-this-powershell-script-do/</guid>
      <pubDate>Sun, 14 Dec 2025 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>PowerShell</category>
      <category>Automation</category>
      
      <description>Read a script you copied with the tools built into PowerShell, without running anything that changes your files.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Take the script apart one command at a time, and ask PowerShell about each
piece:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="c"># What a name runs, and where it comes from.</span>
</span></span><span class="line"><span class="cl"><span class="nb">Get-Command</span> <span class="nb">sort
</span></span></span><span class="line"><span class="cl"><span class="c"># What a cmdlet is for, with worked examples (run Update-Help once first).</span>
</span></span><span class="line"><span class="cl"><span class="nb">Get-Help</span> <span class="nb">Sort-Object</span> <span class="n">-Examples</span>
</span></span><span class="line"><span class="cl"><span class="c"># What the objects in a pipeline carry.</span>
</span></span><span class="line"><span class="cl"><span class="nb">Get-ChildItem</span> <span class="p">|</span> <span class="nb">Get-Member</span>
</span></span><span class="line"><span class="cl"><span class="c"># What a command would change, without changing it.</span>
</span></span><span class="line"><span class="cl"><span class="nb">Get-ChildItem</span> <span class="n">-Recurse</span> <span class="n">-Filter</span> <span class="p">*.</span><span class="n">log</span> <span class="p">|</span> <span class="nb">Remove-Item</span> <span class="n">-WhatIf</span></span></span></code></pre></div></div>
  </div>
</div><p>Check <code>Get-Command</code> first: a short name such as <code>sort</code> can run a different
program on macOS or Linux than it does on Windows.</p>

</div>

<p>A question on <a href="https://www.reddit.com/r/PowerShell/comments/1p5vxxg/what_does_it_mean_to_learnknow_powershell" rel="external noopener" target="_blank">r/PowerShell<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/what-does-this-powershell-script-do/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
put it plainly: someone needed to export names from Exchange, found a script,
pasted it in, and it worked. They had no idea what it did, and wondered
whether they could say they knew PowerShell.</p>
<p>To me, knowing PowerShell means you can sit at a prompt with a repetitive
task and find your way to automating it: the basics, plus knowing how to find
the right cmdlets. Writing your own modules is where that leads, not the
entry requirement. If you&rsquo;re learning PowerShell, reading the scripts you
already use this way is a faster start than memorizing cmdlets. And the way
to find the right cmdlets is the same way to understand a script someone else wrote: ask PowerShell itself. Every step
below is one of those questions.</p>
<p>The example is a one-liner of the sort people paste: list the three largest
files under the current folder.</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">gci </span><span class="n">-r</span> <span class="o">-File</span> <span class="p">|</span> <span class="nb">sort </span><span class="n">Length</span> <span class="n">-Desc</span> <span class="p">|</span> <span class="nb">select </span><span class="n">-First</span> <span class="mf">3</span> <span class="n">Name</span><span class="p">,</span> <span class="vm">@</span><span class="p">{</span><span class="n">n</span><span class="p">=</span><span class="s1">&#39;MB&#39;</span><span class="p">;</span><span class="n">e</span><span class="p">={[</span><span class="no">math</span><span class="p">]::</span><span class="n">Round</span><span class="p">(</span><span class="nv">$_</span><span class="p">.</span><span class="n">Length</span><span class="p">/</span><span class="mf">1</span><span class="p">MB,</span><span class="mf">1</span><span class="p">)}}</span></span></span></code></pre></div></div>
  </div>
</div><!-- style-check: procedure -->
<h2 id="find-out-what-each-command-is">Find out what each command is<a class="heading-anchor" href="https://looselydecoupled.com/what-does-this-powershell-script-do/#find-out-what-each-command-is"
     aria-label="Link to section: Find out what each command is" data-pagefind-ignore></a>
</h2>
<p>A pipeline is commands joined by <code>|</code>. Before anything else, ask what each name
runs. <code>Get-Command</code> resolves aliases and shows where a command comes from:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">Get-Command</span> <span class="console__token">gci,</span> <span class="console__token">sort,</span> <span class="console__token">select</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line">
</span><span class="line"><span class="console__token">CommandType</span>     <span class="console__token">Name</span>                                               <span class="console__token">Version</span>    <span class="console__token">Source</span>
</span><span class="line"><span class="console__token">-----------</span>     <span class="console__token">----</span>                                               <span class="console__token">-------</span>    <span class="console__token">------</span>
</span><span class="line"><span class="console__token">Alias</span>           <span class="console__token">gci</span> <span class="console__token">-&gt;</span> <span class="console__token">Get-ChildItem</span>
</span><span class="line"><span class="console__token">Application</span>     <span class="console__token">sort</span>                                               <span class="console__token">0.0.0.0</span>    <span class="console__token">/usr/bin/sort</span>
</span><span class="line"><span class="console__token">Alias</span>           <span class="console__token">select</span> <span class="console__token">-&gt;</span> <span class="console__token">Select-Object</span></span></code></pre>
    </div>
  </div></div><p><code>gci</code> and <code>select</code> are aliases for cmdlets. <code>sort</code> isn&rsquo;t: on macOS, PowerShell
has no <code>sort</code> alias, so the name runs the Unix <code>sort</code> program, which doesn&rsquo;t
understand <code>-Desc</code>. A one-liner written on Windows, where <code>sort</code> means
<code>Sort-Object</code>, fails here. Replace each alias with the full name it stands
for, and the script reads the same everywhere:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Get-ChildItem</span> <span class="n">-Recurse</span> <span class="o">-File</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Sort-Object</span> <span class="n">-Property</span> <span class="n">Length</span> <span class="n">-Descending</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Select-Object</span> <span class="n">-First</span> <span class="mf">3</span> <span class="n">-Property</span> <span class="n">Name</span><span class="p">,</span> <span class="vm">@</span><span class="p">{</span><span class="n">Name</span> <span class="p">=</span> <span class="s1">&#39;MB&#39;</span><span class="p">;</span> <span class="n">Expression</span> <span class="p">=</span> <span class="p">{</span> <span class="p">[</span><span class="no">math</span><span class="p">]::</span><span class="n">Round</span><span class="p">(</span><span class="nv">$_</span><span class="p">.</span><span class="py">Length</span> <span class="p">/</span> <span class="mf">1</span><span class="p">MB,</span> <span class="mf">1</span><span class="p">)</span> <span class="p">}}</span></span></span></code></pre></div></div>
  </div>
</div><p><code>Get-Alias -Definition Get-ChildItem</code> works the other way, listing every
short name for a cmdlet. The same command decodes the two names that look
least like commands, <code>?</code> and <code>%</code>:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">Get-Alias</span> <span class="console__token">-Definition</span> <span class="console__token">Where-Object,</span> <span class="console__token">ForEach-Object</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line">
</span><span class="line"><span class="console__token">CommandType</span>     <span class="console__token">Name</span>                                               <span class="console__token">Version</span>    <span class="console__token">Source</span>
</span><span class="line"><span class="console__token">-----------</span>     <span class="console__token">----</span>                                               <span class="console__token">-------</span>    <span class="console__token">------</span>
</span><span class="line"><span class="console__token">Alias</span>           <span class="console__token">?</span> <span class="console__token">-&gt;</span> <span class="console__token">Where-Object</span>
</span><span class="line"><span class="console__token">Alias</span>           <span class="console__token">where</span> <span class="console__token">-&gt;</span> <span class="console__token">Where-Object</span>
</span><span class="line"><span class="console__token">Alias</span>           <span class="console__token">%</span> <span class="console__token">-&gt;</span> <span class="console__token">ForEach-Object</span>
</span><span class="line"><span class="console__token">Alias</span>           <span class="console__token">foreach</span> <span class="console__token">-&gt;</span> <span class="console__token">ForEach-Object</span></span></code></pre>
    </div>
  </div></div><p>Inside their braces, <code>$_</code> is the object the pipeline is handing over at that
moment.</p>
<h2 id="preview-what-a-command-would-change">Preview what a command would change<a class="heading-anchor" href="https://looselydecoupled.com/what-does-this-powershell-script-do/#preview-what-a-command-would-change"
     aria-label="Link to section: Preview what a command would change" data-pagefind-ignore></a>
</h2>
<p>Before running any part of the script, find the commands that change
something. Most built-in cmdlets that delete, move or overwrite take
<code>-WhatIf</code>, which reports what they would do instead of doing it:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">Get-ChildItem</span> <span class="console__token">-Recurse</span> <span class="console__token">-File</span> <span class="console__token">-Filter</span> <span class="console__token">*.log</span> <span class="console__token">|</span> <span class="console__token">Remove-Item</span> <span class="console__token">-WhatIf</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">What</span> <span class="console__token">if:</span> <span class="console__token">Performing</span> <span class="console__token">the</span> <span class="console__token">operation</span> <span class="console__token">&#34;Remove</span> <span class="console__token">File&#34;</span> <span class="console__token">on</span> <span class="console__token">target</span> <span class="console__token">&#34;&lt;folder&gt;/logs/app.log&#34;.</span>
</span><span class="line"><span class="console__token">What</span> <span class="console__token">if:</span> <span class="console__token">Performing</span> <span class="console__token">the</span> <span class="console__token">operation</span> <span class="console__token">&#34;Remove</span> <span class="console__token">File&#34;</span> <span class="console__token">on</span> <span class="console__token">target</span> <span class="console__token">&#34;&lt;folder&gt;/logs/old.log&#34;.</span></span></code></pre>
    </div>
  </div></div><p><code>-WhatIf</code> works only on commands that support it: cmdlets, and functions
declared with <code>SupportsShouldProcess</code>. On macOS, <code>rm</code>, <code>mv</code> and <code>cp</code> aren&rsquo;t
aliases but the system programs, which don&rsquo;t know <code>-WhatIf</code> at all, so a
Windows script&rsquo;s <code>rm -WhatIf</code> doesn&rsquo;t preview anything; <code>Get-Command</code> shows
which you have. A function can also accept <code>-WhatIf</code> and still change things
outside its <code>ShouldProcess</code> check, so read its body too.</p>
<h2 id="read-the-help-and-its-examples">Read the help and its examples<a class="heading-anchor" href="https://looselydecoupled.com/what-does-this-powershell-script-do/#read-the-help-and-its-examples"
     aria-label="Link to section: Read the help and its examples" data-pagefind-ignore></a>
</h2>
<p><code>Get-Help</code> explains a cmdlet, and <code>-Examples</code> shows it in use. A fresh
PowerShell installation has no local help files, so install them once:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Update-Help</span> <span class="n">-Scope</span> <span class="n">CurrentUser</span>
</span></span><span class="line"><span class="cl"><span class="nb">Get-Help</span> <span class="nb">Sort-Object</span> <span class="n">-Examples</span></span></span></code></pre></div></div>
  </div>
</div><p><code>Update-Help</code> downloads the help for every installed module; add <code>-Module</code>
with a module name to fetch only one.</p>
<h2 id="run-the-pipeline-one-stage-at-a-time">Run the pipeline one stage at a time<a class="heading-anchor" href="https://looselydecoupled.com/what-does-this-powershell-script-do/#run-the-pipeline-one-stage-at-a-time"
     aria-label="Link to section: Run the pipeline one stage at a time" data-pagefind-ignore></a>
</h2>
<p>Run only the stages that read. A cmdlet&rsquo;s verb is a first clue, not a
guarantee: <code>Get</code> and <code>Select</code> usually read, while <code>Remove</code>, <code>Set</code>, <code>New</code>,
<code>Move</code>, <code>Copy</code>, <code>Rename</code>, <code>Clear</code>, <code>Out</code>, <code>Export</code> and <code>Invoke</code> change things
and get <code>-WhatIf</code> first, as shown earlier. Read any <code>{ }</code> block too: the code
inside <code>Where-Object</code> or <code>ForEach-Object</code> can do anything.</p>
<p>Run the first reading command alone and look at what it produces, then add
the next <code>|</code> stage and run it again. Each stage&rsquo;s output is the next stage&rsquo;s
input, so this shows what every command contributes. <code>Select-Object</code> here
only picks the columns to show:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">Get-ChildItem</span> <span class="console__token">-Recurse</span> <span class="console__token">-File</span> <span class="console__token">|</span> <span class="console__token">Select-Object</span> <span class="console__token">-Property</span> <span class="console__token">Name,</span> <span class="console__token">Length</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line">
</span><span class="line"><span class="console__token">Name</span>              <span class="console__token">Length</span>
</span><span class="line"><span class="console__token">----</span>              <span class="console__token">------</span>
</span><span class="line"><span class="console__token">notes.txt</span>           <span class="console__token">4000</span>
</span><span class="line"><span class="console__token">photo.jpg</span>        <span class="console__token">3300000</span>
</span><span class="line"><span class="console__token">app.log</span>          <span class="console__token">2100000</span>
</span><span class="line"><span class="console__token">old.log</span>           <span class="console__token">800000</span>
</span><span class="line"><span class="console__token">archive.zip</span>     <span class="console__token">12600000</span>
</span><span class="line"><span class="console__token">q3-summary.xlsx</span>  <span class="console__token">4200000</span></span></code></pre>
    </div>
  </div></div><p>Add the sort, and the largest files rise to the top:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">Get-ChildItem</span> <span class="console__token">-Recurse</span> <span class="console__token">-File</span> <span class="console__token">|</span> <span class="console__token">Sort-Object</span> <span class="console__token">-Property</span> <span class="console__token">Length</span> <span class="console__token">-Descending</span> <span class="console__token">|</span> <span class="console__token">Select-Object</span> <span class="console__token">-Property</span> <span class="console__token">Name,</span> <span class="console__token">Length</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line">
</span><span class="line"><span class="console__token">Name</span>              <span class="console__token">Length</span>
</span><span class="line"><span class="console__token">----</span>              <span class="console__token">------</span>
</span><span class="line"><span class="console__token">archive.zip</span>     <span class="console__token">12600000</span>
</span><span class="line"><span class="console__token">q3-summary.xlsx</span>  <span class="console__token">4200000</span>
</span><span class="line"><span class="console__token">photo.jpg</span>        <span class="console__token">3300000</span>
</span><span class="line"><span class="console__token">app.log</span>          <span class="console__token">2100000</span>
</span><span class="line"><span class="console__token">old.log</span>           <span class="console__token">800000</span>
</span><span class="line"><span class="console__token">notes.txt</span>           <span class="console__token">4000</span></span></code></pre>
    </div>
  </div></div><h2 id="see-what-the-objects-carry">See what the objects carry<a class="heading-anchor" href="https://looselydecoupled.com/what-does-this-powershell-script-do/#see-what-the-objects-carry"
     aria-label="Link to section: See what the objects carry" data-pagefind-ignore></a>
</h2>
<p>PowerShell passes objects between commands, not text. <code>Get-Member</code> lists what
an object holds, which is where a name like <code>Length</code> in the example comes
from:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">Get-ChildItem</span> <span class="console__token">-File</span> <span class="console__token">|</span> <span class="console__token">Get-Member</span> <span class="console__token">-MemberType</span> <span class="console__token">Property</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line">
</span><span class="line">   <span class="console__token">TypeName:</span> <span class="console__token">System.IO.FileInfo</span>
</span><span class="line">
</span><span class="line"><span class="console__token">Name</span>              <span class="console__token">MemberType</span> <span class="console__token">Definition</span>
</span><span class="line"><span class="console__token">----</span>              <span class="console__token">----------</span> <span class="console__token">----------</span>
</span><span class="line"><span class="console__token">Attributes</span>        <span class="console__token">Property</span>   <span class="console__token">System.IO.FileAttributes</span> <span class="console__token">Attributes</span> <span class="console__token">{get;set;}</span>
</span><span class="line"><span class="console__token">CreationTime</span>      <span class="console__token">Property</span>   <span class="console__token">datetime</span> <span class="console__token">CreationTime</span> <span class="console__token">{get;set;}</span>
</span><span class="line"><span class="console__token">…</span>
</span><span class="line"><span class="console__token">Length</span>            <span class="console__token">Property</span>   <span class="console__token">long</span> <span class="console__token">Length</span> <span class="console__token">{get;}</span>
</span><span class="line"><span class="console__token">LinkTarget</span>        <span class="console__token">Property</span>   <span class="console__token">string</span> <span class="console__token">LinkTarget</span> <span class="console__token">{get;}</span>
</span><span class="line"><span class="console__token">Name</span>              <span class="console__token">Property</span>   <span class="console__token">string</span> <span class="console__token">Name</span> <span class="console__token">{get;}</span>
</span><span class="line"><span class="console__token">UnixFileMode</span>      <span class="console__token">Property</span>   <span class="console__token">System.IO.UnixFileMode</span> <span class="console__token">UnixFileMode</span> <span class="console__token">{get;set;}</span></span></code></pre>
    </div>
  </div></div><h2 id="find-the-cmdlet-for-your-own-task">Find the cmdlet for your own task<a class="heading-anchor" href="https://looselydecoupled.com/what-does-this-powershell-script-do/#find-the-cmdlet-for-your-own-task"
     aria-label="Link to section: Find the cmdlet for your own task" data-pagefind-ignore></a>
</h2>
<p>The same questions work forward, from a task to the command that does it.
Cmdlet names are a verb and a noun, so search by either:</p>
<!-- style-check: off -->
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">Get-Command</span> <span class="console__token">-Noun</span> <span class="console__token">Item</span> <span class="console__token">-Module</span> <span class="console__token">Microsoft.PowerShell.Management</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line">
</span><span class="line"><span class="console__token">CommandType</span>     <span class="console__token">Name</span>                                               <span class="console__token">Version</span>    <span class="console__token">Source</span>
</span><span class="line"><span class="console__token">-----------</span>     <span class="console__token">----</span>                                               <span class="console__token">-------</span>    <span class="console__token">------</span>
</span><span class="line"><span class="console__token">Cmdlet</span>          <span class="console__token">Clear-Item</span>                                         <span class="console__token">7.0.0.0</span>    <span class="console__token">Microsoft.PowerShell.…</span>
</span><span class="line"><span class="console__token">Cmdlet</span>          <span class="console__token">Copy-Item</span>                                          <span class="console__token">7.0.0.0</span>    <span class="console__token">Microsoft.PowerShell.…</span>
</span><span class="line"><span class="console__token">Cmdlet</span>          <span class="console__token">Get-Item</span>                                           <span class="console__token">7.0.0.0</span>    <span class="console__token">Microsoft.PowerShell.…</span>
</span><span class="line"><span class="console__token">Cmdlet</span>          <span class="console__token">Invoke-Item</span>                                        <span class="console__token">7.0.0.0</span>    <span class="console__token">Microsoft.PowerShell.…</span>
</span><span class="line"><span class="console__token">Cmdlet</span>          <span class="console__token">Move-Item</span>                                          <span class="console__token">7.0.0.0</span>    <span class="console__token">Microsoft.PowerShell.…</span>
</span><span class="line"><span class="console__token">Cmdlet</span>          <span class="console__token">New-Item</span>                                           <span class="console__token">7.0.0.0</span>    <span class="console__token">Microsoft.PowerShell.…</span>
</span><span class="line"><span class="console__token">Cmdlet</span>          <span class="console__token">Remove-Item</span>                                        <span class="console__token">7.0.0.0</span>    <span class="console__token">Microsoft.PowerShell.…</span>
</span><span class="line"><span class="console__token">Cmdlet</span>          <span class="console__token">Rename-Item</span>                                        <span class="console__token">7.0.0.0</span>    <span class="console__token">Microsoft.PowerShell.…</span>
</span><span class="line"><span class="console__token">Cmdlet</span>          <span class="console__token">Set-Item</span>                                           <span class="console__token">7.0.0.0</span>    <span class="console__token">Microsoft.PowerShell.…</span></span></code></pre>
    </div>
  </div></div><!-- style-check: on -->
<p><code>Get-Command -Verb Sort, Select, Group</code> finds the commands that sort, pick and
group, plus <code>Select-String</code> and <code>Select-Xml</code> for searching text and XML. From there, <code>Get-Help</code> and <code>Get-Member</code> take you the rest of the way.</p>
<!-- style-check: end procedure -->
<h2 id="expected-result">Expected result<a class="heading-anchor" href="https://looselydecoupled.com/what-does-this-powershell-script-do/#expected-result"
     aria-label="Link to section: Expected result" data-pagefind-ignore></a>
</h2>
<p>The one-liner becomes a script you can read, and running it gives the same
answer it was copied to give:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">Get-ChildItem</span> <span class="console__token">-Recurse</span> <span class="console__token">-File</span> <span class="console__token">|</span> <span class="console__token">Sort-Object</span> <span class="console__token">-Property</span> <span class="console__token">Length</span> <span class="console__token">-Descending</span> <span class="console__token">|</span> <span class="console__token">Select-Object</span> <span class="console__token">-First</span> <span class="console__token">3</span> <span class="console__token">-Property</span> <span class="console__token">Name,</span> <span class="console__token">@{Name</span> <span class="console__token">=</span> <span class="console__token">&#39;MB&#39;;</span> <span class="console__token">Expression</span> <span class="console__token">=</span> <span class="console__token">{</span> <span class="console__token">[math]::Round($_.Length</span> <span class="console__token">/</span> <span class="console__token">1MB,</span> <span class="console__token">1)</span> <span class="console__token">}}</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line">
</span><span class="line"><span class="console__token">Name</span>                <span class="console__token">MB</span>
</span><span class="line"><span class="console__token">----</span>                <span class="console__token">--</span>
</span><span class="line"><span class="console__token">archive.zip</span>     <span class="console__token">12.000</span>
</span><span class="line"><span class="console__token">q3-summary.xlsx</span>  <span class="console__token">4.000</span>
</span><span class="line"><span class="console__token">photo.jpg</span>        <span class="console__token">3.100</span></span></code></pre>
    </div>
  </div></div><h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/what-does-this-powershell-script-do/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="sort-invalid-option----d"><code>sort: invalid option -- D</code><a class="heading-anchor" href="https://looselydecoupled.com/what-does-this-powershell-script-do/#sort-invalid-option----d"
     aria-label="Link to section: sort: invalid option -- D" data-pagefind-ignore></a>
</h3>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">sort:</span> <span class="console__token">invalid</span> <span class="console__token">option</span> <span class="console__token">--</span> <span class="console__token">D</span>
</span><span class="line"><span class="console__token">Usage:</span> <span class="console__token">sort</span> <span class="console__token">[-bcCdfigMmnrsuz]</span> <span class="console__token">[-kPOS1[,POS2]</span> <span class="console__token">...</span> <span class="console__token">]</span> <span class="console__token">[+POS1</span> <span class="console__token">[-POS2]]</span> <span class="console__token">[-S</span> <span class="console__token">memsize]</span> <span class="console__token">[-T</span> <span class="console__token">tmpdir]</span> <span class="console__token">[-t</span> <span class="console__token">separator]</span> <span class="console__token">[-o</span> <span class="console__token">outfile]</span> <span class="console__token">[--batch-size</span> <span class="console__token">size]</span> <span class="console__token">[--files0-from</span> <span class="console__token">file]</span> <span class="console__token">[--heapsort]</span> <span class="console__token">[--mergesort]</span> <span class="console__token">[--radixsort]</span> <span class="console__token">[--qsort]</span> <span class="console__token">[--mmap]</span> <span class="console__token">[--parallel</span> <span class="console__token">thread_no]</span> <span class="console__token">[--human-numeric-sort]</span> <span class="console__token">[--version-sort]</span> <span class="console__token">[--random-sort</span> <span class="console__token">[--random-source</span> <span class="console__token">file]]</span> <span class="console__token">[--compress-program</span> <span class="console__token">program]</span> <span class="console__token">[file</span> <span class="console__token">...]</span></span></code></pre>
    </div>
  </div></div><p>The script was written for Windows, where <code>sort</code> is an alias for
<code>Sort-Object</code>. On macOS the name runs the system&rsquo;s <code>sort</code> program instead.
Use the full cmdlet name:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Sort-Object</span> <span class="n">-Property</span> <span class="n">Length</span> <span class="n">-Descending</span></span></span></code></pre></div></div>
  </div>
</div><h3 id="get-help-shows-only-partial-help"><code>Get-Help</code> shows only partial help<a class="heading-anchor" href="https://looselydecoupled.com/what-does-this-powershell-script-do/#get-help-shows-only-partial-help"
     aria-label="Link to section: Get-Help shows only partial help" data-pagefind-ignore></a>
</h3>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Get-Help</span> <span class="console__token">cannot</span> <span class="console__token">find</span> <span class="console__token">the</span> <span class="console__token">Help</span> <span class="console__token">files</span> <span class="console__token">for</span> <span class="console__token">this</span> <span class="console__token">cmdlet</span> <span class="console__token">on</span> <span class="console__token">this</span> <span class="console__token">computer.</span> <span class="console__token">It</span> <span class="console__token">is</span> <span class="console__token">displaying</span> <span class="console__token">only</span> <span class="console__token">partial</span> <span class="console__token">help.</span></span></code></pre>
    </div>
  </div></div><p>The help files aren&rsquo;t installed. Run <code>Update-Help</code> once, or read the same page
in a browser:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Get-Help</span> <span class="nb">Sort-Object</span> <span class="n">-Online</span></span></span></code></pre></div></div>
  </div>
</div><div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>PowerShell</dt> <dd>7.6.6</dd></div>
    <div class="tested__row"><dt>OS</dt> <dd>macOS 27.0</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
    <item>
      <title>PowerShell Parameter Validation for Safer Scripts</title>
      <link>https://looselydecoupled.com/powershell-parameter-validation/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/powershell-parameter-validation/</guid>
      <pubDate>Wed, 15 Jan 2025 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>PowerShell</category>
      <category>Code quality</category>
      <category>Automation</category>
      
      <description>Validation attributes stop a PowerShell script before it runs on bad input, but ValidateSet and ValidatePattern ignore case unless told not to.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Add a validation attribute to each parameter: <code>ValidateSet</code> for a fixed list,
<code>ValidatePattern</code> for a format, <code>ValidateRange</code> for a number and
<code>ValidateScript</code> for anything else. PowerShell checks every value a caller
passes before the script&rsquo;s first line runs, and stops with an error naming the
parameter. It never checks a default, so keep each default inside its own rule:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="c">#</span><span class="k">Requires</span><span class="w"> </span><span class="kd">-Version</span><span class="na"> 7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">param</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="nb">Parameter</span><span class="p">(</span><span class="na">Mandatory</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="nb">ValidateSet</span><span class="p">(</span><span class="s1">&#39;dev&#39;</span><span class="p">,</span> <span class="s1">&#39;staging&#39;</span><span class="p">,</span> <span class="s1">&#39;prod&#39;</span><span class="p">,</span> <span class="n">IgnoreCase</span> <span class="p">=</span> <span class="vm">$false</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="no">string</span><span class="p">]</span><span class="nv">$Environment</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="nb">Parameter</span><span class="p">(</span><span class="na">Mandatory</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="nb">ValidatePattern</span><span class="p">(</span><span class="s1">&#39;^app-[a-z0-9-]+\z&#39;</span><span class="p">,</span> <span class="n">Options</span> <span class="p">=</span> <span class="s1">&#39;None&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">ErrorMessage</span> <span class="p">=</span> <span class="s1">&#39;{0} is not a service name. Use app- and lowercase letters, digits or hyphens.&#39;</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="no">string</span><span class="p">]</span><span class="nv">$ServiceName</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="nb">ValidateRange</span><span class="p">(</span><span class="mf">1</span><span class="p">,</span> <span class="mf">365</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="no">int</span><span class="p">]</span><span class="nv">$DaysToKeep</span> <span class="p">=</span> <span class="mf">30</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="nb">Parameter</span><span class="p">(</span><span class="na">Mandatory</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="nb">ValidateScript</span><span class="p">({</span> <span class="nb">Test-Path</span> <span class="n">-Path</span> <span class="nv">$_</span> <span class="n">-PathType</span> <span class="n">Container</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="n">ErrorMessage</span> <span class="p">=</span> <span class="s1">&#39;Folder {0} does not exist.&#39;</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="no">string</span><span class="p">]</span><span class="nv">$LogRoot</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span></span></span></code></pre></div></div>
  </div>
</div><p><code>ValidateSet</code> and <code>ValidatePattern</code> ignore case by default, so <code>Prod</code> passes a
set of <code>prod</code>. <code>IgnoreCase = $false</code> and <code>Options = 'None'</code> make them exact.
Windows PowerShell 5.1 can&rsquo;t parse this <code>param</code> block, because <code>ErrorMessage</code>
exists from PowerShell 6 on; drop that argument to run it there.</p>

</div>

<p>A script that deletes, deploys or overwrites is only as safe as the values it&rsquo;s
given. A <code>-DaysToKeep</code> of 0 on a log cleanup removes every log; an environment
typed <code>Prod</code> instead of <code>prod</code> can slip past a later <code>if ($Environment -ceq 'prod')</code> and take the wrong branch. Validation attributes move those checks to
the parameter itself, where PowerShell enforces them on every value passed in,
before anything runs, and reports which parameter was wrong.</p>
<p>The example is a log cleanup script, <code>Remove-OldLog.ps1</code>, with four parameters.
Each section adds the rule for one of them.</p>
<!-- style-check: procedure -->
<h2 id="make-the-required-parameters-mandatory">Make the required parameters mandatory<a class="heading-anchor" href="https://looselydecoupled.com/powershell-parameter-validation/#make-the-required-parameters-mandatory"
     aria-label="Link to section: Make the required parameters mandatory" data-pagefind-ignore></a>
</h2>
<p><code>[Parameter(Mandatory)]</code> makes PowerShell refuse to run without a value. Run
interactively, it prompts for the missing value; run with <code>-NonInteractive</code>,
it stops with an error instead. It also
rejects an empty string, so <code>-ServiceName ''</code> fails before any other rule sees
it.</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="p">[</span><span class="nb">Parameter</span><span class="p">(</span><span class="na">Mandatory</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="no">string</span><span class="p">]</span><span class="nv">$ServiceName</span></span></span></code></pre></div></div>
  </div>
</div><h2 id="limit-a-value-to-a-list-with-validateset">Limit a value to a list with <code>ValidateSet</code><a class="heading-anchor" href="https://looselydecoupled.com/powershell-parameter-validation/#limit-a-value-to-a-list-with-validateset"
     aria-label="Link to section: Limit a value to a list with ValidateSet" data-pagefind-ignore></a>
</h2>
<p><code>ValidateSet</code> accepts only the values it names, and tab completion offers
them. It compares without case unless <code>IgnoreCase</code> is <code>$false</code>; with the
default, <code>Prod</code> passes and reaches the script with its capital P.</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="p">[</span><span class="nb">ValidateSet</span><span class="p">(</span><span class="s1">&#39;dev&#39;</span><span class="p">,</span> <span class="s1">&#39;staging&#39;</span><span class="p">,</span> <span class="s1">&#39;prod&#39;</span><span class="p">,</span> <span class="n">IgnoreCase</span> <span class="p">=</span> <span class="vm">$false</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="no">string</span><span class="p">]</span><span class="nv">$Environment</span></span></span></code></pre></div></div>
  </div>
</div><h2 id="check-a-format-with-validatepattern">Check a format with <code>ValidatePattern</code><a class="heading-anchor" href="https://looselydecoupled.com/powershell-parameter-validation/#check-a-format-with-validatepattern"
     aria-label="Link to section: Check a format with ValidatePattern" data-pagefind-ignore></a>
</h2>
<p><code>ValidatePattern</code> matches the value against a regular expression. Like
<code>ValidateSet</code>, it ignores case by default; <code>Options = 'None'</code> turns that off.
Its default error message repeats the regular expression back at the reader,
so give it an <code>ErrorMessage</code> that says what a valid value looks like. <code>{0}</code> is
the value that failed.</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="p">[</span><span class="nb">ValidatePattern</span><span class="p">(</span><span class="s1">&#39;^app-[a-z0-9-]+\z&#39;</span><span class="p">,</span> <span class="n">Options</span> <span class="p">=</span> <span class="s1">&#39;None&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">ErrorMessage</span> <span class="p">=</span> <span class="s1">&#39;{0} is not a service name. Use app- and lowercase letters, digits or hyphens.&#39;</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="no">string</span><span class="p">]</span><span class="nv">$ServiceName</span></span></span></code></pre></div></div>
  </div>
</div><p>Anchor the pattern with <code>^</code> and <code>\z</code>. Without anchors, <code>app-api; Remove-Item</code>
passes because it contains a match. Use <code>\z</code> rather than <code>$</code>: in .NET, <code>$</code> also
matches before a trailing newline, so <code>app-api</code> followed by a newline passes
<code>^app-[a-z0-9-]+$</code> and reaches the script with the newline still in it.</p>
<h2 id="bound-a-number-with-validaterange">Bound a number with <code>ValidateRange</code><a class="heading-anchor" href="https://looselydecoupled.com/powershell-parameter-validation/#bound-a-number-with-validaterange"
     aria-label="Link to section: Bound a number with ValidateRange" data-pagefind-ignore></a>
</h2>
<p><code>ValidateRange</code> sets an inclusive minimum and maximum. For a cleanup script,
the minimum is the safety rule: a 0 here would delete every log up to now.</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="p">[</span><span class="nb">ValidateRange</span><span class="p">(</span><span class="mf">1</span><span class="p">,</span> <span class="mf">365</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="no">int</span><span class="p">]</span><span class="nv">$DaysToKeep</span> <span class="p">=</span> <span class="mf">30</span></span></span></code></pre></div></div>
  </div>
</div><h2 id="check-anything-else-with-validatescript">Check anything else with <code>ValidateScript</code><a class="heading-anchor" href="https://looselydecoupled.com/powershell-parameter-validation/#check-anything-else-with-validatescript"
     aria-label="Link to section: Check anything else with ValidateScript" data-pagefind-ignore></a>
</h2>
<p><code>ValidateScript</code> runs a script block with the value in <code>$_</code> and accepts it
when the block&rsquo;s result is truthy. A result of 0, an empty string, <code>$null</code> or
no output fails it as surely as <code>$false</code>, so end the block on a test that
returns <code>$true</code>. Use it for checks no other attribute makes, such as
whether a folder exists.</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="p">[</span><span class="nb">ValidateScript</span><span class="p">({</span> <span class="nb">Test-Path</span> <span class="n">-Path</span> <span class="nv">$_</span> <span class="n">-PathType</span> <span class="n">Container</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="n">ErrorMessage</span> <span class="p">=</span> <span class="s1">&#39;Folder {0} does not exist.&#39;</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="no">string</span><span class="p">]</span><span class="nv">$LogRoot</span></span></span></code></pre></div></div>
  </div>
</div><!-- style-check: end procedure -->
<h2 id="expected-result">Expected result<a class="heading-anchor" href="https://looselydecoupled.com/powershell-parameter-validation/#expected-result"
     aria-label="Link to section: Expected result" data-pagefind-ignore></a>
</h2>
<p>Valid input runs the script. Anything else stops it before the first line, with
the parameter named:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">./Remove-OldLog.ps1</span> <span class="console__token">-Environment</span> <span class="console__token">prod</span> <span class="console__token">-ServiceName</span> <span class="console__token">app-api</span> <span class="console__token">-DaysToKeep</span> <span class="console__token">0</span> <span class="console__token">-LogRoot</span> <span class="console__token">./logs</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Remove-OldLog.ps1:</span> <span class="console__token">Cannot</span> <span class="console__token">validate</span> <span class="console__token">argument</span> <span class="console__token">on</span> <span class="console__token">parameter</span> <span class="console__token">&#39;DaysToKeep&#39;.</span> <span class="console__token">The</span> <span class="console__token">0</span> <span class="console__token">argument</span> <span class="console__token">is</span> <span class="console__token">less</span> <span class="console__token">than</span> <span class="console__token">the</span> <span class="console__token">minimum</span> <span class="console__token">allowed</span> <span class="console__token">range</span> <span class="console__token">of</span> <span class="console__token">1.</span> <span class="console__token">Supply</span> <span class="console__token">an</span> <span class="console__token">argument</span> <span class="console__token">that</span> <span class="console__token">is</span> <span class="console__token">greater</span> <span class="console__token">than</span> <span class="console__token">or</span> <span class="console__token">equal</span> <span class="console__token">to</span> <span class="console__token">1</span> <span class="console__token">and</span> <span class="console__token">then</span> <span class="console__token">try</span> <span class="console__token">the</span> <span class="console__token">command</span> <span class="console__token">again.</span></span></code></pre>
    </div>
  </div></div><p>The error is terminating, so a caller&rsquo;s <code>try</code>/<code>catch</code> sees it, and nothing in
the script body has run.</p>
<h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/powershell-parameter-validation/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="validateset-and-validatepattern-ignore-case-by-default"><code>ValidateSet</code> and <code>ValidatePattern</code> ignore case by default<a class="heading-anchor" href="https://looselydecoupled.com/powershell-parameter-validation/#validateset-and-validatepattern-ignore-case-by-default"
     aria-label="Link to section: ValidateSet and ValidatePattern ignore case by default" data-pagefind-ignore></a>
</h3>
<p><code>ValidateSet</code> and <code>ValidatePattern</code> compare without case unless told
otherwise, and the value reaches the script exactly as typed. Add
<code>IgnoreCase = $false</code> to <code>ValidateSet</code> and <code>Options = 'None'</code> to
<code>ValidatePattern</code>. With them, the mistyped value stops at the boundary:</p>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Remove-OldLog.ps1:</span> <span class="console__token">Cannot</span> <span class="console__token">validate</span> <span class="console__token">argument</span> <span class="console__token">on</span> <span class="console__token">parameter</span> <span class="console__token">&#39;Environment&#39;.</span> <span class="console__token">The</span> <span class="console__token">argument</span> <span class="console__token">&#34;Prod&#34;</span> <span class="console__token">does</span> <span class="console__token">not</span> <span class="console__token">belong</span> <span class="console__token">to</span> <span class="console__token">the</span> <span class="console__token">set</span> <span class="console__token">&#34;dev,staging,prod&#34;</span> <span class="console__token">specified</span> <span class="console__token">by</span> <span class="console__token">the</span> <span class="console__token">ValidateSet</span> <span class="console__token">attribute.</span> <span class="console__token">Supply</span> <span class="console__token">an</span> <span class="console__token">argument</span> <span class="console__token">that</span> <span class="console__token">is</span> <span class="console__token">in</span> <span class="console__token">the</span> <span class="console__token">set</span> <span class="console__token">and</span> <span class="console__token">then</span> <span class="console__token">try</span> <span class="console__token">the</span> <span class="console__token">command</span> <span class="console__token">again.</span></span></code></pre>
    </div>
  </div></div><h3 id="default-values-are-never-validated">Default values are never validated<a class="heading-anchor" href="https://looselydecoupled.com/powershell-parameter-validation/#default-values-are-never-validated"
     aria-label="Link to section: Default values are never validated" data-pagefind-ignore></a>
</h3>
<p>PowerShell validates the values a caller passes, never the default. A
<code>[ValidateRange(1, 365)]</code> parameter with a default of 0 runs with 0. Check each
default against its own rule when you write it.</p>
<h3 id="invalid-assignment-inside-the-script-doesnt-stop-it">Invalid assignment inside the script doesn&rsquo;t stop it<a class="heading-anchor" href="https://looselydecoupled.com/powershell-parameter-validation/#invalid-assignment-inside-the-script-doesnt-stop-it"
     aria-label="Link to section: Invalid assignment inside the script doesn’t stop it" data-pagefind-ignore></a>
</h3>
<p>The attribute stays on the variable, so a later assignment is checked too. A
failed check on an assignment ends only that statement: PowerShell prints the
error, keeps the old value and runs the next line.</p>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">MetadataError:</span> <span class="console__token">&lt;folder&gt;/Remove-OldLog.ps1:7</span>
</span><span class="line"><span class="console__token">Line</span> <span class="console__token">|</span>
</span><span class="line">   <span class="console__token">7</span> <span class="console__token">|</span>  <span class="console__token">$DaysToKeep</span> <span class="console__token">=</span> <span class="console__token">0</span>
</span><span class="line">     <span class="console__token">|</span>  <span class="console__token">~~~~~~~~~~~~~~~</span>
</span><span class="line">     <span class="console__token">|</span> <span class="console__token">The</span> <span class="console__token">variable</span> <span class="console__token">cannot</span> <span class="console__token">be</span> <span class="console__token">validated</span> <span class="console__token">because</span> <span class="console__token">the</span> <span class="console__token">value</span> <span class="console__token">0</span> <span class="console__token">is</span> <span class="console__token">not</span> <span class="console__token">a</span> <span class="console__token">valid</span>
</span><span class="line">     <span class="console__token">|</span> <span class="console__token">value</span> <span class="console__token">for</span> <span class="console__token">the</span> <span class="console__token">DaysToKeep</span> <span class="console__token">variable.</span></span></code></pre>
    </div>
  </div></div><p>Treat that message as a bug in the script. <code>try</code>/<code>catch</code> catches it, and
<code>$ErrorActionPreference = 'Stop'</code> at the top makes it end the run.</p>
<h3 id="validatepatterns-default-error-shows-the-regular-expression"><code>ValidatePattern</code>&rsquo;s default error shows the regular expression<a class="heading-anchor" href="https://looselydecoupled.com/powershell-parameter-validation/#validatepatterns-default-error-shows-the-regular-expression"
     aria-label="Link to section: ValidatePattern’s default error shows the regular expression" data-pagefind-ignore></a>
</h3>
<p>Without an <code>ErrorMessage</code>, a failed pattern reports the pattern itself:</p>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Remove-OldLog.ps1:</span> <span class="console__token">Cannot</span> <span class="console__token">validate</span> <span class="console__token">argument</span> <span class="console__token">on</span> <span class="console__token">parameter</span> <span class="console__token">&#39;ServiceName&#39;.</span> <span class="console__token">The</span> <span class="console__token">argument</span> <span class="console__token">&#34;API&#34;</span> <span class="console__token">does</span> <span class="console__token">not</span> <span class="console__token">match</span> <span class="console__token">the</span> <span class="console__token">&#34;^app-[a-z0-9-]+\z&#34;</span> <span class="console__token">pattern.</span> <span class="console__token">Supply</span> <span class="console__token">an</span> <span class="console__token">argument</span> <span class="console__token">that</span> <span class="console__token">matches</span> <span class="console__token">&#34;^app-[a-z0-9-]+\z&#34;</span> <span class="console__token">and</span> <span class="console__token">try</span> <span class="console__token">the</span> <span class="console__token">command</span> <span class="console__token">again.</span></span></code></pre>
    </div>
  </div></div><p>Add <code>ErrorMessage</code> to <code>ValidatePattern</code> and <code>ValidateScript</code>, as in the earlier
steps. It exists from PowerShell 6 on, so Windows PowerShell 5.1 can&rsquo;t run a
<code>param</code> block that uses it; the <code>#Requires -Version 7</code> line at the top of the
script states that requirement before anything else. I tested only PowerShell
7.6.6. <a href="https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_functions_advanced_parameters" rel="external noopener" target="_blank">about_Functions_Advanced_Parameters on Microsoft Learn<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/powershell-parameter-validation/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
lists every validation attribute.</p>
<h3 id="scheduled-run-stops-on-a-missing-value">Scheduled run stops on a missing value<a class="heading-anchor" href="https://looselydecoupled.com/powershell-parameter-validation/#scheduled-run-stops-on-a-missing-value"
     aria-label="Link to section: Scheduled run stops on a missing value" data-pagefind-ignore></a>
</h3>
<p>A mandatory parameter prompts when someone is at the console. Started with
<code>-NonInteractive</code>, the script stops instead:</p>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Remove-OldLog.ps1:</span> <span class="console__token">Cannot</span> <span class="console__token">process</span> <span class="console__token">command</span> <span class="console__token">because</span> <span class="console__token">of</span> <span class="console__token">one</span> <span class="console__token">or</span> <span class="console__token">more</span> <span class="console__token">missing</span> <span class="console__token">mandatory</span> <span class="console__token">parameters:</span> <span class="console__token">LogRoot.</span></span></code></pre>
    </div>
  </div></div><p>Start the task with <code>pwsh -NonInteractive -File</code> and pass every mandatory
parameter on its command line.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>PowerShell</dt> <dd>7.6.6</dd></div>
    <div class="tested__row"><dt>OS</dt> <dd>macOS 27.0</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
    <item>
      <title>How to Enable SSH on Windows Server 2025</title>
      <link>https://looselydecoupled.com/enable-ssh-windows-server-2025/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/enable-ssh-windows-server-2025/</guid>
      <pubDate>Mon, 16 Dec 2024 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Windows Server</category>
      <category>SSH</category>
      <category>PowerShell</category>
      <category>Automation</category>
      
      <description>Enabling SSH on Windows Server 2025 lets only Administrators and the OpenSSH Users group sign in. You turn it on with PowerShell, then choose who&#39;s in it.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>On Windows Server 2025 the OpenSSH server is installed but not running. In an
elevated PowerShell session:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Set-Service</span> <span class="n">-Name</span> <span class="n">sshd</span> <span class="n">-StartupType</span> <span class="n">Automatic</span>
</span></span><span class="line"><span class="cl"><span class="nb">Start-Service</span> <span class="n">-Name</span> <span class="n">sshd</span>
</span></span><span class="line"><span class="cl"><span class="nb">Get-NetFirewallRule</span> <span class="n">-Name</span> <span class="s1">&#39;OpenSSH-Server-In-TCP&#39;</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Format-Table</span> <span class="n">Name</span><span class="p">,</span> <span class="n">Enabled</span><span class="p">,</span> <span class="n">Profile</span></span></span></code></pre></div></div>
  </div>
</div><p>The stock rule covers the Private profile only. Before connecting, add the
profile the server&rsquo;s network is on; the firewall step below shows how to tell.
On a domain-joined server:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Set-NetFirewallRule</span> <span class="n">-Name</span> <span class="s1">&#39;OpenSSH-Server-In-TCP&#39;</span> <span class="n">-Profile</span> <span class="n">Domain</span><span class="p">,</span> <span class="n">Private</span></span></span></code></pre></div></div>
  </div>
</div><p>On a cloud VM or a workgroup server, whose network is usually Public:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Set-NetFirewallRule</span> <span class="n">-Name</span> <span class="s1">&#39;OpenSSH-Server-In-TCP&#39;</span> <span class="n">-Profile</span> <span class="n">Public</span><span class="p">,</span> <span class="n">Private</span></span></span></code></pre></div></div>
  </div>
</div><p>Run one, not both: <code>-Profile</code> replaces the rule&rsquo;s list rather than adding to it.</p>
<p>Then connect from a client:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ssh</span> <span class="console__token">Administrator@server-address</span></span></code></pre>
    </div>
  </div></div><p>If <code>Set-Service</code> reports that the service does not exist, the capability was
removed from your image; the first step below puts it back.</p>

</div>

<p>The visible change from Server 2022 is that the install step is usually gone:
Server 2025 images carry the <code>OpenSSH.Server</code> capability by default. Server
Manager gained a <strong>Remote SSH Access</strong> toggle on the Local Server page that
enables the service. The commands below do the
same thing from PowerShell, where they can be scripted, and they check each
assumption rather than relying on the image being stock.</p>
<!-- style-check: procedure -->
<h2 id="confirm-the-server-capability-is-present">Confirm the server capability is present<a class="heading-anchor" href="https://looselydecoupled.com/enable-ssh-windows-server-2025/#confirm-the-server-capability-is-present"
     aria-label="Link to section: Confirm the server capability is present" data-pagefind-ignore></a>
</h2>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Get-WindowsCapability</span> <span class="n">-Online</span> <span class="p">|</span> <span class="nb">Where-Object</span> <span class="n">Name</span> <span class="o">-like</span> <span class="s1">&#39;OpenSSH*&#39;</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Format-Table</span> <span class="n">Name</span><span class="p">,</span> <span class="n">State</span></span></span></code></pre></div></div>
  </div>
</div><p>On a stock image <code>OpenSSH.Server</code> reads <code>Installed</code>. If it reads <code>NotPresent</code>
— a hardened or minimised image may have dropped it — add it the same way as
on Server 2022:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Add-WindowsCapability</span> <span class="n">-Online</span> <span class="n">-Name</span> <span class="n">OpenSSH</span><span class="p">.</span><span class="n">Server</span><span class="p">~~~~</span><span class="mf">0.0</span><span class="p">.</span><span class="py">1</span><span class="p">.</span><span class="mf">0</span></span></span></code></pre></div></div>
  </div>
</div><h2 id="start-sshd-and-make-it-start-at-boot">Start sshd and make it start at boot<a class="heading-anchor" href="https://looselydecoupled.com/enable-ssh-windows-server-2025/#start-sshd-and-make-it-start-at-boot"
     aria-label="Link to section: Start sshd and make it start at boot" data-pagefind-ignore></a>
</h2>
<p>The service is registered but stopped. Set the start type before starting it,
so a reboot cannot leave the server unreachable:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Set-Service</span> <span class="n">-Name</span> <span class="n">sshd</span> <span class="n">-StartupType</span> <span class="n">Automatic</span>
</span></span><span class="line"><span class="cl"><span class="nb">Start-Service</span> <span class="n">-Name</span> <span class="n">sshd</span>
</span></span><span class="line"><span class="cl"><span class="nb">Get-Service</span> <span class="n">-Name</span> <span class="n">sshd</span></span></span></code></pre></div></div>
  </div>
</div><p>The first start writes the host keys and <code>sshd_config</code> to <code>C:\ProgramData\ssh\</code>.</p>
<h2 id="confirm-the-firewall-rule">Confirm the firewall rule<a class="heading-anchor" href="https://looselydecoupled.com/enable-ssh-windows-server-2025/#confirm-the-firewall-rule"
     aria-label="Link to section: Confirm the firewall rule" data-pagefind-ignore></a>
</h2>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Get-NetFirewallRule</span> <span class="n">-Name</span> <span class="s1">&#39;OpenSSH-Server-In-TCP&#39;</span> <span class="n">-ErrorAction</span> <span class="n">SilentlyContinue</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Format-Table</span> <span class="n">Name</span><span class="p">,</span> <span class="n">Enabled</span><span class="p">,</span> <span class="n">Direction</span><span class="p">,</span> <span class="n">Action</span><span class="p">,</span> <span class="n">Profile</span></span></span></code></pre></div></div>
  </div>
</div><p>The rule is normally present with <code>Enabled</code> set to <code>True</code>. If it is present
but disabled, enable it; if it is absent, create it:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Enable-NetFirewallRule</span> <span class="n">-Name</span> <span class="s1">&#39;OpenSSH-Server-In-TCP&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">New-NetFirewallRule</span> <span class="n">-Name</span> <span class="s1">&#39;OpenSSH-Server-In-TCP&#39;</span> <span class="n">-DisplayName</span> <span class="s1">&#39;OpenSSH Server (sshd)&#39;</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-Enabled</span> <span class="n">True</span> <span class="n">-Direction</span> <span class="n">Inbound</span> <span class="n">-Protocol</span> <span class="n">TCP</span> <span class="n">-Action</span> <span class="n">Allow</span> <span class="n">-LocalPort</span> <span class="mf">22</span></span></span></code></pre></div></div>
  </div>
</div><p>Run whichever of the two applies, not both. A rule created this way covers
every profile.</p>
<p>The stock rule covers the Private profile only, so check which profile the
server&rsquo;s network is on:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Get-NetConnectionProfile</span> <span class="p">|</span> <span class="nb">Format-Table</span> <span class="n">InterfaceAlias</span><span class="p">,</span> <span class="n">NetworkCategory</span></span></span></code></pre></div></div>
  </div>
</div><p>A domain-joined server usually reads <code>DomainAuthenticated</code>, and a cloud VM or
a workgroup server usually reads <code>Public</code>. The stock rule admits neither. For
<code>DomainAuthenticated</code>:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Set-NetFirewallRule</span> <span class="n">-Name</span> <span class="s1">&#39;OpenSSH-Server-In-TCP&#39;</span> <span class="n">-Profile</span> <span class="n">Domain</span><span class="p">,</span> <span class="n">Private</span></span></span></code></pre></div></div>
  </div>
</div><p>For <code>Public</code>:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Set-NetFirewallRule</span> <span class="n">-Name</span> <span class="s1">&#39;OpenSSH-Server-In-TCP&#39;</span> <span class="n">-Profile</span> <span class="n">Public</span><span class="p">,</span> <span class="n">Private</span></span></span></code></pre></div></div>
  </div>
</div><p>Run one, not both: <code>-Profile</code> replaces the rule&rsquo;s list rather than adding to
it, so the second command would undo the first. A server with more than one
network adapter can report more than one category; then name them all in one
command, such as <code>-Profile Domain, Public, Private</code>.</p>
<p>Public applies the rule on any network the server joins, so on a public
address let the network firewall in front of the server decide who reaches
port 22.</p>
<h2 id="connect-from-a-client">Connect from a client<a class="heading-anchor" href="https://looselydecoupled.com/enable-ssh-windows-server-2025/#connect-from-a-client"
     aria-label="Link to section: Connect from a client" data-pagefind-ignore></a>
</h2>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ssh</span> <span class="console__token">Administrator@server-address</span></span></code></pre>
    </div>
  </div></div><p>Accept the host key and enter the account&rsquo;s Windows password. You arrive in
<code>cmd.exe</code>; that is the default shell for Windows OpenSSH. To land in PowerShell
instead:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">New-ItemProperty</span> <span class="n">-Path</span> <span class="s1">&#39;HKLM:\SOFTWARE\OpenSSH&#39;</span> <span class="n">-Name</span> <span class="n">DefaultShell</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-PropertyType</span> <span class="n">String</span> <span class="n">-Force</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-Value</span> <span class="s1">&#39;C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe&#39;</span></span></span></code></pre></div></div>
  </div>
</div><!-- style-check: end procedure -->
<h2 id="expected-result">Expected result<a class="heading-anchor" href="https://looselydecoupled.com/enable-ssh-windows-server-2025/#expected-result"
     aria-label="Link to section: Expected result" data-pagefind-ignore></a>
</h2>
<p><code>Get-Service sshd</code> reports <code>Running</code>, something is listening on port 22:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Get-NetTCPConnection</span> <span class="n">-LocalPort</span> <span class="mf">22</span> <span class="n">-State</span> <span class="n">Listen</span></span></span></code></pre></div></div>
  </div>
</div><p>and a password login from a client succeeds for an administrator. Nobody else
can sign in yet, and the next section is why.</p>

<!-- style-check: procedure -->
<h2 id="choose-who-can-sign-in">Choose who can sign in<a class="heading-anchor" href="https://looselydecoupled.com/enable-ssh-windows-server-2025/#choose-who-can-sign-in"
     aria-label="Link to section: Choose who can sign in" data-pagefind-ignore></a>
</h2>
<p>On Server 2025, the generated <code>C:\ProgramData\ssh\sshd_config</code> already
limits SSH to two local groups:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">AllowGroups administrators &#34;openssh users&#34;</span></span></code></pre></div></div>
  </div>
</div><p>This is the change from Server 2022, where the generated file has no such line
and every account that can sign in to the server can sign in over SSH. On
Server 2025, an account that isn&rsquo;t an administrator gets in only once it&rsquo;s a
member of OpenSSH Users:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Add-LocalGroupMember</span> <span class="n">-Group</span> <span class="s1">&#39;OpenSSH Users&#39;</span> <span class="n">-Member</span> <span class="s1">&#39;deploy&#39;</span></span></span></code></pre></div></div>
  </div>
</div><p>There is no root account, so <code>PermitRootLogin</code> does nothing. To keep
administrators off SSH entirely, deny their group, which leaves OpenSSH Users
as the only way in:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">DenyGroups administrators</span></span></code></pre></div></div>
  </div>
</div><p><code>AllowUsers</code> narrows access further rather than widening it. An account has to
pass <code>AllowUsers</code> and <code>AllowGroups</code> both, so a name listed in <code>AllowUsers</code> that
isn&rsquo;t in either group is still refused.</p>
<p>And once key-based login works for the accounts that need it, turn passwords
off:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">PasswordAuthentication no</span></span></code></pre></div></div>
  </div>
</div><p>Put each new line above the <code>Match Group administrators</code> block at the end of
the file. A line added after it belongs to that block: it applies only to
administrators, or, for a directive the block doesn&rsquo;t allow, stops sshd from
starting.</p>
<p>Restart the service after any change:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Restart-Service</span> <span class="n">-Name</span> <span class="n">sshd</span></span></span></code></pre></div></div>
  </div>
</div><p>Where an administrator&rsquo;s public key has to go is the Windows-specific part; the
related article scripts it.</p>
<!-- style-check: end procedure -->
<h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/enable-ssh-windows-server-2025/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="administrator-gets-permission-denied-publickey">Administrator gets <code>Permission denied (publickey)</code><a class="heading-anchor" href="https://looselydecoupled.com/enable-ssh-windows-server-2025/#administrator-gets-permission-denied-publickey"
     aria-label="Link to section: Administrator gets Permission denied (publickey)" data-pagefind-ignore></a>
</h3>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Permission</span> <span class="console__token">denied</span> <span class="console__token">(publickey)</span></span></code></pre>
    </div>
  </div></div><p>Windows OpenSSH doesn&rsquo;t read <code>~\.ssh\authorized_keys</code> for members of the
Administrators group. The key belongs in
<code>C:\ProgramData\ssh\administrators_authorized_keys</code>, with inheritance removed
and only Administrators and SYSTEM granted access.</p>
<h3 id="sshd-stopped-while-the-toggle-says-on"><code>sshd</code> stopped while the toggle says on<a class="heading-anchor" href="https://looselydecoupled.com/enable-ssh-windows-server-2025/#sshd-stopped-while-the-toggle-says-on"
     aria-label="Link to section: sshd stopped while the toggle says on" data-pagefind-ignore></a>
</h3>
<p>Refresh Server Manager. If they still disagree, the service failed to start,
and the usual cause is a broken edit to <code>sshd_config</code>. Event Viewer won&rsquo;t say
so: a broken file leaves the OpenSSH <strong>Admin</strong> and <strong>Operational</strong> channels
empty, and the System log records only that the service terminated
unexpectedly. Check the file without starting anything:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="n">sshd</span> <span class="n">-t</span></span></span></code></pre></div></div>
  </div>
</div><p>It prints the line number and what is wrong with it, and nothing when the file
is valid.</p>
<h3 id="only-administrators-can-sign-in">Only administrators can sign in<a class="heading-anchor" href="https://looselydecoupled.com/enable-ssh-windows-server-2025/#only-administrators-can-sign-in"
     aria-label="Link to section: Only administrators can sign in" data-pagefind-ignore></a>
</h3>
<p>Even after adding an account to OpenSSH Users, on a non-English installation
the group can carry a translated name, such as <code>OpenSSH-Benutzer</code> in German,
while <code>sshd_config</code> still names <code>openssh users</code>. Find the local name, put it in
the <code>AllowGroups</code> line in lower case, quoted if it contains a space, and
restart sshd:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Get-LocalGroup</span> <span class="p">|</span> <span class="nb">Where-Object</span> <span class="n">Name</span> <span class="o">-like</span> <span class="s1">&#39;*SSH*&#39;</span></span></span></code></pre></div></div>
  </div>
</div><h3 id="connection-times-out-with-the-service-running">Connection times out with the service running<a class="heading-anchor" href="https://looselydecoupled.com/enable-ssh-windows-server-2025/#connection-times-out-with-the-service-running"
     aria-label="Link to section: Connection times out with the service running" data-pagefind-ignore></a>
</h3>
<p>The rule exists but is disabled, or doesn&rsquo;t cover the network profile the
server is on. The firewall drops the packets, so the client waits and times
out rather than being refused:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Get-NetFirewallRule</span> <span class="n">-Name</span> <span class="s1">&#39;OpenSSH-Server-In-TCP&#39;</span> <span class="p">|</span> <span class="nb">Format-Table</span> <span class="n">Name</span><span class="p">,</span> <span class="n">Enabled</span><span class="p">,</span> <span class="n">Profile</span></span></span></code></pre></div></div>
  </div>
</div><p><code>Enabled</code> should read <code>True</code>, and <code>Profile</code> must include the profile the
server is on; the firewall step earlier covers both.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>OS</dt> <dd>Windows Server 2025 Datacenter (build 26100.33438), OpenSSH.Server removed and added again for a clean install</dd></div>
    <div class="tested__row"><dt>OpenSSH</dt> <dd>9.5p2</dd></div>
    <div class="tested__row"><dt>Client</dt> <dd>OpenSSH on macOS 27</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
    <item>
      <title>Configure SSH Key Authentication for an Administrator on Windows Server with PowerShell</title>
      <link>https://looselydecoupled.com/windows-server-ssh-key-authentication-powershell/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/windows-server-ssh-key-authentication-powershell/</guid>
      <pubDate>Sun, 05 May 2024 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Windows Server</category>
      <category>SSH</category>
      <category>PowerShell</category>
      <category>Automation</category>
      
      <description>Windows OpenSSH ignores an administrator&#39;s authorized_keys. Put the key in administrators_authorized_keys with the right ACL, scripted for a fresh server.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>For any account in the local Administrators group, the public key goes in one
shared file, and that file must be accessible only by Administrators and SYSTEM.
In an elevated PowerShell session on the server, with your public key in
<code>$publicKey</code>:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$authorizedKeys</span> <span class="p">=</span> <span class="s2">&#34;</span><span class="nv">$env:ProgramData</span><span class="s2">\ssh\administrators_authorized_keys&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">Add-Content</span> <span class="n">-Path</span> <span class="nv">$authorizedKeys</span> <span class="n">-Value</span> <span class="nv">$publicKey</span> <span class="n">-Encoding</span> <span class="n">ascii</span>
</span></span><span class="line"><span class="cl"><span class="n">icacls</span><span class="p">.</span><span class="py">exe</span> <span class="nv">$authorizedKeys</span> <span class="p">/</span><span class="n">inheritance</span><span class="err">:</span><span class="nb">r </span><span class="p">/</span><span class="n">grant</span> <span class="s1">&#39;Administrators:F&#39;</span> <span class="p">/</span><span class="n">grant</span> <span class="s1">&#39;SYSTEM:F&#39;</span></span></span></code></pre></div></div>
  </div>
</div><p>Then from the client:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ssh</span> <span class="console__token">-i</span> <span class="console__token">~/.ssh/id_ed25519</span> <span class="console__token">Administrator@server-address</span></span></code></pre>
    </div>
  </div></div><p>No password prompt means it worked. A non-administrator account uses
<code>~\.ssh\authorized_keys</code> as on any other OpenSSH server and needs none of this.</p>

</div>

<p>The OpenSSH port for Windows makes one deliberate departure from the
<code>sshd_config</code> defaults you know. Its stock configuration ends with:</p>
<div class="code-block" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Match Group administrators
</span></span><span class="line"><span class="cl">       AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys</span></span></code></pre></div></div>
  </div>
</div><p>So for an administrator, the per-user <code>authorized_keys</code> is never consulted, and
<code>ssh-copy-id</code> — which doesn&rsquo;t exist on Windows anyway — would put the key in
a file sshd never reads. The key goes in the shared file, and sshd ignores that
file without saying why if an ordinary user can write to it.</p>
<!-- style-check: procedure -->
<h2 id="generate-a-key-pair-on-the-client">Generate a key pair on the client<a class="heading-anchor" href="https://looselydecoupled.com/windows-server-ssh-key-authentication-powershell/#generate-a-key-pair-on-the-client"
     aria-label="Link to section: Generate a key pair on the client" data-pagefind-ignore></a>
</h2>
<p>If you do not already have one:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ssh-keygen</span> <span class="console__token">-t</span> <span class="console__token">ed25519</span></span></code></pre>
    </div>
  </div></div><p>Take the defaults. Key formats, <code>ssh-keygen</code> and the rest of the client side
are unmodified OpenSSH, so a key you already use elsewhere works here too. The
public half is <code>~/.ssh/id_ed25519.pub</code>, one line beginning <code>ssh-ed25519</code>.
That line is what the server needs; the private half never leaves the client.</p>
<h2 id="get-the-public-key-onto-the-server">Get the public key onto the server<a class="heading-anchor" href="https://looselydecoupled.com/windows-server-ssh-key-authentication-powershell/#get-the-public-key-onto-the-server"
     aria-label="Link to section: Get the public key onto the server" data-pagefind-ignore></a>
</h2>
<p>Pick whichever route reaches the server. In a password-authenticated SSH
session, or an RDP session, paste the one line into a variable on the server:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="c"># Paste the whole public key line between the quotes.</span>
</span></span><span class="line"><span class="cl"><span class="nv">$publicKey</span> <span class="p">=</span> <span class="s1">&#39;ssh-ed25519 AAAA... you@laptop&#39;</span></span></span></code></pre></div></div>
  </div>
</div><p>Or, from the client, copy the line in one step, which avoids pasting a long
line through a terminal:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">scp</span> <span class="console__token">~/.ssh/id_ed25519.pub</span> <span class="console__token">Administrator@server-address:id_ed25519.pub</span></span></code></pre>
    </div>
  </div></div><p>and on the server:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$publicKey</span> <span class="p">=</span> <span class="nb">Get-Content</span> <span class="s2">&#34;</span><span class="nv">$env:USERPROFILE</span><span class="s2">\id_ed25519.pub&#34;</span></span></span></code></pre></div></div>
  </div>
</div><h2 id="write-the-key-and-fix-the-permissions">Write the key and fix the permissions<a class="heading-anchor" href="https://looselydecoupled.com/windows-server-ssh-key-authentication-powershell/#write-the-key-and-fix-the-permissions"
     aria-label="Link to section: Write the key and fix the permissions" data-pagefind-ignore></a>
</h2>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$sshDir</span>        <span class="p">=</span> <span class="s2">&#34;</span><span class="nv">$env:ProgramData</span><span class="s2">\ssh&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$authorizedKeys</span> <span class="p">=</span> <span class="s2">&#34;</span><span class="nv">$sshDir</span><span class="s2">\administrators_authorized_keys&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">-not</span> <span class="p">(</span><span class="nb">Test-Path</span> <span class="nv">$sshDir</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">New-Item</span> <span class="n">-ItemType</span> <span class="n">Directory</span> <span class="n">-Path</span> <span class="nv">$sshDir</span> <span class="p">|</span> <span class="nb">Out-Null</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">Add-Content</span> <span class="n">-Path</span> <span class="nv">$authorizedKeys</span> <span class="n">-Value</span> <span class="nv">$publicKey</span> <span class="n">-Encoding</span> <span class="n">ascii</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">icacls</span><span class="p">.</span><span class="py">exe</span> <span class="nv">$authorizedKeys</span> <span class="p">/</span><span class="n">inheritance</span><span class="err">:</span><span class="nb">r </span><span class="p">/</span><span class="n">grant</span> <span class="s1">&#39;Administrators:F&#39;</span> <span class="p">/</span><span class="n">grant</span> <span class="s1">&#39;SYSTEM:F&#39;</span></span></span></code></pre></div></div>
  </div>
</div><p><code>Add-Content</code> creates the file if it is missing and appends if it is not, so
the same lines add a second administrator&rsquo;s key later. <code>-Encoding ascii</code> is
not decoration: see the first common failure.</p>
<p><code>/inheritance:r</code> strips the inherited entries. The two <code>/grant</code> entries put
back the only two principals Microsoft&rsquo;s documentation allows on this file.
sshd&rsquo;s own permission check is looser, and rejects the file only when another
account has write access. Confirm:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="n">icacls</span><span class="p">.</span><span class="py">exe</span> <span class="nv">$authorizedKeys</span></span></span></code></pre></div></div>
  </div>
</div><p>The output should list exactly <code>BUILTIN\Administrators:(F)</code> and
<code>NT AUTHORITY\SYSTEM:(F)</code>.</p>
<h2 id="test-from-the-client">Test from the client<a class="heading-anchor" href="https://looselydecoupled.com/windows-server-ssh-key-authentication-powershell/#test-from-the-client"
     aria-label="Link to section: Test from the client" data-pagefind-ignore></a>
</h2>
<p>sshd reads the keys file at every sign-in, so no restart is needed. From the
client:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ssh</span> <span class="console__token">-i</span> <span class="console__token">~/.ssh/id_ed25519</span> <span class="console__token">Administrator@server-address</span></span></code></pre>
    </div>
  </div></div><!-- style-check: end procedure -->
<h2 id="expected-result">Expected result<a class="heading-anchor" href="https://looselydecoupled.com/windows-server-ssh-key-authentication-powershell/#expected-result"
     aria-label="Link to section: Expected result" data-pagefind-ignore></a>
</h2>
<p>You&rsquo;re signed in without a password prompt. Once that holds for every account
that needs access, turn password authentication off in
<code>C:\ProgramData\ssh\sshd_config</code>:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">PasswordAuthentication no</span></span></code></pre></div></div>
  </div>
</div><p>and restart the service, which reads <code>sshd_config</code> only when it starts. Leave
password authentication on until the key login is confirmed, or you&rsquo;ll lock
yourself out of the server you&rsquo;re configuring.</p>

<h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/windows-server-ssh-key-authentication-powershell/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="login-still-asks-for-a-password">Login still asks for a password<a class="heading-anchor" href="https://looselydecoupled.com/windows-server-ssh-key-authentication-powershell/#login-still-asks-for-a-password"
     aria-label="Link to section: Login still asks for a password" data-pagefind-ignore></a>
</h3>
<p>The key is in the file and the ACL is right, but the file isn&rsquo;t plain ASCII.
In Windows PowerShell 5.1, <code>&gt;</code> and <code>Out-File</code> write UTF-16 with a byte-order
mark, and sshd can&rsquo;t parse a key that starts with one. Delete the file and
write it again with <code>Add-Content -Encoding ascii</code> or <code>Set-Content -Encoding ascii</code>. Inspect a suspect file: a good file starts <code>73 73 68</code> (<code>ssh</code>), a bad
one starts <code>FF FE</code>.</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Format-Hex</span> <span class="n">-Path</span> <span class="nv">$authorizedKeys</span> <span class="p">|</span> <span class="nb">Select-Object</span> <span class="n">-First</span> <span class="mf">1</span></span></span></code></pre></div></div>
  </div>
</div><h3 id="acl-shows-extra-principals-after-icacls">ACL shows extra principals after <code>icacls</code><a class="heading-anchor" href="https://looselydecoupled.com/windows-server-ssh-key-authentication-powershell/#acl-shows-extra-principals-after-icacls"
     aria-label="Link to section: ACL shows extra principals after icacls" data-pagefind-ignore></a>
</h3>
<p>Inheritance wasn&rsquo;t removed before granting. Run the <code>/inheritance:r</code> form
earlier, which does both steps in one call, and check again.</p>
<h3 id="icacls-reports-no-mapping-for-an-account-name"><code>icacls</code> reports no mapping for an account name<a class="heading-anchor" href="https://looselydecoupled.com/windows-server-ssh-key-authentication-powershell/#icacls-reports-no-mapping-for-an-account-name"
     aria-label="Link to section: icacls reports no mapping for an account name" data-pagefind-ignore></a>
</h3>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">No</span> <span class="console__token">mapping</span> <span class="console__token">between</span> <span class="console__token">account</span> <span class="console__token">names</span> <span class="console__token">and</span> <span class="console__token">security</span> <span class="console__token">IDs</span> <span class="console__token">was</span> <span class="console__token">done.</span></span></code></pre>
    </div>
  </div></div><p>The Windows installation isn&rsquo;t in English, and the Administrators group has a
localized name. Grant it by SID instead, with <code>/grant '*S-1-5-32-544:F'</code> in
place of <code>/grant 'Administrators:F'</code>.</p>
<h3 id="works-for-one-account-and-not-another">Works for one account and not another<a class="heading-anchor" href="https://looselydecoupled.com/windows-server-ssh-key-authentication-powershell/#works-for-one-account-and-not-another"
     aria-label="Link to section: Works for one account and not another" data-pagefind-ignore></a>
</h3>
<p>The second account isn&rsquo;t an administrator. For it, the key belongs in that
user&rsquo;s own <code>C:\Users\&lt;name&gt;\.ssh\authorized_keys</code>, and the shared file is
irrelevant.</p>
<h3 id="client-output-doesnt-explain-the-refusal">Client output doesn&rsquo;t explain the refusal<a class="heading-anchor" href="https://looselydecoupled.com/windows-server-ssh-key-authentication-powershell/#client-output-doesnt-explain-the-refusal"
     aria-label="Link to section: Client output doesn’t explain the refusal" data-pagefind-ignore></a>
</h3>
<p>Ask the server. When the ACL is the cause, Server 2025 logs it under
<strong>Applications and Services Logs &gt; OpenSSH &gt; Operational</strong>, naming the file and
the account that can write to it. Server 2022&rsquo;s OpenSSH 8.1 logs only
&ldquo;Authentication refused&rdquo; there; the line naming the file goes to the
<strong>OpenSSH &gt; Debug</strong> channel, which is off by default. An encoding fault logs nothing there, so check the bytes
as described earlier. <code>ssh -v</code> on the client shows only that the key was
offered and declined.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>OS</dt> <dd>Windows Server 2025 Datacenter (26100.33438) and Windows Server 2022 Datacenter (20348.5622)</dd></div>
    <div class="tested__row"><dt>OpenSSH</dt> <dd>9.5p2 on 2025, 8.1p1 on 2022</dd></div>
    <div class="tested__row"><dt>Client</dt> <dd>OpenSSH on macOS 27</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
    <item>
      <title>How to Enable SSH on Windows Server 2022</title>
      <link>https://looselydecoupled.com/enable-ssh-windows-server-2022/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/enable-ssh-windows-server-2022/</guid>
      <pubDate>Sat, 04 May 2024 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Windows Server</category>
      <category>SSH</category>
      <category>PowerShell</category>
      <category>Automation</category>
      
      <description>Install the OpenSSH server with PowerShell, start sshd at boot, confirm the port 22 rule, and connect, plus where an administrator&#39;s key really goes.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>In an elevated PowerShell session on the server:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Add-WindowsCapability</span> <span class="n">-Online</span> <span class="n">-Name</span> <span class="n">OpenSSH</span><span class="p">.</span><span class="n">Server</span><span class="p">~~~~</span><span class="mf">0.0</span><span class="p">.</span><span class="py">1</span><span class="p">.</span><span class="py">0</span>
</span></span><span class="line"><span class="cl"><span class="nb">Set-Service</span> <span class="n">-Name</span> <span class="n">sshd</span> <span class="n">-StartupType</span> <span class="n">Automatic</span>
</span></span><span class="line"><span class="cl"><span class="nb">Start-Service</span> <span class="n">-Name</span> <span class="n">sshd</span>
</span></span><span class="line"><span class="cl"><span class="nb">Get-NetFirewallRule</span> <span class="n">-Name</span> <span class="s1">&#39;OpenSSH-Server-In-TCP&#39;</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Format-Table</span> <span class="n">Name</span><span class="p">,</span> <span class="n">Enabled</span><span class="p">,</span> <span class="n">Direction</span><span class="p">,</span> <span class="n">Action</span></span></span></code></pre></div></div>
  </div>
</div><p>Then from any machine with an SSH client:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ssh</span> <span class="console__token">Administrator@server-address</span></span></code></pre>
    </div>
  </div></div><p>Sign in with the Windows account&rsquo;s password. Key-based login for an
administrator has one Windows-specific rule, covered under common failures.</p>

</div>

<p>Windows Server 2022 ships the OpenSSH server as an optional capability, not as
an installed service. Adding it takes one command; the rest of the work is
making sure it starts on boot and that the firewall lets the connection in.
Everything below runs in a PowerShell session opened as Administrator.</p>
<!-- style-check: procedure -->
<h2 id="install-the-openssh-server-capability">Install the OpenSSH server capability<a class="heading-anchor" href="https://looselydecoupled.com/enable-ssh-windows-server-2022/#install-the-openssh-server-capability"
     aria-label="Link to section: Install the OpenSSH server capability" data-pagefind-ignore></a>
</h2>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Add-WindowsCapability</span> <span class="n">-Online</span> <span class="n">-Name</span> <span class="n">OpenSSH</span><span class="p">.</span><span class="n">Server</span><span class="p">~~~~</span><span class="mf">0.0</span><span class="p">.</span><span class="py">1</span><span class="p">.</span><span class="mf">0</span></span></span></code></pre></div></div>
  </div>
</div><p>The four tildes are part of the name. The command downloads the capability
from Windows Update, so the server needs a route to it, or a configured
alternate source for features on demand.</p>
<p>Confirm it landed:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Get-WindowsCapability</span> <span class="n">-Online</span> <span class="p">|</span> <span class="nb">Where-Object</span> <span class="n">Name</span> <span class="o">-like</span> <span class="s1">&#39;OpenSSH*&#39;</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Format-Table</span> <span class="n">Name</span><span class="p">,</span> <span class="n">State</span></span></span></code></pre></div></div>
  </div>
</div><p><code>OpenSSH.Server</code> should show <code>Installed</code>. If <code>OpenSSH.Client</code> shows
<code>NotPresent</code> and you want it on the server too, add it the same way with
<code>OpenSSH.Client~~~~0.0.1.0</code>.</p>
<p>The same install is available without a shell under <strong>Settings &gt; Apps &gt; Apps &amp;
features &gt; Optional features &gt; Add a feature</strong>, searching for &ldquo;OpenSSH Server&rdquo;. It is the
same capability, so the commands that follow apply either way.</p>
<h2 id="start-sshd-and-make-it-start-at-boot">Start sshd and make it start at boot<a class="heading-anchor" href="https://looselydecoupled.com/enable-ssh-windows-server-2022/#start-sshd-and-make-it-start-at-boot"
     aria-label="Link to section: Start sshd and make it start at boot" data-pagefind-ignore></a>
</h2>
<p>The install registers the <code>sshd</code> service but doesn&rsquo;t start it or set it to
start at boot. Set the start type first, so a reboot before you finish does not
leave the server unreachable:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Set-Service</span> <span class="n">-Name</span> <span class="n">sshd</span> <span class="n">-StartupType</span> <span class="n">Automatic</span>
</span></span><span class="line"><span class="cl"><span class="nb">Start-Service</span> <span class="n">-Name</span> <span class="n">sshd</span>
</span></span><span class="line"><span class="cl"><span class="nb">Get-Service</span> <span class="n">-Name</span> <span class="n">sshd</span></span></span></code></pre></div></div>
  </div>
</div><p><code>Status</code> should read <code>Running</code>. The first start generates the host keys under
<code>C:\ProgramData\ssh\</code>, which is also where <code>sshd_config</code> lives from now on.</p>
<h2 id="confirm-the-firewall-rule">Confirm the firewall rule<a class="heading-anchor" href="https://looselydecoupled.com/enable-ssh-windows-server-2022/#confirm-the-firewall-rule"
     aria-label="Link to section: Confirm the firewall rule" data-pagefind-ignore></a>
</h2>
<p>Installing the capability with <code>Add-WindowsCapability</code> normally creates and
enables an inbound rule named <code>OpenSSH-Server-In-TCP</code> for port 22. Check rather than
assume:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Get-NetFirewallRule</span> <span class="n">-Name</span> <span class="s1">&#39;OpenSSH-Server-In-TCP&#39;</span> <span class="n">-ErrorAction</span> <span class="n">SilentlyContinue</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Format-Table</span> <span class="n">Name</span><span class="p">,</span> <span class="n">Enabled</span><span class="p">,</span> <span class="n">Direction</span><span class="p">,</span> <span class="n">Action</span></span></span></code></pre></div></div>
  </div>
</div><p>If the rule is missing, create it:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">New-NetFirewallRule</span> <span class="n">-Name</span> <span class="s1">&#39;OpenSSH-Server-In-TCP&#39;</span> <span class="n">-DisplayName</span> <span class="s1">&#39;OpenSSH Server (sshd)&#39;</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-Enabled</span> <span class="n">True</span> <span class="n">-Direction</span> <span class="n">Inbound</span> <span class="n">-Protocol</span> <span class="n">TCP</span> <span class="n">-Action</span> <span class="n">Allow</span> <span class="n">-LocalPort</span> <span class="mf">22</span></span></span></code></pre></div></div>
  </div>
</div><p>Run it only when the check finds nothing. Rule names are unique, so
<code>New-NetFirewallRule</code> fails if <code>OpenSSH-Server-In-TCP</code> already exists.</p>
<h2 id="connect-from-a-client">Connect from a client<a class="heading-anchor" href="https://looselydecoupled.com/enable-ssh-windows-server-2022/#connect-from-a-client"
     aria-label="Link to section: Connect from a client" data-pagefind-ignore></a>
</h2>
<p>From another machine, using its own OpenSSH client (macOS, Linux, and Windows
10 version 1809 or later all have one):</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ssh</span> <span class="console__token">Administrator@server-address</span></span></code></pre>
    </div>
  </div></div><p>Accept the host key on first connection and enter the account&rsquo;s Windows
password. Windows OpenSSH is a port of the same OpenSSH that ships on Linux and
macOS, so the client and its keys behave the way you already expect.
Authentication is the difference: sshd hands it to Windows. The default
<code>sshd_config</code> has no <code>AllowUsers</code> or <code>AllowGroups</code> line, so any local or
domain account Windows accepts can sign in. SSH keeps no user list of its own
until you add one.</p>
<!-- style-check: end procedure -->
<h2 id="expected-result">Expected result<a class="heading-anchor" href="https://looselydecoupled.com/enable-ssh-windows-server-2022/#expected-result"
     aria-label="Link to section: Expected result" data-pagefind-ignore></a>
</h2>
<p>You land in a <code>cmd.exe</code> prompt on the server. That&rsquo;s the default shell for
Windows OpenSSH, not a sign that something went wrong. To get PowerShell
instead, set the default shell once on the server:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">New-ItemProperty</span> <span class="n">-Path</span> <span class="s1">&#39;HKLM:\SOFTWARE\OpenSSH&#39;</span> <span class="n">-Name</span> <span class="n">DefaultShell</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-PropertyType</span> <span class="n">String</span> <span class="n">-Force</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-Value</span> <span class="s1">&#39;C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe&#39;</span></span></span></code></pre></div></div>
  </div>
</div><p>Point it at <code>pwsh.exe</code> instead if PowerShell 7 is installed and that&rsquo;s what
you want to arrive in.</p>

<h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/enable-ssh-windows-server-2022/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="administrator-gets-permission-denied-publickey">Administrator gets <code>Permission denied (publickey)</code><a class="heading-anchor" href="https://looselydecoupled.com/enable-ssh-windows-server-2022/#administrator-gets-permission-denied-publickey"
     aria-label="Link to section: Administrator gets Permission denied (publickey)" data-pagefind-ignore></a>
</h3>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Permission</span> <span class="console__token">denied</span> <span class="console__token">(publickey)</span></span></code></pre>
    </div>
  </div></div><p>The key is in <code>~\.ssh\authorized_keys</code>, and for any account in the local
Administrators group Windows OpenSSH ignores that file entirely. The key has to
be in <code>C:\ProgramData\ssh\administrators_authorized_keys</code>, and sshd refuses
that file unless only SYSTEM and Administrators have access to it. The client
is never told why; the reason goes to the server&rsquo;s OpenSSH event log. The
procedure, scripted, is in <a href="https://looselydecoupled.com/windows-server-ssh-key-authentication-powershell/">the article on key authentication for an
administrator</a>.
Non-administrator accounts use <code>~\.ssh\authorized_keys</code> as usual.</p>
<h3 id="connection-refused-or-timing-out">Connection refused or timing out<a class="heading-anchor" href="https://looselydecoupled.com/enable-ssh-windows-server-2022/#connection-refused-or-timing-out"
     aria-label="Link to section: Connection refused or timing out" data-pagefind-ignore></a>
</h3>
<p>A stopped service usually gives a refusal. A missing firewall rule usually
gives a timeout, because the firewall drops the packets. <code>Get-Service sshd</code> and
the firewall check earlier tell you which. If both look right, confirm
something is listening:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Get-NetTCPConnection</span> <span class="n">-LocalPort</span> <span class="mf">22</span> <span class="n">-State</span> <span class="n">Listen</span></span></span></code></pre></div></div>
  </div>
</div><h3 id="add-windowscapability-fails-with-0x800f0954"><code>Add-WindowsCapability</code> fails with 0x800f0954<a class="heading-anchor" href="https://looselydecoupled.com/enable-ssh-windows-server-2022/#add-windowscapability-fails-with-0x800f0954"
     aria-label="Link to section: Add-WindowsCapability fails with 0x800f0954" data-pagefind-ignore></a>
</h3>
<p>The server can&rsquo;t reach Windows Update, or can&rsquo;t find the source. The usual
cause is WSUS: Server 2022 can&rsquo;t install OpenSSH from WSUS at all. One fix is
the Group Policy setting <strong>Specify settings for optional component installation
and component repair</strong>, under Computer Configuration &gt; Administrative Templates</p>
<blockquote>
  <p>System. Enable it and select <strong>Download repair content and optional features
directly from Windows Update instead of Windows Server Update Services</strong>. The
other is to mount the Server 2022 Features on Demand ISO and pass its drive to
<code>-Source</code>. The same fix applies to 0x800F0950 and the 0x8024xxxx connection
errors.</p>

</blockquote><h3 id="ssh-hangs-after-the-password"><code>ssh</code> hangs after the password<a class="heading-anchor" href="https://looselydecoupled.com/enable-ssh-windows-server-2022/#ssh-hangs-after-the-password"
     aria-label="Link to section: ssh hangs after the password" data-pagefind-ignore></a>
</h3>
<p>Every command works, but the session never opens. Check Event Viewer under
<strong>Applications and Services Logs &gt; OpenSSH &gt; Operational</strong>, which records each
sign-in and refusal. Don&rsquo;t wait for the <strong>Admin</strong> channel: it stays empty even
when sshd can&rsquo;t start. For that case, <code>sshd -t</code> names the broken line in
<code>sshd_config</code>, and prints nothing when the file is valid.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>OS</dt> <dd>Windows Server 2022 Datacenter (build 20348.5622)</dd></div>
    <div class="tested__row"><dt>OpenSSH</dt> <dd>8.1p1 server, from the OpenSSH.Server capability</dd></div>
    <div class="tested__row"><dt>Client</dt> <dd>OpenSSH on macOS 27</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
    <item>
      <title>How to Publish a Custom CloudWatch Metric from PowerShell</title>
      <link>https://looselydecoupled.com/custom-cloudwatch-metrics-powershell/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/custom-cloudwatch-metrics-powershell/</guid>
      <pubDate>Sun, 26 Nov 2023 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>AWS</category>
      <category>PowerShell</category>
      <category>Automation</category>
      
      <description>Send data from your PowerShell scripts to CloudWatch to graph and alarm on, with one cmdlet that runs on Windows, macOS or Linux.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="c"># Install once per machine; later sessions only need Import-Module.</span>
</span></span><span class="line"><span class="cl"><span class="nb">Install-Module</span> <span class="n">-Name</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="py">CloudWatch</span> <span class="n">-Scope</span> <span class="n">CurrentUser</span> <span class="n">-Force</span>
</span></span><span class="line"><span class="cl"><span class="nb">Import-Module</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="py">CloudWatch</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">$datum</span> <span class="p">=</span> <span class="nb">New-Object</span> <span class="n">Amazon</span><span class="p">.</span><span class="py">CloudWatch</span><span class="p">.</span><span class="py">Model</span><span class="p">.</span><span class="py">MetricDatum</span>
</span></span><span class="line"><span class="cl"><span class="nv">$datum</span><span class="p">.</span><span class="py">MetricName</span> <span class="p">=</span> <span class="s1">&#39;QueueDepth&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$datum</span><span class="p">.</span><span class="py">Value</span>      <span class="p">=</span> <span class="mf">42</span>
</span></span><span class="line"><span class="cl"><span class="nv">$datum</span><span class="p">.</span><span class="py">Unit</span>       <span class="p">=</span> <span class="s1">&#39;Count&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$datum</span><span class="p">.</span><span class="py">Timestamp</span>  <span class="p">=</span> <span class="p">(</span><span class="nb">Get-Date</span><span class="p">).</span><span class="py">ToUniversalTime</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">$hostDimension</span> <span class="p">=</span> <span class="nb">New-Object</span> <span class="n">Amazon</span><span class="p">.</span><span class="py">CloudWatch</span><span class="p">.</span><span class="py">Model</span><span class="p">.</span><span class="py">Dimension</span>
</span></span><span class="line"><span class="cl"><span class="nv">$hostDimension</span><span class="p">.</span><span class="py">Name</span>  <span class="p">=</span> <span class="s1">&#39;Host&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$hostDimension</span><span class="p">.</span><span class="py">Value</span> <span class="p">=</span> <span class="p">[</span><span class="no">Environment</span><span class="p">]::</span><span class="n">MachineName</span>
</span></span><span class="line"><span class="cl"><span class="nv">$datum</span><span class="p">.</span><span class="py">Dimensions</span> <span class="p">=</span> <span class="vm">@</span><span class="p">(</span><span class="nv">$hostDimension</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">Write-CWMetricData</span> <span class="n">-Namespace</span> <span class="s1">&#39;MyApp/Queue&#39;</span> <span class="n">-MetricData</span> <span class="nv">$datum</span></span></span></code></pre></div></div>
  </div>
</div><p>The metric appears under <strong>Metrics &gt; All metrics &gt; MyApp/Queue</strong> in the
CloudWatch console once CloudWatch has indexed it, which can take some
minutes. The namespace is yours to choose;
don&rsquo;t begin it with <code>AWS/</code>, which AWS services use.</p>

</div>

<p>A custom metric is any number you care about, sent to CloudWatch so you can
graph it and alarm on it like a built-in metric: a queue depth, the number of
files waiting in a drop folder, or the age of the newest backup. In PowerShell, you put the number in a <code>MetricDatum</code> object and pass it to
<code>Write-CWMetricData</code>.</p>
<!-- style-check: procedure -->
<h2 id="install-the-cloudwatch-module">Install the CloudWatch module<a class="heading-anchor" href="https://looselydecoupled.com/custom-cloudwatch-metrics-powershell/#install-the-cloudwatch-module"
     aria-label="Link to section: Install the CloudWatch module" data-pagefind-ignore></a>
</h2>
<p><code>AWS.Tools</code> ships one module per AWS service, so you install only the
CloudWatch one. It pulls in <code>AWS.Tools.Common</code> on its own:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Install-Module</span> <span class="n">-Name</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="py">CloudWatch</span> <span class="n">-Scope</span> <span class="n">CurrentUser</span> <span class="n">-Force</span>
</span></span><span class="line"><span class="cl"><span class="nb">Import-Module</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="n">CloudWatch</span></span></span></code></pre></div></div>
  </div>
</div><p>When installing from PSGallery, <code>-Force</code> skips the &ldquo;Untrusted repository&rdquo;
prompt.</p>
<p>The AWS Tools look for credentials in a profile, in environment variables, or
in the instance&rsquo;s role when the script runs on EC2. Whichever identity they
find needs permission for <code>cloudwatch:PutMetricData</code>, the API call behind
<code>Write-CWMetricData</code>. CloudWatch is regional, so the tools also need a region:
from the profile, the <code>AWS_REGION</code> environment variable, <code>Set-DefaultAWSRegion</code>,
or <code>-Region</code> on the cmdlet itself.</p>
<h2 id="build-the-metricdatum">Build the <code>MetricDatum</code><a class="heading-anchor" href="https://looselydecoupled.com/custom-cloudwatch-metrics-powershell/#build-the-metricdatum"
     aria-label="Link to section: Build the MetricDatum" data-pagefind-ignore></a>
</h2>
<p>A <code>MetricDatum</code> holds one data point. <code>MetricName</code> names it and <code>Value</code> is the
number; <code>Unit</code> and <code>Timestamp</code> are optional but worth setting:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$datum</span> <span class="p">=</span> <span class="nb">New-Object</span> <span class="n">Amazon</span><span class="p">.</span><span class="py">CloudWatch</span><span class="p">.</span><span class="py">Model</span><span class="p">.</span><span class="py">MetricDatum</span>
</span></span><span class="line"><span class="cl"><span class="nv">$datum</span><span class="p">.</span><span class="py">MetricName</span> <span class="p">=</span> <span class="s1">&#39;QueueDepth&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$datum</span><span class="p">.</span><span class="py">Value</span>      <span class="p">=</span> <span class="mf">42</span>
</span></span><span class="line"><span class="cl"><span class="nv">$datum</span><span class="p">.</span><span class="py">Unit</span>       <span class="p">=</span> <span class="s1">&#39;Count&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$datum</span><span class="p">.</span><span class="py">Timestamp</span>  <span class="p">=</span> <span class="p">(</span><span class="nb">Get-Date</span><span class="p">).</span><span class="py">ToUniversalTime</span><span class="p">()</span></span></span></code></pre></div></div>
  </div>
</div><p><code>Unit</code> takes one of a fixed list of names, such as <code>Count</code>, <code>Seconds</code>, <code>Bytes</code>
or <code>Percent</code>; <a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_MetricDatum.html" rel="external noopener" target="_blank">MetricDatum in the CloudWatch API Reference<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/custom-cloudwatch-metrics-powershell/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a> lists them
all. PowerShell doesn&rsquo;t check the name when you assign it, so a misspelled unit
fails only when <code>Write-CWMetricData</code> sends it. Pick the unit the number actually
is. CloudWatch never converts between units, and a metric sent as <code>Count</code> one
day and <code>None</code> the next becomes two separate series.</p>
<p>Leave <code>Timestamp</code> off and CloudWatch uses the time the data arrives. Set it
when you measured the number earlier than you send it.</p>
<h2 id="add-a-dimension">Add a dimension<a class="heading-anchor" href="https://looselydecoupled.com/custom-cloudwatch-metrics-powershell/#add-a-dimension"
     aria-label="Link to section: Add a dimension" data-pagefind-ignore></a>
</h2>
<p>A dimension is a name and a value that describe where the number came from.
Without one, every host that sends <code>QueueDepth</code> adds to the same series. With a
<code>Host</code> dimension, each host gets its own series. CloudWatch doesn&rsquo;t add those
series together for you; to see all hosts at once, sum them with metric math,
or send each datum a second time without the dimension.</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$hostDimension</span> <span class="p">=</span> <span class="nb">New-Object</span> <span class="n">Amazon</span><span class="p">.</span><span class="py">CloudWatch</span><span class="p">.</span><span class="py">Model</span><span class="p">.</span><span class="py">Dimension</span>
</span></span><span class="line"><span class="cl"><span class="nv">$hostDimension</span><span class="p">.</span><span class="py">Name</span>  <span class="p">=</span> <span class="s1">&#39;Host&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$hostDimension</span><span class="p">.</span><span class="py">Value</span> <span class="p">=</span> <span class="p">[</span><span class="no">Environment</span><span class="p">]::</span><span class="n">MachineName</span>
</span></span><span class="line"><span class="cl"><span class="nv">$datum</span><span class="p">.</span><span class="py">Dimensions</span> <span class="p">=</span> <span class="vm">@</span><span class="p">(</span><span class="nv">$hostDimension</span><span class="p">)</span></span></span></code></pre></div></div>
  </div>
</div><p>Use <code>[Environment]::MachineName</code> rather than <code>$env:COMPUTERNAME</code>, which is set
only on Windows. On macOS or Linux it&rsquo;s empty, and CloudWatch rejects an empty
dimension value.</p>
<p>Keep dimensions few. CloudWatch bills each distinct combination of dimension
values as its own metric. A dimension whose value changes every time, such as a
request ID or a timestamp, creates a new metric on every run. <a href="https://aws.amazon.com/cloudwatch/pricing/" rel="external noopener" target="_blank">CloudWatch
pricing<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/custom-cloudwatch-metrics-powershell/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a> shows what each custom
metric costs.</p>
<h2 id="send-it-with-write-cwmetricdata">Send it with <code>Write-CWMetricData</code><a class="heading-anchor" href="https://looselydecoupled.com/custom-cloudwatch-metrics-powershell/#send-it-with-write-cwmetricdata"
     aria-label="Link to section: Send it with Write-CWMetricData" data-pagefind-ignore></a>
</h2>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Write-CWMetricData</span> <span class="n">-Namespace</span> <span class="s1">&#39;MyApp/Queue&#39;</span> <span class="n">-MetricData</span> <span class="nv">$datum</span></span></span></code></pre></div></div>
  </div>
</div><p><code>-MetricData</code> takes an array, so a script that measures several things can send
them in one call:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Write-CWMetricData</span> <span class="n">-Namespace</span> <span class="s1">&#39;MyApp/Queue&#39;</span> <span class="n">-MetricData</span> <span class="vm">@</span><span class="p">(</span><span class="nv">$depth</span><span class="p">,</span> <span class="nv">$oldestAge</span><span class="p">,</span> <span class="nv">$workers</span><span class="p">)</span></span></span></code></pre></div></div>
  </div>
</div><p>One call can carry only so many data points and dimensions; <a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_PutMetricData.html" rel="external noopener" target="_blank">PutMetricData in
the CloudWatch API Reference<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/custom-cloudwatch-metrics-powershell/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a> has the current limits.</p>
<p>The cmdlet prints nothing when it succeeds and throws an error when it fails.
In a script that runs unattended, wrap it in <code>try</code>/<code>catch</code>; otherwise the
script carries on as if the number had been recorded.</p>
<!-- style-check: end procedure -->
<h2 id="expected-result">Expected result<a class="heading-anchor" href="https://looselydecoupled.com/custom-cloudwatch-metrics-powershell/#expected-result"
     aria-label="Link to section: Expected result" data-pagefind-ignore></a>
</h2>
<p>In the CloudWatch console, open <strong>Metrics &gt; All metrics</strong>. The <code>MyApp/Queue</code>
namespace is listed, and inside it a <code>Host</code> group with one row for each host
that has sent data. To check from PowerShell instead:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Get-CWMetricList</span> <span class="n">-Namespace</span> <span class="s1">&#39;MyApp/Queue&#39;</span> <span class="p">|</span> <span class="nb">Select-Object</span> <span class="n">MetricName</span><span class="p">,</span> <span class="n">Dimensions</span></span></span></code></pre></div></div>
  </div>
</div><p>A new metric can take a few minutes to appear in that list while CloudWatch
indexes it; the data was still received. <code>Get-CWMetricData</code> can read the data
points before the metric is listed.</p>
<h2 id="why-this-works">Why this works<a class="heading-anchor" href="https://looselydecoupled.com/custom-cloudwatch-metrics-powershell/#why-this-works"
     aria-label="Link to section: Why this works" data-pagefind-ignore></a>
</h2>
<p><code>Write-CWMetricData</code> calls the CloudWatch <code>PutMetricData</code> API. CloudWatch files
each data point under its namespace, metric name and set of dimensions, and
those three together identify one series.</p>
<p>Standard metrics are stored at one-minute resolution. A number sent once a
minute comes back as that number. Numbers sent more often, say every five
seconds, are combined into that minute&rsquo;s sum, count, minimum and maximum. To
keep them separate, set <code>StorageResolution</code> to <code>1</code> for a high-resolution
metric. That costs the same per metric as a standard one; what costs more is
the extra calls and any alarm that checks more often than once a minute.</p>
<h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/custom-cloudwatch-metrics-powershell/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="import-module-cant-find-awstoolscloudwatch"><code>Import-Module</code> can&rsquo;t find <code>AWS.Tools.CloudWatch</code><a class="heading-anchor" href="https://looselydecoupled.com/custom-cloudwatch-metrics-powershell/#import-module-cant-find-awstoolscloudwatch"
     aria-label="Link to section: Import-Module can’t find AWS.Tools.CloudWatch" data-pagefind-ignore></a>
</h3>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Import-Module:</span> <span class="console__token">The</span> <span class="console__token">specified</span> <span class="console__token">module</span> <span class="console__token">&#39;AWS.Tools.CloudWatch&#39;</span> <span class="console__token">was</span> <span class="console__token">not</span> <span class="console__token">loaded</span> <span class="console__token">because</span> <span class="console__token">no</span> <span class="console__token">valid</span> <span class="console__token">module</span> <span class="console__token">file</span> <span class="console__token">was</span> <span class="console__token">found</span> <span class="console__token">in</span> <span class="console__token">any</span> <span class="console__token">module</span> <span class="console__token">directory.</span></span></code></pre>
    </div>
  </div></div><p>The CloudWatch module isn&rsquo;t installed for your account. Installing
<code>AWS.Tools.Installer</code> doesn&rsquo;t install it:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Install-Module</span> <span class="n">-Name</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="py">CloudWatch</span> <span class="n">-Scope</span> <span class="n">CurrentUser</span> <span class="n">-Force</span></span></span></code></pre></div></div>
  </div>
</div><h3 id="new-object-cant-find-the-metricdatum-type"><code>New-Object</code> can&rsquo;t find the <code>MetricDatum</code> type<a class="heading-anchor" href="https://looselydecoupled.com/custom-cloudwatch-metrics-powershell/#new-object-cant-find-the-metricdatum-type"
     aria-label="Link to section: New-Object can’t find the MetricDatum type" data-pagefind-ignore></a>
</h3>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">New-Object:</span> <span class="console__token">Cannot</span> <span class="console__token">find</span> <span class="console__token">type</span> <span class="console__token">[Amazon.CloudWatch.Model.MetricDatum]:</span> <span class="console__token">verify</span> <span class="console__token">that</span> <span class="console__token">the</span> <span class="console__token">assembly</span> <span class="console__token">containing</span> <span class="console__token">this</span> <span class="console__token">type</span> <span class="console__token">is</span> <span class="console__token">loaded.</span>
</span><span class="line"><span class="console__token">InvalidOperation:</span> <span class="console__token">The</span> <span class="console__token">property</span> <span class="console__token">&#39;MetricName&#39;</span> <span class="console__token">cannot</span> <span class="console__token">be</span> <span class="console__token">found</span> <span class="console__token">on</span> <span class="console__token">this</span> <span class="console__token">object.</span> <span class="console__token">Verify</span> <span class="console__token">that</span> <span class="console__token">the</span> <span class="console__token">property</span> <span class="console__token">exists</span> <span class="console__token">and</span> <span class="console__token">can</span> <span class="console__token">be</span> <span class="console__token">set.</span></span></code></pre>
    </div>
  </div></div><p>The module is installed but hasn&rsquo;t been imported yet. PowerShell imports a
module on its own when you call one of its cmdlets, but not when you use one of
its types, and <code>New-Object</code> comes first. Every property error after it has the
same cause: <code>$datum</code> is empty. Import the module before anything else:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Import-Module</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="n">CloudWatch</span></span></span></code></pre></div></div>
  </div>
</div><h3 id="write-cwmetricdata-prints-nothing"><code>Write-CWMetricData</code> prints nothing<a class="heading-anchor" href="https://looselydecoupled.com/custom-cloudwatch-metrics-powershell/#write-cwmetricdata-prints-nothing"
     aria-label="Link to section: Write-CWMetricData prints nothing" data-pagefind-ignore></a>
</h3>
<p>No output means it worked. The cmdlet has no output on success and throws an error
on failure.</p>
<h3 id="metric-not-showing-up">Metric not showing up<a class="heading-anchor" href="https://looselydecoupled.com/custom-cloudwatch-metrics-powershell/#metric-not-showing-up"
     aria-label="Link to section: Metric not showing up" data-pagefind-ignore></a>
</h3>
<p>Give it a few minutes. A new metric takes a while to appear in the list, and a
data point with a timestamp in the past takes longer still. CloudWatch also
accepts timestamps only within a window around the current time. <a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_PutMetricData.html" rel="external noopener" target="_blank">PutMetricData
in the CloudWatch API Reference<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/custom-cloudwatch-metrics-powershell/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
gives the current delays and the window.</p>
<h3 id="graph-not-updating-or-flat-at-the-wrong-value">Graph not updating, or flat at the wrong value<a class="heading-anchor" href="https://looselydecoupled.com/custom-cloudwatch-metrics-powershell/#graph-not-updating-or-flat-at-the-wrong-value"
     aria-label="Link to section: Graph not updating, or flat at the wrong value" data-pagefind-ignore></a>
</h3>
<p>The console graphs the <code>Average</code> over a period longer than a minute. A metric
sent once a minute shows the average of several values, so one new value barely
moves the line. Set the period to one minute, or switch the statistic to
<code>Maximum</code> or <code>Sum</code>, whichever is the number you want.</p>
<h3 id="namespace-keeps-filling-with-new-metrics">Namespace keeps filling with new metrics<a class="heading-anchor" href="https://looselydecoupled.com/custom-cloudwatch-metrics-powershell/#namespace-keeps-filling-with-new-metrics"
     aria-label="Link to section: Namespace keeps filling with new metrics" data-pagefind-ignore></a>
</h3>
<p>A dimension value changes on every run. Look at the dimension values under the
namespace: if the list grows with each run, that dimension is an identifier, not
a category. Put it in a log line instead.</p>
<h3 id="no-credentials">No credentials<a class="heading-anchor" href="https://looselydecoupled.com/custom-cloudwatch-metrics-powershell/#no-credentials"
     aria-label="Link to section: No credentials" data-pagefind-ignore></a>
</h3>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Failed</span> <span class="console__token">to</span> <span class="console__token">resolve</span> <span class="console__token">AWS</span> <span class="console__token">credentials.</span> <span class="console__token">The</span> <span class="console__token">credential</span> <span class="console__token">providers</span> <span class="console__token">used</span> <span class="console__token">to</span> <span class="console__token">search</span> <span class="console__token">for</span> <span class="console__token">credentials</span> <span class="console__token">returned</span> <span class="console__token">the</span> <span class="console__token">following</span> <span class="console__token">errors:</span></span></code></pre>
    </div>
  </div></div><p>PowerShell found no profile, environment variables or instance role, so the
call never reached CloudWatch. Choose a profile for the session, or set the
<code>AWS_PROFILE</code> environment variable:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Set-AWSCredential</span> <span class="n">-ProfileName</span> <span class="p">&lt;</span><span class="n">name</span><span class="p">&gt;</span></span></span></code></pre></div></div>
  </div>
</div><h3 id="no-region">No region<a class="heading-anchor" href="https://looselydecoupled.com/custom-cloudwatch-metrics-powershell/#no-region"
     aria-label="Link to section: No region" data-pagefind-ignore></a>
</h3>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">No</span> <span class="console__token">region</span> <span class="console__token">specified</span> <span class="console__token">or</span> <span class="console__token">obtained</span> <span class="console__token">from</span> <span class="console__token">persisted/shell</span> <span class="console__token">defaults.</span></span></code></pre>
    </div>
  </div></div><p>The credentials were found but no region was. Set one for the session, set
<code>AWS_REGION</code>, or add <code>-Region &lt;region&gt;</code> to <code>Write-CWMetricData</code>:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Set-DefaultAWSRegion</span> <span class="n">-Region</span> <span class="p">&lt;</span><span class="n">region</span><span class="p">&gt;</span></span></span></code></pre></div></div>
  </div>
</div><h3 id="not-authorized-to-call-putmetricdata">Not authorized to call <code>PutMetricData</code><a class="heading-anchor" href="https://looselydecoupled.com/custom-cloudwatch-metrics-powershell/#not-authorized-to-call-putmetricdata"
     aria-label="Link to section: Not authorized to call PutMetricData" data-pagefind-ignore></a>
</h3>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Write-CWMetricData:</span> <span class="console__token">User:</span> <span class="console__token">arn:aws:sts::&lt;account-id&gt;:assumed-role/&lt;role&gt;/&lt;session&gt;</span> <span class="console__token">is</span> <span class="console__token">not</span> <span class="console__token">authorized</span> <span class="console__token">to</span> <span class="console__token">perform:</span> <span class="console__token">cloudwatch:PutMetricData</span> <span class="console__token">because</span> <span class="console__token">no</span> <span class="console__token">identity-based</span> <span class="console__token">policy</span> <span class="console__token">allows</span> <span class="console__token">the</span> <span class="console__token">cloudwatch:PutMetricData</span> <span class="console__token">action</span></span></code></pre>
    </div>
  </div></div><p>The credentials work, but the identity named in the message isn&rsquo;t allowed to
call <code>cloudwatch:PutMetricData</code>. To see that identity, install the
<code>AWS.Tools.SecurityToken</code> module the same way as the CloudWatch one and run:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Get-STSCallerIdentity</span></span></span></code></pre></div></div>
  </div>
</div>]]></content:encoded>
    </item>
    
    <item>
      <title>How to Install Visual Studio Code Silently for All Users with PowerShell</title>
      <link>https://looselydecoupled.com/install-vscode-silently-powershell/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/install-vscode-silently-powershell/</guid>
      <pubDate>Sat, 18 Nov 2023 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Windows Server</category>
      <category>PowerShell</category>
      <category>Automation</category>
      
      <description>Windows Server 2022 has no winget, but VS Code&#39;s own installer takes switches that install it for every account without showing a window.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>In an elevated PowerShell session:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$installer</span> <span class="p">=</span> <span class="nb">Join-Path</span> <span class="nv">$env:TEMP</span> <span class="s1">&#39;VSCodeSetup-x64.exe&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Invoke-WebRequest</span> <span class="n">-OutFile</span> <span class="nv">$installer</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-Uri</span> <span class="s1">&#39;https://code.visualstudio.com/sha/download?build=stable&amp;os=win32-x64&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Start-Process</span> <span class="n">-FilePath</span> <span class="nv">$installer</span> <span class="n">-Wait</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-ArgumentList</span> <span class="s1">&#39;/VERYSILENT&#39;</span><span class="p">,</span> <span class="s1">&#39;/NORESTART&#39;</span><span class="p">,</span> <span class="s1">&#39;/MERGETASKS=!runcode,addtopath&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Remove-Item</span> <span class="nv">$installer</span></span></span></code></pre></div></div>
  </div>
</div><p><code>/VERYSILENT</code> shows nothing, <code>/NORESTART</code> never reboots, <code>!runcode</code> stops the
installer opening VS Code when it finishes, and <code>addtopath</code> puts <code>code</code> on the
system PATH for the next sign-in.</p>

</div>

<p>On a machine that has winget, <code>winget install Microsoft.VisualStudioCode --scope machine --silent</code>
does this job with less to know. Windows Server 2022 doesn&rsquo;t ship winget.
VS Code&rsquo;s Windows installer is an Inno
Setup package, and Inno Setup has had stable command-line switches for
decades. The whole job is fetching the right build, running it with those
switches, and knowing which of its optional tasks to turn on or off.</p>
<!-- style-check: procedure -->
<h2 id="download-the-system-installer">Download the system installer<a class="heading-anchor" href="https://looselydecoupled.com/install-vscode-silently-powershell/#download-the-system-installer"
     aria-label="Link to section: Download the system installer" data-pagefind-ignore></a>
</h2>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$installer</span> <span class="p">=</span> <span class="nb">Join-Path</span> <span class="nv">$env:TEMP</span> <span class="s1">&#39;VSCodeSetup-x64.exe&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Invoke-WebRequest</span> <span class="n">-OutFile</span> <span class="nv">$installer</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-Uri</span> <span class="s1">&#39;https://code.visualstudio.com/sha/download?build=stable&amp;os=win32-x64&#39;</span></span></span></code></pre></div></div>
  </div>
</div><p>That URL is Microsoft&rsquo;s documented direct-download link for the current stable
<strong>system</strong> installer, which puts VS Code under <code>Program Files</code> for every
account on the machine. Change <code>os=win32-x64</code> to <code>os=win32-x64-user</code> for the
per-user installer, which needs no elevation and installs under one account&rsquo;s
<code>%LocalAppData%</code>. For a server or a shared workstation the system build is the
one you want.</p>
<p>On Windows PowerShell 5.1 the download is faster with the progress bar off:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$ProgressPreference</span> <span class="p">=</span> <span class="s1">&#39;SilentlyContinue&#39;</span></span></span></code></pre></div></div>
  </div>
</div><h2 id="run-it-without-a-window">Run it without a window<a class="heading-anchor" href="https://looselydecoupled.com/install-vscode-silently-powershell/#run-it-without-a-window"
     aria-label="Link to section: Run it without a window" data-pagefind-ignore></a>
</h2>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Start-Process</span> <span class="n">-FilePath</span> <span class="nv">$installer</span> <span class="n">-Wait</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-ArgumentList</span> <span class="s1">&#39;/VERYSILENT&#39;</span><span class="p">,</span> <span class="s1">&#39;/NORESTART&#39;</span><span class="p">,</span> <span class="s1">&#39;/MERGETASKS=!runcode,addtopath&#39;</span></span></span></code></pre></div></div>
  </div>
</div><p>Inno Setup reads its switches before it draws anything, so <code>/VERYSILENT</code> with
<code>/NORESTART</code> is a complete unattended install with no answer file. <code>-Wait</code>
matters. Without it the script moves on while the installer is still
running, and anything after — a settings file, an extension install — races
it.</p>
<p>The switches:</p>
<div class="table-wrap" data-scrollable data-label="Table: Switch">
  <table>
    <thead>
      <tr><th scope="col">Switch</th><th scope="col">Effect</th></tr>
    </thead>
    <tbody>
      <tr><td><code>/VERYSILENT</code></td><td>No wizard, no progress window. <code>/SILENT</code> shows progress only.</td></tr>
      <tr><td><code>/NORESTART</code></td><td>Never reboot, even when the installer requests one.</td></tr>
      <tr><td><code>/MERGETASKS=…</code></td><td>Turn the optional tasks on or off; a leading <code>!</code> turns one off.</td></tr>
    </tbody>
  </table>
</div>
<p>The installer offers six tasks. After install, <code>runcode</code> launches the editor,
<code>addtopath</code> edits PATH, and <code>associatewithfiles</code> registers Code as an editor
for supported file types. The other three, <code>desktopicon</code>,
<code>addcontextmenufiles</code> and <code>addcontextmenufolders</code>, add the shortcut and the
&ldquo;Open with Code&rdquo; entries in Explorer. On a headless install <code>runcode</code> leaves an
editor window nobody will ever see, and it is on by default, so turn it off.
Turning <code>runcode</code> off leaves every other task as it was, because <code>/MERGETASKS</code>
applies your changes on top of the installer&rsquo;s defaults. The <code>/TASKS</code> switch
would replace them wholesale.</p>
<h2 id="verify">Verify<a class="heading-anchor" href="https://looselydecoupled.com/install-vscode-silently-powershell/#verify"
     aria-label="Link to section: Verify" data-pagefind-ignore></a>
</h2>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Remove-Item</span> <span class="nv">$installer</span>
</span></span><span class="line"><span class="cl"><span class="nb">Test-Path</span> <span class="s2">&#34;</span><span class="nv">$env:ProgramFiles</span><span class="s2">\Microsoft VS Code\Code.exe&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">&amp;</span> <span class="s2">&#34;</span><span class="nv">$env:ProgramFiles</span><span class="s2">\Microsoft VS Code\bin\code.cmd&#34;</span> <span class="p">-</span><span class="n">-version</span></span></span></code></pre></div></div>
  </div>
</div><p>The version check calls the launcher by path because the PATH change from
<code>addtopath</code> is not visible in the session that ran the installer.</p>
<!-- style-check: end procedure -->
<h2 id="expected-result">Expected result<a class="heading-anchor" href="https://looselydecoupled.com/install-vscode-silently-powershell/#expected-result"
     aria-label="Link to section: Expected result" data-pagefind-ignore></a>
</h2>
<p><code>Code.exe</code> exists under <code>Program Files\Microsoft VS Code</code>, the uninstall entry
reports the version you expected, and <code>code --version</code> works in a new shell.
A new one, because <code>addtopath</code> edits the machine PATH and the session that ran
the installer still has the old one.</p>
<h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/install-vscode-silently-powershell/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="script-hangs-at-start-process">Script hangs at <code>Start-Process</code><a class="heading-anchor" href="https://looselydecoupled.com/install-vscode-silently-powershell/#script-hangs-at-start-process"
     aria-label="Link to section: Script hangs at Start-Process" data-pagefind-ignore></a>
</h3>
<p>The installer is showing a dialog you can&rsquo;t see. Usually VS Code is already
running under this account and Inno Setup is asking whether to close it. Stop
<code>Code.exe</code> first, or add <code>/CLOSEAPPLICATIONS</code>.</p>
<h3 id="access-denied-under-program-files">Access denied under <code>Program Files</code><a class="heading-anchor" href="https://looselydecoupled.com/install-vscode-silently-powershell/#access-denied-under-program-files"
     aria-label="Link to section: Access denied under Program Files" data-pagefind-ignore></a>
</h3>
<p>The session isn&rsquo;t elevated. The system installer needs it; the user installer
doesn&rsquo;t.</p>
<h3 id="code-isnt-recognized-after-the-install"><code>code</code> isn&rsquo;t recognized after the install<a class="heading-anchor" href="https://looselydecoupled.com/install-vscode-silently-powershell/#code-isnt-recognized-after-the-install"
     aria-label="Link to section: code isn’t recognized after the install" data-pagefind-ignore></a>
</h3>
<p>Either <code>addtopath</code> wasn&rsquo;t in <code>/MERGETASKS</code>, or you&rsquo;re in the same shell that
ran the installer. Open a new one. On Windows Server the change reaches
services and scheduled tasks only after they restart.</p>
<h3 id="download-is-a-few-kilobytes-of-html">Download is a few kilobytes of HTML<a class="heading-anchor" href="https://looselydecoupled.com/install-vscode-silently-powershell/#download-is-a-few-kilobytes-of-html"
     aria-label="Link to section: Download is a few kilobytes of HTML" data-pagefind-ignore></a>
</h3>
<p>The download URL redirected to an error page, from a proxy or an outbound rule
blocking the CDN. Check the signature before running it:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Get-AuthenticodeSignature</span> <span class="nv">$installer</span> <span class="p">|</span> <span class="nb">Select-Object</span> <span class="n">Status</span><span class="p">,</span> <span class="n">SignerCertificate</span></span></span></code></pre></div></div>
  </div>
</div><p>The real installer reports <code>Valid</code>, signed by Microsoft Corporation. Anything
else means the file isn&rsquo;t the installer; don&rsquo;t run it.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>OS</dt> <dd>Windows Server 2019 (10.0.17763)</dd></div>
    <div class="tested__row"><dt>PowerShell</dt> <dd>Windows PowerShell 5.1</dd></div>
    <div class="tested__row"><dt>VS Code</dt> <dd>1.139.0 upgraded to 1.140.0</dd></div>
    <div class="tested__row"><dt>Installer</dt> <dd>system, Inno Setup</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
    <item>
      <title>PowerShell Splatting: Replace Long One-Liners with a Hashtable</title>
      <link>https://looselydecoupled.com/splatting-readability-scaling/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/splatting-readability-scaling/</guid>
      <pubDate>Sun, 12 Nov 2023 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>PowerShell</category>
      <category>Code quality</category>
      
      <description>Splatting forces every parameter of a long command to be named and laid out, which keeps it readable and safe to change years after it was written.</description>
      <content:encoded><![CDATA[<p>Did you know a PowerShell command can take its parameters from a hashtable?
The feature is called splatting. It has been in the language since
PowerShell 2.0, nothing ever requires it, and so plenty of scripts are written
without it.</p>
<p>Here is a line it exists to fix. It opens an incident on a status page through
a REST API, with the token and the JSON body already in variables:</p>
<!-- style-check: off -->
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Invoke-RestMethod</span> <span class="n">-Uri</span> <span class="s1">&#39;https://status.example.com/api/incidents&#39;</span> <span class="n">-Method</span> <span class="n">Post</span> <span class="n">-Headers</span> <span class="vm">@</span><span class="p">{</span><span class="n">Authorization</span><span class="p">=</span><span class="s2">&#34;Bearer </span><span class="nv">$token</span><span class="s2">&#34;</span><span class="p">;</span><span class="n">Accept</span><span class="p">=</span><span class="s1">&#39;application/json&#39;</span><span class="p">}</span> <span class="n">-ContentType</span> <span class="s1">&#39;application/json&#39;</span> <span class="n">-Body</span> <span class="nv">$body</span> <span class="n">-TimeoutSec</span> <span class="mf">30</span></span></span></code></pre></div></div>
  </div>
</div><!-- style-check: on -->
<p>It works. It&rsquo;s also a line you have to read three times, and not because it&rsquo;s
wrong. It doesn&rsquo;t scale — not in execution time, in comprehension time. The
computer parses it in microseconds. A person parses it in minutes, even the
person who wrote it.</p>
<p>Splatting turns that line into a block you can read at a glance, and it&rsquo;s
worth using less as a convenience than as a discipline.</p>
<h2 id="six-months-later">Six months later<a class="heading-anchor" href="https://looselydecoupled.com/splatting-readability-scaling/#six-months-later"
     aria-label="Link to section: Six months later" data-pagefind-ignore></a>
</h2>
<p>A script like this is usually written in one sitting, for a job that needs
doing that evening, by someone who knows exactly what every argument is for.
Commands run across several lines on backtick continuations, parameters stack
one after another, a hashtable sits inline.</p>
<p>Six months later the person reading it doesn&rsquo;t know, even when it&rsquo;s the same
person.</p>
<p>The failure isn&rsquo;t correctness. The script still runs. The failure is that a
one-line change, a different endpoint, means re-deriving what the whole line
does before anyone dares touch it.</p>
<p><strong>A script its own author can&rsquo;t debug after six months has a problem, and so
does every script written the same way.</strong></p>
<h2 id="what-makes-a-line-hard-to-read">What makes a line hard to read<a class="heading-anchor" href="https://looselydecoupled.com/splatting-readability-scaling/#what-makes-a-line-hard-to-read"
     aria-label="Link to section: What makes a line hard to read" data-pagefind-ignore></a>
</h2>
<p>Cognitive load is the effort it takes to hold something in your head long
enough to understand it. In a command line it comes from a few places at once.</p>
<p><strong>Syntax you have to parse while reading.</strong> Where does the list of extensions
end? Is that parenthesis inside the script block or after it?</p>
<!-- style-check: off -->
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Get-ChildItem</span> <span class="n">-Path</span> <span class="n">D:</span><span class="p">\</span><span class="n">Exports</span> <span class="n">-Include</span> <span class="p">*.</span><span class="n">csv</span><span class="p">,*.</span><span class="n">json</span> <span class="n">-Exclude</span> <span class="p">*</span><span class="n">-keep</span><span class="p">.*</span> <span class="n">-Recurse</span> <span class="o">-File</span> <span class="p">|</span> <span class="nb">Where-Object</span> <span class="p">{</span> <span class="nv">$_</span><span class="p">.</span><span class="py">LastWriteTime</span> <span class="o">-lt</span> <span class="p">(</span><span class="nb">Get-Date</span><span class="p">).</span><span class="py">AddDays</span><span class="p">(</span><span class="mf">-14</span><span class="p">)</span> <span class="p">}</span> <span class="p">|</span> <span class="nb">Remove-Item</span> <span class="n">-Force</span></span></span></code></pre></div></div>
  </div>
</div><!-- style-check: on -->
<p><strong>Density.</strong> One line means one chunk. The brain can&rsquo;t break it into pieces
and check them separately.</p>
<p><strong>Missing context.</strong> Why <code>D:\Exports</code>? Why fourteen days? What makes a file
worth keeping? None of that is in the code; it&rsquo;s in the head of whoever wrote
it, or in a document somewhere else.</p>
<p>Any one of these is manageable. The one-liner stacks all three.</p>
<h2 id="what-splatting-forces">What splatting forces<a class="heading-anchor" href="https://looselydecoupled.com/splatting-readability-scaling/#what-splatting-forces"
     aria-label="Link to section: What splatting forces" data-pagefind-ignore></a>
</h2>
<p>Splatting takes two steps: put the command&rsquo;s parameters in a hashtable, then
hand the whole table to the command with <code>@</code> in place of <code>$</code>:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$incident</span> <span class="p">=</span> <span class="vm">@</span><span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">Uri</span>         <span class="p">=</span> <span class="s1">&#39;https://status.example.com/api/incidents&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="n">Method</span>      <span class="p">=</span> <span class="s1">&#39;Post&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="n">Headers</span>     <span class="p">=</span> <span class="vm">@</span><span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">Authorization</span> <span class="p">=</span> <span class="s2">&#34;Bearer </span><span class="nv">$token</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="n">Accept</span>        <span class="p">=</span> <span class="s1">&#39;application/json&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="n">ContentType</span> <span class="p">=</span> <span class="s1">&#39;application/json&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="n">Body</span>        <span class="p">=</span> <span class="nv">$body</span>
</span></span><span class="line"><span class="cl">    <span class="n">TimeoutSec</span>  <span class="p">=</span> <span class="mf">30</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">Invoke-RestMethod</span> <span class="nv">@incident</span></span></span></code></pre></div></div>
  </div>
</div><p>Same call. More lines. Far less to hold in your head, for four reasons.</p>
<p><strong>Every parameter is named and on its own line.</strong> Nothing is positional; nothing
depends on order.</p>
<p><strong>The structure is visible.</strong> The headers are a nested block, not a run of
braces in the middle of a line. You can see that the call posts JSON, with a
bearer token, to one endpoint.</p>
<p><strong>Changing it is safe.</strong> Adding a header is adding a line. Deleting a
parameter is deleting a line. There&rsquo;s no adjacent argument to break.</p>
<p><strong>Comments fit.</strong></p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$incident</span> <span class="p">=</span> <span class="vm">@</span><span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">Uri</span>         <span class="p">=</span> <span class="s1">&#39;https://status.example.com/api/incidents&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="n">Method</span>      <span class="p">=</span> <span class="s1">&#39;Post&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="n">Headers</span>     <span class="p">=</span> <span class="vm">@</span><span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="c"># Read the token from the vault, never from the script.</span>
</span></span><span class="line"><span class="cl">        <span class="n">Authorization</span> <span class="p">=</span> <span class="s2">&#34;Bearer </span><span class="nv">$token</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="n">Accept</span>        <span class="p">=</span> <span class="s1">&#39;application/json&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="n">ContentType</span> <span class="p">=</span> <span class="s1">&#39;application/json&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="n">Body</span>        <span class="p">=</span> <span class="nv">$body</span>
</span></span><span class="line"><span class="cl">    <span class="c"># Fail fast; the caller retries.</span>
</span></span><span class="line"><span class="cl">    <span class="n">TimeoutSec</span>  <span class="p">=</span> <span class="mf">30</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p>None of this is for the computer. It&rsquo;s for the person reading the script
later — probably you, and if not you, someone who can&rsquo;t ask you.</p>
<h2 id="people-first-computers-second">People first, computers second<a class="heading-anchor" href="https://looselydecoupled.com/splatting-readability-scaling/#people-first-computers-second"
     aria-label="Link to section: People first, computers second" data-pagefind-ignore></a>
</h2>
<p>There&rsquo;s an old Unix principle: write programs for people first, computers
second. Not because the computer doesn&rsquo;t matter, but because the human is the
slow part. The machine executes the dense one-liner in the same time as the
splatted block. The human pays for the density every single time they read it.</p>
<p>Splatting is a forcing function for that principle. To splat, you have to name
every parameter, arrange them in a structure, and decide what you&rsquo;re passing.
You can&rsquo;t throw arguments at a cmdlet and hope.</p>
<h2 id="rules-that-are-not-obvious">Rules that are not obvious<a class="heading-anchor" href="https://looselydecoupled.com/splatting-readability-scaling/#rules-that-are-not-obvious"
     aria-label="Link to section: Rules that are not obvious" data-pagefind-ignore></a>
</h2>
<p>Splatting is simple until it isn&rsquo;t. These are the parts that trip people up.</p>
<p><strong>It&rsquo;s <code>@params</code> at the call and <code>$params</code> everywhere else.</strong> The hashtable is
a normal variable; only the call site uses <code>@</code>. Writing <code>Invoke-RestMethod $incident</code> passes the whole table as the first positional argument, and the
error message will be about a type conversion, not about splatting.</p>
<p><strong>Switch parameters take a boolean.</strong> <code>Recurse = $true</code>, not <code>Recurse = 'yes'</code>
and not a bare <code>Recurse</code>. A hashtable value has to be a value.</p>
<p><strong>You can mix splatted and explicit parameters.</strong> This is the pattern for a
common base with a per-call difference:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$common</span> <span class="p">=</span> <span class="vm">@</span><span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">Headers</span>    <span class="p">=</span> <span class="vm">@</span><span class="p">{</span> <span class="n">Authorization</span> <span class="p">=</span> <span class="s2">&#34;Bearer </span><span class="nv">$token</span><span class="s2">&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="n">TimeoutSec</span> <span class="p">=</span> <span class="mf">30</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">Invoke-RestMethod</span> <span class="nv">@common</span> <span class="n">-Uri</span> <span class="s2">&#34;</span><span class="nv">$api</span><span class="s2">/components&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Invoke-RestMethod</span> <span class="nv">@common</span> <span class="n">-Uri</span> <span class="s2">&#34;</span><span class="nv">$api</span><span class="s2">/incidents&#34;</span></span></span></code></pre></div></div>
  </div>
</div><p>PowerShell merges them. Naming the same parameter in both places is an error,
which is what you want — a silent override would be worse.</p>
<p><strong>You can build the table conditionally.</strong> A one-liner can&rsquo;t express &ldquo;add
this parameter only if the variable is set&rdquo;; a hashtable can:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$copy</span> <span class="p">=</span> <span class="vm">@</span><span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">Path</span>        <span class="p">=</span> <span class="nv">$source</span>
</span></span><span class="line"><span class="cl">    <span class="n">Destination</span> <span class="p">=</span> <span class="nv">$target</span>
</span></span><span class="line"><span class="cl">    <span class="n">Recurse</span>     <span class="p">=</span> <span class="vm">$true</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nv">$DryRun</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$copy</span><span class="p">[</span><span class="s1">&#39;WhatIf&#39;</span><span class="p">]</span> <span class="p">=</span> <span class="vm">$true</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">Copy-Item</span> <span class="nv">@copy</span></span></span></code></pre></div></div>
  </div>
</div><p><strong>A function can pass its own parameters straight through.</strong> Inside a
function, <code>$PSBoundParameters</code> is a hashtable of the parameters the caller
supplied, and it splats:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="kd">function</span><span class="w"> </span><span class="nb">Get-RecentLog</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">param</span> <span class="p">([</span><span class="no">string</span><span class="p">]</span><span class="nv">$Path</span><span class="p">,</span> <span class="p">[</span><span class="no">switch</span><span class="p">]</span><span class="nv">$Recurse</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Get-ChildItem</span> <span class="nv">@PSBoundParameters</span> <span class="n">-Filter</span> <span class="p">*.</span><span class="n">log</span> <span class="o">-File</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">        <span class="nb">Sort-Object</span> <span class="n">LastWriteTime</span> <span class="n">-Descending</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">        <span class="nb">Select-Object</span> <span class="n">-First</span> <span class="mf">10</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p>Whatever the caller passed goes through unchanged, and whatever they didn&rsquo;t
pass isn&rsquo;t mentioned at all. It&rsquo;s the cleanest way to write a wrapper, and
it only exists because splatting does.</p>
<p><strong>Arrays splat positionally.</strong> <code>@('a', 'b')</code> splatted fills parameters by
position, not by name. It&rsquo;s legal and almost never what you want in a script
you intend to read later.</p>
<h2 id="bigger-commands-bigger-differences">Bigger commands, bigger differences<a class="heading-anchor" href="https://looselydecoupled.com/splatting-readability-scaling/#bigger-commands-bigger-differences"
     aria-label="Link to section: Bigger commands, bigger differences" data-pagefind-ignore></a>
</h2>
<p>The gap between the two forms grows with the command. Here is the incident call
once it runs from a server. It goes through a proxy, sends a user agent the
status page can identify, and retries when the status page answers with an
error.</p>
<!-- style-check: off -->
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Invoke-RestMethod</span> <span class="n">-Uri</span> <span class="s1">&#39;https://status.example.com/api/incidents&#39;</span> <span class="n">-Method</span> <span class="n">Post</span> <span class="n">-Headers</span> <span class="vm">@</span><span class="p">{</span><span class="n">Authorization</span><span class="p">=</span><span class="s2">&#34;Bearer </span><span class="nv">$token</span><span class="s2">&#34;</span><span class="p">;</span><span class="n">Accept</span><span class="p">=</span><span class="s1">&#39;application/json&#39;</span><span class="p">;</span><span class="s1">&#39;X-Request-Id&#39;</span><span class="p">=</span><span class="nv">$requestId</span><span class="p">}</span> <span class="n">-ContentType</span> <span class="s1">&#39;application/json&#39;</span> <span class="n">-Body</span> <span class="nv">$body</span> <span class="n">-TimeoutSec</span> <span class="mf">30</span> <span class="n">-UserAgent</span> <span class="s1">&#39;deploy-bot/2.1&#39;</span> <span class="n">-Proxy</span> <span class="s1">&#39;http://proxy.corp.example:8080&#39;</span> <span class="n">-ProxyUseDefaultCredentials</span> <span class="n">-MaximumRetryCount</span> <span class="mf">3</span> <span class="n">-RetryIntervalSec</span> <span class="mf">5</span></span></span></code></pre></div></div>
  </div>
</div><!-- style-check: on -->
<p>One command, eleven parameters, one of them a table. Now the same call,
splatted:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$incident</span> <span class="p">=</span> <span class="vm">@</span><span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">Uri</span>                        <span class="p">=</span> <span class="s1">&#39;https://status.example.com/api/incidents&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="n">Method</span>                     <span class="p">=</span> <span class="s1">&#39;Post&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="n">Headers</span>                    <span class="p">=</span> <span class="vm">@</span><span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">Authorization</span>  <span class="p">=</span> <span class="s2">&#34;Bearer </span><span class="nv">$token</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="n">Accept</span>         <span class="p">=</span> <span class="s1">&#39;application/json&#39;</span>
</span></span><span class="line"><span class="cl">        <span class="s1">&#39;X-Request-Id&#39;</span> <span class="p">=</span> <span class="nv">$requestId</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="n">ContentType</span>                <span class="p">=</span> <span class="s1">&#39;application/json&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="n">Body</span>                       <span class="p">=</span> <span class="nv">$body</span>
</span></span><span class="line"><span class="cl">    <span class="n">TimeoutSec</span>                 <span class="p">=</span> <span class="mf">30</span>
</span></span><span class="line"><span class="cl">    <span class="n">UserAgent</span>                  <span class="p">=</span> <span class="s1">&#39;deploy-bot/2.1&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="n">Proxy</span>                      <span class="p">=</span> <span class="s1">&#39;http://proxy.corp.example:8080&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="n">ProxyUseDefaultCredentials</span> <span class="p">=</span> <span class="vm">$true</span>
</span></span><span class="line"><span class="cl">    <span class="n">MaximumRetryCount</span>          <span class="p">=</span> <span class="mf">3</span>
</span></span><span class="line"><span class="cl">    <span class="n">RetryIntervalSec</span>           <span class="p">=</span> <span class="mf">5</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">Invoke-RestMethod</span> <span class="nv">@incident</span></span></span></code></pre></div></div>
  </div>
</div><p>Longer, and easier to understand, modify and debug. When the proxy changes, the
proxy is one line you can find. <code>MaximumRetryCount</code> and <code>RetryIntervalSec</code> need
PowerShell 7. To run the same call on Windows PowerShell 5.1, you delete those
two lines; nothing around them changes.</p>
<h2 id="when-not-to-splat">When not to splat<a class="heading-anchor" href="https://looselydecoupled.com/splatting-readability-scaling/#when-not-to-splat"
     aria-label="Link to section: When not to splat" data-pagefind-ignore></a>
</h2>
<p>Splatting is a tool for commands that are hard to read. Applying it to
commands that aren&rsquo;t adds lines and nothing else.</p>
<p><strong>One or two parameters.</strong> <code>Get-Content 'settings.json'</code> doesn&rsquo;t improve with
a hashtable.</p>
<p><strong>Interactive use.</strong> At the prompt you&rsquo;re the only reader, and you&rsquo;re reading
it right now.</p>
<p><strong>Idioms everyone knows.</strong> <code>Get-ChildItem | Where-Object Name -like '*.log'</code> is
a pattern PowerShell users recognize on sight; splatting it hides the idiom.</p>
<p>The rule I use: <strong>splat when future-you would have to re-read the line to
understand it</strong>. For most scripts that turns out to be most of the
non-trivial calls, and almost none of the trivial ones.</p>
<h2 id="documentation-you-no-longer-need">Documentation you no longer need<a class="heading-anchor" href="https://looselydecoupled.com/splatting-readability-scaling/#documentation-you-no-longer-need"
     aria-label="Link to section: Documentation you no longer need" data-pagefind-ignore></a>
</h2>
<p>A splatted call documents itself in two places. The variable name says what
the call is for; the table says how:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$openIncident</span> <span class="p">=</span> <span class="vm">@</span><span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">Uri</span>         <span class="p">=</span> <span class="s1">&#39;https://status.example.com/api/incidents&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="n">Method</span>      <span class="p">=</span> <span class="s1">&#39;Post&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="n">Headers</span>     <span class="p">=</span> <span class="vm">@</span><span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">Authorization</span> <span class="p">=</span> <span class="s2">&#34;Bearer </span><span class="nv">$token</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="n">Accept</span>        <span class="p">=</span> <span class="s1">&#39;application/json&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="n">ContentType</span> <span class="p">=</span> <span class="s1">&#39;application/json&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="n">Body</span>        <span class="p">=</span> <span class="nv">$body</span>
</span></span><span class="line"><span class="cl">    <span class="n">TimeoutSec</span>  <span class="p">=</span> <span class="mf">30</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">Invoke-RestMethod</span> <span class="nv">@openIncident</span></span></span></code></pre></div></div>
  </div>
</div><p>The one-liner needed a comment above it to say what it did. This doesn&rsquo;t.
Comments still earn their place for <em>why</em> — why this endpoint, why this
timeout —
but <em>what</em> is now in the code, where it can&rsquo;t drift from the code.</p>
<h2 id="readability-compounds">Readability compounds<a class="heading-anchor" href="https://looselydecoupled.com/splatting-readability-scaling/#readability-compounds"
     aria-label="Link to section: Readability compounds" data-pagefind-ignore></a>
</h2>
<p>Every future change to a script starts with reading it. A bug fix, a new
parameter, an upgrade to a newer module: read first, then edit. The cost of an
unreadable line is paid on every one of those visits, for as long as the script
is in use. The scripts that matter stay in use for years.</p>
<p>Splatting is a small feature. What it represents is larger: optimize for the
person who will maintain the code, not the machine that will run it. The
machine is fast and doesn&rsquo;t care whether the call is one line or twenty. The
person is slow and cares whether they can understand it without an
afternoon of archaeology.</p>
<p>Splatting is how a script written in one evening stays usable for the next
several years.</p>
<h2 id="references">References<a class="heading-anchor" href="https://looselydecoupled.com/splatting-readability-scaling/#references"
     aria-label="Link to section: References" data-pagefind-ignore></a>
</h2>
<ul>
<li><a href="https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_splatting" rel="external noopener" target="_blank">about_Splatting<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/splatting-readability-scaling/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a> — the PowerShell documentation, including <code>$PSBoundParameters</code> and positional splatting</li>
<li><a href="http://www.catb.org/~esr/writings/taoup/html/ch01s06.html" rel="external noopener" target="_blank">Basics of the Unix Philosophy<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/splatting-readability-scaling/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a> — Eric Raymond on writing for people first</li>
</ul>
]]></content:encoded>
    </item>
    
    <item>
      <title>How to Show File Extensions in File Explorer with PowerShell</title>
      <link>https://looselydecoupled.com/show-file-extensions-windows-powershell/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/show-file-extensions-windows-powershell/</guid>
      <pubDate>Sat, 04 Nov 2023 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Windows Server</category>
      <category>PowerShell</category>
      <category>Automation</category>
      
      <description>HideFileExt set to 0 shows file extensions for your account, and one Active Setup entry sets it for every account on the machine at its next sign-in.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>For the account you are signed in as:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$advanced</span> <span class="p">=</span> <span class="s1">&#39;HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Set-ItemProperty</span> <span class="n">-Path</span> <span class="nv">$advanced</span> <span class="n">-Name</span> <span class="n">HideFileExt</span> <span class="n">-Value</span> <span class="mf">0</span> <span class="n">-Type</span> <span class="n">DWord</span>
</span></span><span class="line"><span class="cl"><span class="nb">Stop-Process</span> <span class="n">-Name</span> <span class="n">explorer</span> <span class="n">-Force</span></span></span></code></pre></div></div>
  </div>
</div><p>Windows restarts the shell, and every window now shows <code>report.txt.exe</code> as
what it is. Run it unelevated: the setting is per user and lives in the hive
of whoever runs the command, and an unelevated session can only stop its own
Explorer.</p>

</div>

<p>Windows hides the extension of known file types by default, so <code>invoice.pdf</code>
and <code>invoice.pdf.exe</code> look the same in Explorer. Turning that off is the first
thing to do on any machine you&rsquo;ll administer from the desktop, and the
setting is one DWORD.</p>
<h2 id="where-the-setting-lives">Where the setting lives<a class="heading-anchor" href="https://looselydecoupled.com/show-file-extensions-windows-powershell/#where-the-setting-lives"
     aria-label="Link to section: Where the setting lives" data-pagefind-ignore></a>
</h2>
<div class="code-block" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced
</span></span><span class="line"><span class="cl">    HideFileExt    REG_DWORD    0 = show extensions, 1 = hide</span></span></code></pre></div></div>
  </div>
</div><p>Explorer reads the value when it starts, which is why the answer restarts it:
a write from PowerShell notifies nothing, and open windows stay as they were.
Stopping it with <code>Stop-Process -Name explorer</code> ends the shell process;
Winlogon relaunches it (<code>AutoRestartShell</code>, on by default), so the taskbar is
back within a second or two.</p>
<div class="admonition admonition--warning" role="note">
  <p class="admonition__label">
    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/show-file-extensions-windows-powershell/#i-triangle-alert"/></svg>
    <span>Warning</span>
  </p>
  <p>Elevated, <code>Stop-Process -Name explorer -Force</code> stops every user&rsquo;s Explorer
on the machine, not only yours — on a server with other people signed in
over RDP, that is their desktop vanishing. From an elevated session, stop
only the one in your session:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Get-Process</span> <span class="n">-Name</span> <span class="n">explorer</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Where-Object</span> <span class="n">SessionId</span> <span class="o">-eq</span> <span class="p">(</span><span class="nb">Get-Process</span> <span class="n">-Id</span> <span class="nv">$PID</span><span class="p">).</span><span class="py">SessionId</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Stop-Process</span> <span class="n">-Force</span></span></span></code></pre></div></div>
  </div>
</div>
</div><!-- style-check: procedure -->
<h2 id="for-every-account-including-ones-that-do-not-exist-yet">For every account, including ones that do not exist yet<a class="heading-anchor" href="https://looselydecoupled.com/show-file-extensions-windows-powershell/#for-every-account-including-ones-that-do-not-exist-yet"
     aria-label="Link to section: For every account, including ones that do not exist yet" data-pagefind-ignore></a>
</h2>
<p><code>HKCU</code> is the current user&rsquo;s hive, so the command above changes one account.
On a server where several administrators sign in, put the same command where
Windows runs it once for each user at their next sign-in, before Explorer
starts. That is Active Setup, and it is a key under <code>HKLM</code>, so it is set with
the registry provider from an elevated session:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$stub</span> <span class="p">=</span> <span class="s1">&#39;HKLM:\SOFTWARE\Microsoft\Active Setup\Installed Components\ShowFileExtensions&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">New-Item</span> <span class="n">-Path</span> <span class="nv">$stub</span> <span class="n">-Force</span> <span class="p">|</span> <span class="nb">Out-Null</span>
</span></span><span class="line"><span class="cl"><span class="nb">Set-ItemProperty</span> <span class="n">-Path</span> <span class="nv">$stub</span> <span class="n">-Name</span> <span class="s1">&#39;(Default)&#39;</span>   <span class="n">-Value</span> <span class="s1">&#39;Show file extensions&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Set-ItemProperty</span> <span class="n">-Path</span> <span class="nv">$stub</span> <span class="n">-Name</span> <span class="s1">&#39;IsInstalled&#39;</span> <span class="n">-Value</span> <span class="mf">1</span> <span class="n">-Type</span> <span class="n">DWord</span>
</span></span><span class="line"><span class="cl"><span class="nb">Set-ItemProperty</span> <span class="n">-Path</span> <span class="nv">$stub</span> <span class="n">-Name</span> <span class="s1">&#39;Version&#39;</span>     <span class="n">-Value</span> <span class="s1">&#39;1&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$advanced</span> <span class="p">=</span> <span class="s1">&#39;HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$command</span> <span class="p">=</span> <span class="s2">&#34;Set-ItemProperty -Path &#39;</span><span class="nv">$advanced</span><span class="s2">&#39; -Name HideFileExt -Value 0&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Set-ItemProperty</span> <span class="n">-Path</span> <span class="nv">$stub</span> <span class="n">-Name</span> <span class="s1">&#39;StubPath&#39;</span> <span class="n">-Value</span> <span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;powershell.exe -NoProfile -WindowStyle Hidden -Command &#34;&#39;</span> <span class="p">+</span> <span class="nv">$command</span> <span class="p">+</span> <span class="s1">&#39;&#34;&#39;</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span></span></span></code></pre></div></div>
  </div>
</div><p>At each sign-in Windows compares <code>Version</code> here against the copy under that
user&rsquo;s <code>HKCU\Software\Microsoft\Active Setup\Installed Components</code>. When the
user has none, or an older one, it runs <code>StubPath</code> as that user and records
the version. So the command runs once per account: existing accounts at their
next sign-in, new accounts at their first. It runs while the shell is still
starting, before the desktop is shown, so the first Explorer window a user
opens already shows extensions. To run it again for everyone, raise <code>Version</code>;
to stop it running for anyone new, delete the key under <code>HKLM</code>. Users who
already ran it keep the setting either way.</p>
<p>A console window can flash for a moment at sign-in while the stub runs;
<code>-WindowStyle Hidden</code> hides it as soon as PowerShell has started, not before.</p>
<p>This is the whole mechanism Windows uses to seed per-user settings from a
machine-wide install. It is the one route to &ldquo;every account&rdquo; that needs no
hive loading and no tool outside PowerShell. Group Policy Preferences does the
same job on a domain-joined machine.</p>
<!-- style-check: end procedure -->
<h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/show-file-extensions-windows-powershell/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="value-is-0-and-extensions-are-still-hidden">Value is <code>0</code> and extensions are still hidden<a class="heading-anchor" href="https://looselydecoupled.com/show-file-extensions-windows-powershell/#value-is-0-and-extensions-are-still-hidden"
     aria-label="Link to section: Value is 0 and extensions are still hidden" data-pagefind-ignore></a>
</h3>
<p>You ran the command elevated as a different account. An elevated PowerShell
opened with &ldquo;Run as administrator&rdquo; and a second set of credentials writes to
that account&rsquo;s <code>HKCU</code>, not yours. Run it unelevated in your own session.</p>
<h3 id="taskbar-didnt-come-back-after-stop-process">Taskbar didn&rsquo;t come back after <code>Stop-Process</code><a class="heading-anchor" href="https://looselydecoupled.com/show-file-extensions-windows-powershell/#taskbar-didnt-come-back-after-stop-process"
     aria-label="Link to section: Taskbar didn’t come back after Stop-Process" data-pagefind-ignore></a>
</h3>
<p><code>AutoRestartShell</code> is off on that machine, or the process was ended from
outside the session it belonged to. Start it by hand:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Start-Process</span> <span class="n">explorer</span></span></span></code></pre></div></div>
  </div>
</div><p>On Server Core there&rsquo;s no Explorer to stop and no setting to change;
<code>Stop-Process</code> reports that no such process exists.</p>
<h3 id="extensions-still-hidden-for-another-user">Extensions still hidden for another user<a class="heading-anchor" href="https://looselydecoupled.com/show-file-extensions-windows-powershell/#extensions-still-hidden-for-another-user"
     aria-label="Link to section: Extensions still hidden for another user" data-pagefind-ignore></a>
</h3>
<p>The user hasn&rsquo;t signed out and in since it was written; Active Setup runs at
sign-in and nowhere else. Or the stub ran under a policy that blocks
<code>powershell.exe</code> for that user. Check
<code>HKCU\Software\Microsoft\Active Setup\Installed Components\ShowFileExtensions</code>
in their hive: present means the stub was launched, absent means it wasn&rsquo;t.</p>
]]></content:encoded>
    </item>
    
    <item>
      <title>About</title>
      <link>https://looselydecoupled.com/about/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/about/</guid>
      <pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>PowerShell</category>
      
      <description>Who writes Loosely Decoupled and how to get in touch.</description>
      <content:encoded><![CDATA[<p>Everything published so far is in <a href="https://looselydecoupled.com/articles/">the articles list</a>.</p>
<h2 id="written-by-emmanuel-tsouris">Written by Emmanuel Tsouris<a class="heading-anchor" href="https://looselydecoupled.com/about/#written-by-emmanuel-tsouris"
     aria-label="Link to section: Written by Emmanuel Tsouris" data-pagefind-ignore></a>
</h2>
<p>I have spent my career writing software and keeping large systems running. In 2016 I moved from California to the Seattle area to work in the cloud.</p>
<p>Everything here is written in a personal capacity, from my own hobbies and experience. My opinions are my own.</p>
<p>I also co-wrote <a href="https://www.amazon.com/dp/148424849X?tag=loosely-decoupled-20" rel="external noopener sponsored" target="_blank"><em>Pro PowerShell for Amazon Web Services</em><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/about/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a> (affiliate link).</p>
<h2 id="elsewhere">Elsewhere<a class="heading-anchor" href="https://looselydecoupled.com/about/#elsewhere"
     aria-label="Link to section: Elsewhere" data-pagefind-ignore></a>
</h2>
<ul>
<li><a href="https://www.emmanueltsouris.com/" rel="external noopener" target="_blank">emmanueltsouris.com<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/about/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>, the personal site</li>
<li><a href="https://www.amazon.com/stores/author/B08WL28QF8" rel="external noopener" target="_blank">Amazon author page<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/about/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a></li>
<li><a href="https://github.com/EmmanuelTsouris" rel="external noopener" target="_blank">GitHub<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/about/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a></li>
</ul>
<h2 id="contact">Contact<a class="heading-anchor" href="https://looselydecoupled.com/about/#contact"
     aria-label="Link to section: Contact" data-pagefind-ignore></a>
</h2>
<p>To reach me, or to report an error in an article, reply to any newsletter issue. A fix moves the article&rsquo;s update date.</p>
]]></content:encoded>
    </item>
    
  </channel>
</rss>
