<?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>Windows Server · Loosely Decoupled</title>
    <link>https://looselydecoupled.com/topics/windows/</link>
    <description>Enable OpenSSH on Server 2022 and 2025, put administrator keys where sshd reads them, and script workstation setup. With the PowerShell for each step.</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/windows/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>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>Install SteamCMD and a Steam Dedicated Server on Windows Server</title>
      <link>https://looselydecoupled.com/install-steamcmd-steam-dedicated-server-windows/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/install-steamcmd-steam-dedicated-server-windows/</guid>
      <pubDate>Thu, 24 Sep 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Windows Server</category>
      <category>Automation</category>
      
      <description>SteamCMD and a dedicated server installed on Windows Server, checked by the success line, the executable and the build ID rather than the exit code.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Unpack SteamCMD to <code>C:\steamcmd</code>, then install the server with one command that looks the app up first. The examples install 7 Days to Die, whose app ID is <code>294420</code>. The app ID, the install folder, the server executable&rsquo;s name and whether the game accepts <code>+login anonymous</code> are the game-specific parts, and the size you expect changes with them.</p>
<p>In PowerShell 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-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">steamcmd</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">Invoke-WebRequest</span> <span class="n">-Uri</span> <span class="s1">&#39;https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip&#39;</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-OutFile</span> <span class="n">C:</span><span class="p">\</span><span class="n">steamcmd</span><span class="p">\</span><span class="n">steamcmd</span><span class="p">.</span><span class="py">zip</span> <span class="n">-UseBasicParsing</span>
</span></span><span class="line"><span class="cl"><span class="nb">Expand-Archive</span> <span class="n">-Path</span> <span class="n">C:</span><span class="p">\</span><span class="n">steamcmd</span><span class="p">\</span><span class="n">steamcmd</span><span class="p">.</span><span class="py">zip</span> <span class="n">-DestinationPath</span> <span class="n">C:</span><span class="p">\</span><span class="n">steamcmd</span> <span class="n">-Force</span>
</span></span><span class="line"><span class="cl"><span class="nb">Remove-Item</span> <span class="n">C:</span><span class="p">\</span><span class="n">steamcmd</span><span class="p">\</span><span class="n">steamcmd</span><span class="p">.</span><span class="py">zip</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">C:</span><span class="p">\</span><span class="n">steamcmd</span><span class="p">\</span><span class="n">steamcmd</span><span class="p">.</span><span class="py">exe</span> <span class="p">+</span><span class="n">force_install_dir</span> <span class="s2">&#34;C:\7DaysToDieServer&#34;</span> <span class="p">+</span><span class="n">login</span> <span class="n">anonymous</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="p">+</span><span class="n">app_info_print</span> <span class="mf">294420</span> <span class="p">+</span><span class="n">app_update</span> <span class="mf">294420</span> <span class="n">validate</span> <span class="p">+</span><span class="n">quit</span></span></span></code></pre></div></div>
  </div>
</div><p>The install worked when the output contains <code>Success! App '294420' fully installed.</code> and <code>C:\7DaysToDieServer\7DaysToDieServer.exe</code> exists. Don&rsquo;t go by the exit code. A fresh SteamCMD exits 7 on its first run, install or not.</p>
<p>Don&rsquo;t run that command with <code>validate</code> later on a server whose <code>serverconfig.xml</code> you&rsquo;ve edited, because <code>validate</code> restores the shipped file. <a href="https://looselydecoupled.com/install-steamcmd-steam-dedicated-server-windows/#4-run-it-again-on-an-installed-server">Step 4</a> has the details.</p>
<p>If the output ends in <code>ERROR! Failed to install app '294420' (Missing configuration)</code> or <code>Error! App '294420' state is 0x602 after update job.</code>, run the same command again. Both cleared on the second run in testing. <a href="https://looselydecoupled.com/install-steamcmd-steam-dedicated-server-windows/#missing-configuration-on-the-first-install"><code>Missing configuration</code></a> is the one most likely to stop you, and <code>+app_info_print</code> prevents it on a fresh SteamCMD.</p>

</div>

<p>The install is one command, which is why the ways it misleads you on a fresh machine are easy to miss. The first run of SteamCMD updates itself and exits 7, including a run that installs the server. The first install on a fresh SteamCMD fails unless SteamCMD has already fetched the app&rsquo;s record. A download of 16 GB can end with one corrupt file, and a rerun repairs it. An install path on a drive that doesn&rsquo;t exist gets a <code>Success!</code> line and an install in the wrong folder. This part installs SteamCMD and the 7 Days to Die server and says how to tell a good install from a bad one. Finding the app ID for another game is the first part of this series.</p>
<!-- style-check: procedure -->
<h2 id="1-download-and-unpack-steamcmdzip">1. Download and unpack steamcmd.zip<a class="heading-anchor" href="https://looselydecoupled.com/install-steamcmd-steam-dedicated-server-windows/#1-download-and-unpack-steamcmdzip"
     aria-label="Link to section: 1. Download and unpack steamcmd.zip" data-pagefind-ignore></a>
</h2>
<p>Run this in PowerShell on the server. It creates <code>C:\steamcmd</code>, downloads Valve&rsquo;s <code>steamcmd.zip</code> into it, unpacks it and deletes the zip. The zip holds one file, <code>steamcmd.exe</code>, so there is no installer to run. <code>-UseBasicParsing</code> stops <code>Invoke-WebRequest</code> from parsing the download as a web page. <a href="https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/invoke-webrequest?view=powershell-5.1" rel="external noopener" target="_blank">Microsoft&rsquo;s 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-steamcmd-steam-dedicated-server-windows/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a> says Windows PowerShell requires it where Internet Explorer isn&rsquo;t installed, and that after a December 2025 security update <code>Invoke-WebRequest</code> asks for confirmation without it, which stalls an unattended 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="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">steamcmd</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">Invoke-WebRequest</span> <span class="n">-Uri</span> <span class="s1">&#39;https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip&#39;</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-OutFile</span> <span class="n">C:</span><span class="p">\</span><span class="n">steamcmd</span><span class="p">\</span><span class="n">steamcmd</span><span class="p">.</span><span class="py">zip</span> <span class="n">-UseBasicParsing</span>
</span></span><span class="line"><span class="cl"><span class="nb">Expand-Archive</span> <span class="n">-Path</span> <span class="n">C:</span><span class="p">\</span><span class="n">steamcmd</span><span class="p">\</span><span class="n">steamcmd</span><span class="p">.</span><span class="py">zip</span> <span class="n">-DestinationPath</span> <span class="n">C:</span><span class="p">\</span><span class="n">steamcmd</span> <span class="n">-Force</span>
</span></span><span class="line"><span class="cl"><span class="nb">Remove-Item</span> <span class="n">C:</span><span class="p">\</span><span class="n">steamcmd</span><span class="p">\</span><span class="n">steamcmd</span><span class="p">.</span><span class="n">zip</span></span></span></code></pre></div></div>
  </div>
</div><p>SteamCMD fetches the rest of itself the first time it runs, and that run exits 7. Step 3 and <a href="https://looselydecoupled.com/install-steamcmd-steam-dedicated-server-windows/#exit-code-7-and-other-codes-that-mislead">the exit code section</a> say how to check an install without the exit code.</p>
<h2 id="2-install-the-server-with-app_update">2. Install the server with app_update<a class="heading-anchor" href="https://looselydecoupled.com/install-steamcmd-steam-dedicated-server-windows/#2-install-the-server-with-app_update"
     aria-label="Link to section: 2. Install the server with app_update" data-pagefind-ignore></a>
</h2>
<p>This writes about 16.5 GB under <code>C:\7DaysToDieServer</code> and downloads about that much, so the drive needs 17 GB free. Check that the drive exists and has the room: <code>Get-PSDrive C | Select-Object Used, Free</code>. The command, with each argument explained:</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="n">C:</span><span class="p">\</span><span class="n">steamcmd</span><span class="p">\</span><span class="n">steamcmd</span><span class="p">.</span><span class="py">exe</span> <span class="p">+</span><span class="n">force_install_dir</span> <span class="s2">&#34;C:\7DaysToDieServer&#34;</span> <span class="p">+</span><span class="n">login</span> <span class="n">anonymous</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="p">+</span><span class="n">app_info_print</span> <span class="mf">294420</span> <span class="p">+</span><span class="n">app_update</span> <span class="mf">294420</span> <span class="n">validate</span> <span class="p">+</span><span class="n">quit</span></span></span></code></pre></div></div>
  </div>
</div><div class="table-wrap" data-scrollable data-label="Table: Argument">
  <table>
    <thead>
      <tr><th scope="col">Argument</th><th scope="col">What it does</th></tr>
    </thead>
    <tbody>
      <tr><td><code>+force_install_dir &quot;C:\7DaysToDieServer&quot;</code></td><td>The folder the server goes in. SteamCMD creates it. Keep the quotes</td></tr>
      <tr><td><code>+login anonymous</code></td><td>Signs in without an account. The first part of this series says which servers allow it</td></tr>
      <tr><td><code>+app_info_print 294420</code></td><td>Fetches the app&rsquo;s record and prints it, about 550 lines. Without it, or an earlier run that signed in, the first install on a fresh SteamCMD fails</td></tr>
      <tr><td><code>+app_update 294420 validate</code></td><td>Installs the app, or repairs an existing install by checking every file</td></tr>
      <tr><td><code>+quit</code></td><td>Ends the session. Without it SteamCMD stays open instead of exiting</td></tr>
    </tbody>
  </table>
</div>
<p>Keep the quotes around the folder. Without them, a path with a space splits at the space. I ran a small app with <code>+force_install_dir C:\scratch\p2 space\lit</code>, and SteamCMD installed it into <code>C:\scratch\p2</code>, exited with code <code>0</code> and printed no warning. With the path in quotes it installed where I told it to.</p>
<p>On a fresh SteamCMD the first part of the output is SteamCMD updating itself. A complete download and install took about five to six minutes on this server. Near the end of a successful run the output reads as follows, and a few lines of shutdown messages follow it. The progress numbers differ from run to run:</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"> Update state (0x101) committing, progress: 88.23 (15671513734 / 17761385525)
</span></span><span class="line"><span class="cl"> Update state (0x0) unknown, progress: 0.00 (0 / 0)
</span></span><span class="line"><span class="cl">Success! App &#39;294420&#39; fully installed.</span></span></code></pre></div></div>
  </div>
</div><h2 id="3-confirm-the-install">3. Confirm the install<a class="heading-anchor" href="https://looselydecoupled.com/install-steamcmd-steam-dedicated-server-windows/#3-confirm-the-install"
     aria-label="Link to section: 3. Confirm the install" data-pagefind-ignore></a>
</h2>
<p>Check three things: the <code>Success!</code> line from step 2, the executable at the path you chose, and the manifest SteamCMD writes for the app. A path SteamCMD can&rsquo;t use isn&rsquo;t an error, as <a href="https://looselydecoupled.com/install-steamcmd-steam-dedicated-server-windows/#success-but-the-files-are-in-the-wrong-folder">one of the failures</a> shows, so look at the path and don&rsquo;t rely on the <code>Success!</code> line alone. The last two checks are:</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">Test-Path</span> <span class="console__token">C:\7DaysToDieServer\7DaysToDieServer.exe</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">True</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">Select-String</span> <span class="console__token">-Path</span> <span class="console__token">C:\7DaysToDieServer\steamapps\appmanifest_294420.acf</span> <span class="console__token">`</span>
</span><span class="line"><span class="prompt">&gt;     </span><span class="console__token">-Pattern</span> <span class="console__token">&#39;&#34;(StateFlags|buildid)&#34;&#39;</span> <span class="console__token">|</span>
</span><span class="line"><span class="prompt">&gt;     </span><span class="console__token">ForEach-Object</span> <span class="console__token">{</span> <span class="console__token">$_.Line.Trim()</span> <span class="console__token">-replace</span> <span class="console__token">&#39;\t+&#39;,</span> <span class="console__token">&#39;</span>  <span class="console__token">&#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">&#34;StateFlags&#34;</span>  <span class="console__token">&#34;4&#34;</span>
</span><span class="line"><span class="console__token">&#34;buildid&#34;</span>  <span class="console__token">&#34;24994542&#34;</span></span></code></pre>
    </div>
  </div></div><p><code>StateFlags</code> is <code>4</code> after a complete install. A failed install leaves a different value: the failed install in <a href="https://looselydecoupled.com/install-steamcmd-steam-dedicated-server-windows/#state-is-0x602-after-update-job-at-the-end-of-the-download">the 0x602 section</a> left <code>1538</code>. <code>buildid</code> is the build you installed. A later part uses it to decide whether Steam has published a newer one.</p>
<h2 id="4-run-it-again-on-an-installed-server">4. Run it again on an installed server<a class="heading-anchor" href="https://looselydecoupled.com/install-steamcmd-steam-dedicated-server-windows/#4-run-it-again-on-an-installed-server"
     aria-label="Link to section: 4. Run it again on an installed server" data-pagefind-ignore></a>
</h2>
<p>The same command works on a server that is already installed. A later part uses it to update. With <code>validate</code> it checks every file, which took from roughly 80 to 150 seconds for about 17,500 files here, and ends with the same <code>fully installed</code> 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="n">C:</span><span class="p">\</span><span class="n">steamcmd</span><span class="p">\</span><span class="n">steamcmd</span><span class="p">.</span><span class="py">exe</span> <span class="p">+</span><span class="n">force_install_dir</span> <span class="s2">&#34;C:\7DaysToDieServer&#34;</span> <span class="p">+</span><span class="n">login</span> <span class="n">anonymous</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="p">+</span><span class="n">app_update</span> <span class="mf">294420</span> <span class="n">validate</span> <span class="p">+</span><span class="n">quit</span></span></span></code></pre></div></div>
  </div>
</div><p>Without <code>validate</code> it doesn&rsquo;t check every file. On an up-to-date server it did nothing, took a few seconds and ended <code>Success! App '294420' already up to date.</code></p>
<p><code>validate</code> also restores every file to the version Valve ships, including <code>serverconfig.xml</code>. I changed <code>ServerName</code> and <code>ServerMaxPlayerCount</code> in that file and ran the command with <code>validate</code>. Both were back to the shipped values, and the file matched the shipped original. The same command without <code>validate</code> left my edit alone. A copy of the file under another name, and a note file that isn&rsquo;t part of the install, survived <code>validate</code>. So keep your settings in a copy and start the server with <code>-configfile=</code> naming it. The next part does that.</p>
<!-- style-check: end procedure -->
<h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/install-steamcmd-steam-dedicated-server-windows/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="missing-configuration-on-the-first-install"><code>Missing configuration</code> on the first install<a class="heading-anchor" href="https://looselydecoupled.com/install-steamcmd-steam-dedicated-server-windows/#missing-configuration-on-the-first-install"
     aria-label="Link to section: Missing configuration on the first install" 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">Failed</span> <span class="console__token">to</span> <span class="console__token">install</span> <span class="console__token">app</span> <span class="console__token">&#39;294420&#39;</span> <span class="console__token">(Missing</span> <span class="console__token">configuration)</span></span></code></pre>
    </div>
  </div></div><p>A fresh SteamCMD fails the first install before it downloads anything, and the same command succeeds when you run it again. I ran two or three trials of each case on a fresh SteamCMD with Core Keeper&rsquo;s server (app <code>1963720</code>), which is a small download:</p>
<ul>
<li>The install as the first run exited 7 with the error.</li>
<li>The install after one <code>+quit</code> run, or two, exited 8 with the error.</li>
<li>The install with <code>+app_info_update 1</code> added exited 8 with the error.</li>
<li>The install in the same command as a lookup of a different app, <code>+app_info_print 440</code>, exited 8 with the error.</li>
<li>The install in the same command as a lookup of the app itself, <code>+app_info_print 1963720</code>, succeeded every time.</li>
<li>The install after an earlier run that signed in, <code>+login anonymous +quit</code>, succeeded every time.</li>
<li>The install after an earlier run that looked up an app, the same one or a different one, succeeded every time.</li>
</ul>
<p>So the first install fails unless SteamCMD has already signed in anonymously in an earlier run, or the same command looks up the app being installed. The 7 Days to Die server failed on a SteamCMD that had run <code>+quit</code> twice, and succeeded with <code>+app_info_print 294420</code> in the same command. Either put the lookup before <code>+app_update</code> as in step 2, or run <code>C:\steamcmd\steamcmd.exe +login anonymous +quit</code> once before the install. I don&rsquo;t know why either works.</p>
<h3 id="success-but-the-files-are-in-the-wrong-folder"><code>Success!</code> but the files are in the wrong folder<a class="heading-anchor" href="https://looselydecoupled.com/install-steamcmd-steam-dedicated-server-windows/#success-but-the-files-are-in-the-wrong-folder"
     aria-label="Link to section: Success! but the files are in the wrong folder" data-pagefind-ignore></a>
</h3>
<p>An install path that SteamCMD can&rsquo;t use isn&rsquo;t an error. With no drive <code>Z:</code> on the machine, this command printed <code>Success! App '1963720' fully installed.</code> and exited <code>0</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="n">C:</span><span class="p">\</span><span class="n">steamcmd</span><span class="p">\</span><span class="n">steamcmd</span><span class="p">.</span><span class="py">exe</span> <span class="p">+</span><span class="n">force_install_dir</span> <span class="s2">&#34;Z:\nope&#34;</span> <span class="p">+</span><span class="n">login</span> <span class="n">anonymous</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="p">+</span><span class="n">app_update</span> <span class="mf">1963720</span> <span class="p">+</span><span class="n">quit</span></span></span></code></pre></div></div>
  </div>
</div><p>Nothing was created on <code>Z:</code>. The server went under the SteamCMD folder, in <code>steamapps\common\Core Keeper Dedicated Server</code>, with its manifest and executable. That happened in three of three trials. A folder that doesn&rsquo;t exist yet on a drive that does, such as <code>C:\7DaysToDieServer</code>, is created. Check that the drive exists with <code>Test-Path Z:\</code>, and check that the executable exists at the path you chose.</p>
<h3 id="state-is-0x602-after-update-job-at-the-end-of-the-download"><code>state is 0x602 after update job</code> at the end of the download<a class="heading-anchor" href="https://looselydecoupled.com/install-steamcmd-steam-dedicated-server-windows/#state-is-0x602-after-update-job-at-the-end-of-the-download"
     aria-label="Link to section: state is 0x602 after update job at the end of the download" 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">App</span> <span class="console__token">&#39;294420&#39;</span> <span class="console__token">state</span> <span class="console__token">is</span> <span class="console__token">0x602</span> <span class="console__token">after</span> <span class="console__token">update</span> <span class="console__token">job.</span></span></code></pre>
    </div>
  </div></div><p>The last progress line was 98.59%, and SteamCMD then rejected the files it had staged. <code>logs\content_log.txt</code> under the SteamCMD folder says why: <code>Staged file validation failed (0 missing, 1 corrupt, 0 unreadable) (Corrupt update files)</code>. The server&rsquo;s folder had no <code>7DaysToDieServer.exe</code> and the manifest read <code>StateFlags</code> <code>1538</code>. Run the same install command again. In my run the second one finished in 21 seconds with <code>fully installed</code>. It happened once in my first three complete installs from a fresh SteamCMD.</p>
<h3 id="exit-code-7-and-other-codes-that-mislead">Exit code 7 and other codes that mislead<a class="heading-anchor" href="https://looselydecoupled.com/install-steamcmd-steam-dedicated-server-windows/#exit-code-7-and-other-codes-that-mislead"
     aria-label="Link to section: Exit code 7 and other codes that mislead" data-pagefind-ignore></a>
</h3>
<p>The exit code of <code>steamcmd.exe</code> doesn&rsquo;t say whether the install worked:</p>
<ul>
<li>The first run of a fresh SteamCMD exits <code>7</code>. When that run also installs the server the output contains <code>Success!</code>, and when the install fails it exits <code>7</code> as well.</li>
<li>A refused install, for example <code>No subscription</code>, exits <code>8</code>.</li>
<li>A command SteamCMD doesn&rsquo;t know, for example <code>+no_such_command</code>, prints <code>Command not found: no_such_command</code> and exits <code>0</code>.</li>
<li>An install path on a drive that doesn&rsquo;t exist exits <code>0</code> and prints <code>Success!</code>, as the previous section shows.</li>
</ul>
<p>Look for a <code>Success!</code> line in the output, and check that the executable exists at the path you chose. A <code>Success!</code> line is followed by a few lines of shutdown messages, so test for it with <code>Select-String</code> and not as the last line. A script that tests only <code>$LASTEXITCODE</code> calls a good first install a failure and a mistyped command a success.</p>
<h3 id="no-subscription"><code>No subscription</code><a class="heading-anchor" href="https://looselydecoupled.com/install-steamcmd-steam-dedicated-server-windows/#no-subscription"
     aria-label="Link to section: No subscription" 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">Failed</span> <span class="console__token">to</span> <span class="console__token">install</span> <span class="console__token">app</span> <span class="console__token">&#39;223350&#39;</span> <span class="console__token">(No</span> <span class="console__token">subscription)</span></span></code></pre>
    </div>
  </div></div><p>That&rsquo;s DayZ&rsquo;s server, app <code>223350</code>. The install was refused for an anonymous login. The ID is the game&rsquo;s, or the server isn&rsquo;t available anonymously, or no app has that ID. The first part of this series tells them apart.</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>SteamCMD</dt> <dd>client version 1788292693, anonymous login</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>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
    <item>
      <title>Find a Dedicated Server&#39;s Steam App ID for SteamCMD</title>
      <link>https://looselydecoupled.com/find-steam-dedicated-server-app-id/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/find-steam-dedicated-server-app-id/</guid>
      <pubDate>Thu, 24 Sep 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Windows Server</category>
      <category>Automation</category>
      
      <description>Look up a dedicated server&#39;s Steam app ID, confirm it with SteamCMD, and see whether anonymous login can install it, checked here for 25 common servers.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>A dedicated server is usually its own Steam app with its own app ID. Pass the server&rsquo;s ID to SteamCMD&rsquo;s <code>+app_update</code>, not the game&rsquo;s: <code>+app_update 294420</code> installs the 7 Days to Die server, and the game&rsquo;s <code>251570</code> is refused. The one exception among the 25 servers listed here is Counter-Strike 2, where the ID I checked, <code>730</code>, is the game&rsquo;s own app. <a href="https://looselydecoupled.com/find-steam-dedicated-server-app-id/#app-id-for-each-of-25-dedicated-servers">App ID for each of 25 dedicated servers</a> lists the ones I checked.</p>
<p>Confirm an ID before you install it by asking SteamCMD for its record and reading <code>name</code>, <code>type</code> and <code>parent</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">&amp;</span> <span class="console__token">C:\steamcmd\steamcmd.exe</span> <span class="console__token">+login</span> <span class="console__token">anonymous</span> <span class="console__token">+app_info_print</span> <span class="console__token">294420</span> <span class="console__token">+quit</span> <span class="console__token">|</span>
</span><span class="line"><span class="prompt">&gt;     </span><span class="console__token">Select-String</span> <span class="console__token">-Pattern</span> <span class="console__token">&#39;^\t\t&#34;(name|type|parent)&#34;\t&#39;</span> <span class="console__token">|</span>
</span><span class="line"><span class="prompt">&gt;     </span><span class="console__token">ForEach-Object</span> <span class="console__token">{</span> <span class="console__token">$_.Line.Trim()</span> <span class="console__token">-replace</span> <span class="console__token">&#39;\t+&#39;,</span> <span class="console__token">&#39;</span>  <span class="console__token">&#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">&#34;name&#34;</span>  <span class="console__token">&#34;7</span> <span class="console__token">Days</span> <span class="console__token">to</span> <span class="console__token">Die</span> <span class="console__token">Dedicated</span> <span class="console__token">Server&#34;</span>
</span><span class="line"><span class="console__token">&#34;type&#34;</span>  <span class="console__token">&#34;Tool&#34;</span>
</span><span class="line"><span class="console__token">&#34;parent&#34;</span>  <span class="console__token">&#34;251570&#34;</span></span></code></pre>
    </div>
  </div></div><p><code>name</code> should say it&rsquo;s a server. A <code>parent</code>, when the record has one, is the game&rsquo;s ID, and seven of the servers in the table have none, so a missing <code>parent</code> isn&rsquo;t a reason to reject a candidate. The thing most likely to stop you is that anonymous login can&rsquo;t install every server: DayZ&rsquo;s and Arma 3&rsquo;s answered <code>No subscription</code>.</p>

</div>

<p>The scripts that run my 7 Days to Die server pass SteamCMD one number, <code>294420</code>. The game has a number too, <code>251570</code>, in the address of its store page, and an anonymous install of it fails with <code>No subscription</code>. I wrote this part first because every later part in this series hands the server&rsquo;s ID to SteamCMD, and a reader running another game has to find theirs.</p>
<h2 id="app-id-for-each-of-25-dedicated-servers">App ID for each of 25 dedicated servers<a class="heading-anchor" href="https://looselydecoupled.com/find-steam-dedicated-server-app-id/#app-id-for-each-of-25-dedicated-servers"
     aria-label="Link to section: App ID for each of 25 dedicated servers" data-pagefind-ignore></a>
</h2>
<p>I checked each row on 2026-10-03, on Windows Server 2025, with the commands in this article.</p>
<div class="table-wrap" data-scrollable data-label="Table: Game">
  <table>
    <thead>
      <tr><th scope="col">Game</th><th scope="col">App ID</th><th scope="col">Anonymous install</th><th scope="col">Name Steam returns</th><th scope="col">Game&rsquo;s ID</th></tr>
    </thead>
    <tbody>
      <tr><td>7 Days to Die</td><td>294420</td><td>Install begins</td><td>7 Days to Die Dedicated Server</td><td>251570</td></tr>
      <tr><td>ARK: Survival Ascended</td><td>2430930</td><td>Install begins</td><td>ARK: Survival Ascended Dedicated Server</td><td>2399830</td></tr>
      <tr><td>ARK: Survival Evolved</td><td>376030</td><td>Install begins</td><td>ARK: Survival Evolved Dedicated Server</td><td>none</td></tr>
      <tr><td>Arma 3</td><td>233780</td><td>Refused: No subscription</td><td>Arma 3 Server</td><td>107410</td></tr>
      <tr><td>Arma Reforger</td><td>1874900</td><td>Install begins</td><td>Arma Reforger Server</td><td>1874880</td></tr>
      <tr><td>Conan Exiles</td><td>443030</td><td>Install begins</td><td>Conan Exiles Dedicated Server</td><td>none</td></tr>
      <tr><td>Core Keeper</td><td>1963720</td><td>Install begins</td><td>Core Keeper Dedicated Server</td><td>1621690</td></tr>
      <tr><td>Counter-Strike 2</td><td>730</td><td>Install begins</td><td>Counter-Strike 2</td><td>none</td></tr>
      <tr><td>DayZ</td><td>223350</td><td>Refused: No subscription</td><td>DayZ Server</td><td>221100</td></tr>
      <tr><td>Don&rsquo;t Starve Together</td><td>343050</td><td>Install begins</td><td>Don&rsquo;t Starve Together Dedicated Server</td><td>none</td></tr>
      <tr><td>Enshrouded</td><td>2278520</td><td>Install begins</td><td>Enshrouded Dedicated Server</td><td>1203620</td></tr>
      <tr><td>Garry&rsquo;s Mod</td><td>4020</td><td>Install begins</td><td>Garry&rsquo;s Mod Dedicated Server</td><td>4000</td></tr>
      <tr><td>Insurgency: Sandstorm</td><td>581330</td><td>Install begins</td><td>Insurgency: Sandstorm Dedicated Server</td><td>581320</td></tr>
      <tr><td>Left 4 Dead 2</td><td>222860</td><td>Install begins</td><td>Left 4 Dead 2 Dedicated Server</td><td>550</td></tr>
      <tr><td>Palworld</td><td>2394010</td><td>Install begins</td><td>Palworld Dedicated Server</td><td>1623730</td></tr>
      <tr><td>Project Zomboid</td><td>380870</td><td>Install begins</td><td>Project Zomboid Dedicated Server</td><td>none</td></tr>
      <tr><td>Rust</td><td>258550</td><td>Install begins</td><td>Rust Dedicated Server</td><td>none</td></tr>
      <tr><td>Satisfactory</td><td>1690800</td><td>Install begins</td><td>Satisfactory Dedicated Server</td><td>526870</td></tr>
      <tr><td>Sons of the Forest</td><td>2465200</td><td>Install begins</td><td>Sons Of The Forest Dedicated Server</td><td>1326470</td></tr>
      <tr><td>Space Engineers</td><td>298740</td><td>Install begins</td><td>Space Engineers Dedicated Server</td><td>244850</td></tr>
      <tr><td>Squad</td><td>403240</td><td>Install begins</td><td>Squad Dedicated Server</td><td>none</td></tr>
      <tr><td>Team Fortress 2</td><td>232250</td><td>Install begins</td><td>Team Fortress 2 Dedicated Server</td><td>440</td></tr>
      <tr><td>Unturned</td><td>1110390</td><td>Install begins</td><td>Unturned Dedicated Server</td><td>304930</td></tr>
      <tr><td>V Rising</td><td>1829350</td><td>Install begins</td><td>V Rising Dedicated Server</td><td>1604030</td></tr>
      <tr><td>Valheim</td><td>896660</td><td>Install begins</td><td>Valheim Dedicated Server</td><td>892970</td></tr>
    </tbody>
  </table>
</div>
<p>The columns:</p>
<ul>
<li><strong>Anonymous install</strong>: <code>Install begins</code> means SteamCMD printed its first <code>Update state</code> line and I stopped it. <code>Refused</code> means the install was refused with <code>No subscription</code>. No install ran to the end, and I didn&rsquo;t try a login.</li>
<li><strong>Name Steam returns</strong>: what <code>app_info_print</code> printed as <code>name</code> for the ID.</li>
<li><strong>Game&rsquo;s ID</strong>: the record&rsquo;s <code>parent</code>. I looked up each one, and each returned that game&rsquo;s name. Seven servers have none.</li>
</ul>
<p>Two rows need a second look. Counter-Strike 2&rsquo;s ID is the game&rsquo;s own app: its <code>type</code> is <code>Game</code> and its name doesn&rsquo;t contain Server. Rust&rsquo;s record calls it a dedicated server but gives it the <code>type</code> <code>Game</code>, so don&rsquo;t filter on <code>type</code>.</p>
<!-- style-check: procedure -->
<h2 id="1-find-a-candidate-id">1. Find a candidate ID<a class="heading-anchor" href="https://looselydecoupled.com/find-steam-dedicated-server-app-id/#1-find-a-candidate-id"
     aria-label="Link to section: 1. Find a candidate ID" data-pagefind-ignore></a>
</h2>
<p>Look for the ID in the game&rsquo;s own server documentation, or search SteamDB in a browser. Treat what you find as a candidate until the next step confirms it.</p>
<p>The store won&rsquo;t lead you to it. The game&rsquo;s ID is in the address of the game&rsquo;s store page: <code>store.steampowered.com/app/251570/</code> is 7 Days to Die. The server has no page of its own. When I requested <code>store.steampowered.com/app/294420/</code> from a script, the store answered with its front page, and the store&rsquo;s <code>api/storesearch</code> endpoint returned no result for <code>7 days to die dedicated server</code>. SteamDB answered a script with 403, and Valve&rsquo;s Developer Community wiki answered with a &ldquo;Making sure you&rsquo;re not a bot!&rdquo; page instead of an article, so I haven&rsquo;t checked what either lists.</p>
<h2 id="2-confirm-the-id-from-its-record">2. Confirm the ID from its record<a class="heading-anchor" href="https://looselydecoupled.com/find-steam-dedicated-server-app-id/#2-confirm-the-id-from-its-record"
     aria-label="Link to section: 2. Confirm the ID from its record" data-pagefind-ignore></a>
</h2>
<p>This step needs <code>steamcmd.exe</code>. <a href="https://looselydecoupled.com/install-steamcmd-steam-dedicated-server-windows/">Part 2</a> installs it, and the commands here assume <code>C:\steamcmd</code>. For the lookup alone, <code>https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip</code> unpacks to <code>steamcmd.exe</code>, and the command works on its first run, though that run exits with code 7. The command is the one in the answer block, with your candidate in place of <code>294420</code>. It reads the record and changes nothing.</p>
<p>Read the three lines:</p>
<ul>
<li><code>name</code> is the one to trust. 24 of the 25 names in the table contain Server. The exception is Counter-Strike 2, which is the game.</li>
<li><code>parent</code> is the game&rsquo;s ID, when the server has one.</li>
<li><code>type</code> is <code>Tool</code> for 23 of the 25 and <code>Game</code> for two, so a <code>Game</code> isn&rsquo;t proof you have the wrong app. Rust&rsquo;s record reads:</li>
</ul>
<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">&amp;</span> <span class="console__token">C:\steamcmd\steamcmd.exe</span> <span class="console__token">+login</span> <span class="console__token">anonymous</span> <span class="console__token">+app_info_print</span> <span class="console__token">258550</span> <span class="console__token">+quit</span> <span class="console__token">|</span>
</span><span class="line"><span class="prompt">&gt;     </span><span class="console__token">Select-String</span> <span class="console__token">-Pattern</span> <span class="console__token">&#39;^\t\t&#34;(name|type|parent)&#34;\t&#39;</span> <span class="console__token">|</span>
</span><span class="line"><span class="prompt">&gt;     </span><span class="console__token">ForEach-Object</span> <span class="console__token">{</span> <span class="console__token">$_.Line.Trim()</span> <span class="console__token">-replace</span> <span class="console__token">&#39;\t+&#39;,</span> <span class="console__token">&#39;</span>  <span class="console__token">&#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">&#34;name&#34;</span>  <span class="console__token">&#34;Rust</span> <span class="console__token">Dedicated</span> <span class="console__token">Server&#34;</span>
</span><span class="line"><span class="console__token">&#34;type&#34;</span>  <span class="console__token">&#34;Game&#34;</span></span></code></pre>
    </div>
  </div></div><h2 id="3-check-that-anonymous-login-can-install-it">3. Check that anonymous login can install it<a class="heading-anchor" href="https://looselydecoupled.com/find-steam-dedicated-server-app-id/#3-check-that-anonymous-login-can-install-it"
     aria-label="Link to section: 3. Check that anonymous login can install it" data-pagefind-ignore></a>
</h2>
<p>A real install downloads the whole server, which for some games is tens of gigabytes, and you only need to know whether it starts. Run the install once into an empty folder you can delete, here <code>C:\steam-check</code>, and stop it when SteamCMD answers:</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="n">C:</span><span class="p">\</span><span class="n">steamcmd</span><span class="p">\</span><span class="n">steamcmd</span><span class="p">.</span><span class="py">exe</span> <span class="p">+</span><span class="n">force_install_dir</span> <span class="s2">&#34;C:\steam-check&#34;</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="p">+</span><span class="n">login</span> <span class="n">anonymous</span> <span class="p">+</span><span class="n">app_update</span> <span class="mf">294420</span></span></span></code></pre></div></div>
  </div>
</div><p>Keep the quotes around the folder, because a path with a space splits at the space. Watch the console. One of two things comes first:</p>
<ul>
<li>A line that starts <code>Update state</code>, such as <code>Update state (0x61) downloading, progress: 0.00 (0 / 6103829589)</code>. The install has begun, so anonymous login can install the server. Press Ctrl+C.</li>
<li><code>ERROR! Failed to install app '233780' (No subscription)</code>. Anonymous login can&rsquo;t install it. SteamCMD waits at its <code>Steam&gt;</code> prompt, so type <code>quit</code>.</li>
</ul>
<p>Then delete the 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">Remove-Item</span> <span class="n">-LiteralPath</span> <span class="s1">&#39;\\?\C:\steam-check&#39;</span> <span class="n">-Recurse</span> <span class="n">-Force</span></span></span></code></pre></div></div>
  </div>
</div><p>The <code>\\?\</code> prefix costs nothing and is there for long paths. <code>Remove-Item</code> in Windows PowerShell 5.1 stops at 260 characters, and some games nest folders deeper than that. An install stopped in its first seconds rarely gets that far, but if you let one run longer, the prefix is what deletes it.</p>
<p>Stopping SteamCMD this way can make its next start print <code>Looks like steam didn't shutdown cleanly, scheduling immediate update check</code>. Nothing is wrong.</p>
<!-- style-check: end procedure -->
<h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/find-steam-dedicated-server-app-id/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="no-subscription-when-you-install"><code>No subscription</code> when you install<a class="heading-anchor" href="https://looselydecoupled.com/find-steam-dedicated-server-app-id/#no-subscription-when-you-install"
     aria-label="Link to section: No subscription when you install" 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">Failed</span> <span class="console__token">to</span> <span class="console__token">install</span> <span class="console__token">app</span> <span class="console__token">&#39;233780&#39;</span> <span class="console__token">(No</span> <span class="console__token">subscription)</span></span></code></pre>
    </div>
  </div></div><p>An anonymous login isn&rsquo;t allowed to install that app. Three cases give the same message:</p>
<ul>
<li>The ID is the game&rsquo;s. Step 3 gives it for <code>251570</code>, 7 Days to Die.</li>
<li>The server isn&rsquo;t available to an anonymous login. The DayZ and Arma 3 servers answered this way. I didn&rsquo;t test whether a login works.</li>
<li>The ID doesn&rsquo;t exist. Step 3 gives it for <code>999999999</code>.</li>
</ul>
<p>Read the ID&rsquo;s record to tell them apart:</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="n">C:</span><span class="p">\</span><span class="n">steamcmd</span><span class="p">\</span><span class="n">steamcmd</span><span class="p">.</span><span class="py">exe</span> <span class="p">+</span><span class="n">login</span> <span class="n">anonymous</span> <span class="p">+</span><span class="n">app_info_print</span> <span class="p">&lt;</span><span class="nb">app-id</span><span class="p">&gt;</span> <span class="p">+</span><span class="n">quit</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Select-String</span> <span class="n">-Pattern</span> <span class="s1">&#39;^\t\t&#34;(name|type|parent)&#34;\t&#39;</span></span></span></code></pre></div></div>
  </div>
</div><p>A <code>name</code> without Server in it, with <code>type</code> <code>Game</code>, is the game. A name that says server means anonymous login can&rsquo;t install it. No lines at all means the lookup returned no record. Either no app has that ID, or SteamCMD couldn&rsquo;t reach Steam. To tell them apart, run the lookup again without the <code>Select-String</code> line, so SteamCMD&rsquo;s own output shows. A missing app still prints <code>Waiting for user info...OK</code>. A failed connection prints <code>FAILED (No Connection)</code> after retrying for a couple of minutes.</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</dd></div>
    <div class="tested__row"><dt>SteamCMD</dt> <dd>client version 1788292693, anonymous login</dd></div>
    <div class="tested__row"><dt>Servers</dt> <dd>25 checked, each install stopped as soon as it began or was refused</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>Why Does My Batch File Exit When I Call a Function?</title>
      <link>https://looselydecoupled.com/batch-file-exits-when-calling-function/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/batch-file-exits-when-calling-function/</guid>
      <pubDate>Mon, 07 Sep 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Windows Server</category>
      <category>Batch</category>
      <category>Automation</category>
      
      <description>The subroutine runs before anything calls it, and its goto :eof ends the whole script, because cmd.exe reads straight down through a label.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>A batch label does not create a function body that <code>cmd.exe</code> skips. Execution
runs straight through the label unless you redirect it.</p>
<p>Put the main work first, end it with <code>exit /b</code>, and place subroutines below it:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="BATCH">
  <div class="code-block__head"><span class="code-block__lang">BATCH</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-batch" data-lang="batch"><span class="line"><span class="cl"><span class="p">@</span><span class="k">echo</span> off
</span></span><span class="line"><span class="cl"><span class="k">setlocal</span> EnableExtensions
</span></span><span class="line"><span class="cl"><span class="k">call</span> <span class="p">:</span><span class="nl">timestamp</span>
</span></span><span class="line"><span class="cl"><span class="k">echo</span> [<span class="nv">%stamp%</span>] Work starts here
</span></span><span class="line"><span class="cl"><span class="k">exit</span> /b 0
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">:</span><span class="nl">timestamp</span>
</span></span><span class="line"><span class="cl"><span class="k">set</span> <span class="s2">&#34;stamp=</span><span class="nv">%DATE%</span><span class="s2"> </span><span class="nv">%TIME%</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">goto</span> <span class="p">:</span><span class="nl">eof</span></span></span></code></pre></div></div>
  </div>
</div><p><code>call :timestamp</code> creates a return point. <code>goto :eof</code> returns from that call.
Reached in the main batch context, it ends the batch instead.</p>

</div>

<p>You add a timestamp helper to a working batch file. The helper looks reasonable.
The next run prints almost nothing, and the real work never starts.</p>
<p>It&rsquo;s tempting to debug the timestamp expression. First check whether the
script ever reaches the line that calls it.</p>
<h2 id="minimal-reproduction-the-call-never-happens">Minimal reproduction: the call never happens<a class="heading-anchor" href="https://looselydecoupled.com/batch-file-exits-when-calling-function/#minimal-reproduction-the-call-never-happens"
     aria-label="Link to section: Minimal reproduction: the call never happens" data-pagefind-ignore></a>
</h2>
<p>Save this as <code>fall-through.cmd</code>:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="fall-through.cmd">
  <div class="code-block__head"><span class="code-block__lang code-block__file">fall-through.cmd</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-batch" data-lang="batch"><span class="line"><span class="cl"><span class="p">@</span><span class="k">echo</span> off
</span></span><span class="line"><span class="cl"><span class="k">echo</span> Starting script
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">:</span><span class="nl">timestamp</span>
</span></span><span class="line"><span class="cl"><span class="k">echo</span> Inside timestamp
</span></span><span class="line"><span class="cl"><span class="k">set</span> <span class="s2">&#34;stamp=</span><span class="nv">%DATE%</span><span class="s2"> </span><span class="nv">%TIME%</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">goto</span> <span class="p">:</span><span class="nl">eof</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">call</span> <span class="p">:</span><span class="nl">timestamp</span>
</span></span><span class="line"><span class="cl"><span class="k">echo</span> [<span class="nv">%stamp%</span>] Doing the work</span></span></code></pre></div></div>
  </div>
</div><p>Run it from an existing Command Prompt so the output stays visible:</p>
<div class="console"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Expected output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">C:\scripts&gt;</span> <span class="console__token">cmd</span> <span class="console__token">/d</span> <span class="console__token">/e:on</span> <span class="console__token">/c</span> <span class="console__token">fall-through.cmd</span>
</span><span class="line"><span class="console__token">Starting</span> <span class="console__token">script</span>
</span><span class="line"><span class="console__token">Inside</span> <span class="console__token">timestamp</span>
</span><span class="line">
</span><span class="line"><span class="console__token">C:\scripts&gt;</span></span></code></pre>
    </div>
  </div></div><p>That output is the clue: the helper ran even though the <code>call</code> below it was
never reached. Execution arrived there by reading downward.</p>
<p>A label marks a location. It doesn&rsquo;t surround or hide the commands beneath it.
The <code>goto :eof</code> then ends the current batch context, so both remaining lines
are unreachable on this path. Microsoft&rsquo;s <a href="https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/goto" rel="external noopener" target="_blank">goto 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/batch-file-exits-when-calling-function/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
documents the special end-of-file target; you don&rsquo;t need to define an <code>:eof</code> label.</p>
<h2 id="end-the-main-path-with-exit-b-before-the-subroutines">End the main path with <code>exit /b</code> before the subroutines<a class="heading-anchor" href="https://looselydecoupled.com/batch-file-exits-when-calling-function/#end-the-main-path-with-exit-b-before-the-subroutines"
     aria-label="Link to section: End the main path with exit /b before the subroutines" data-pagefind-ignore></a>
</h2>
<p>Here is the complete repaired example:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="no-fall-through.cmd">
  <div class="code-block__head"><span class="code-block__lang code-block__file">no-fall-through.cmd</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-batch" data-lang="batch"><span class="line"><span class="cl"><span class="p">@</span><span class="k">echo</span> off
</span></span><span class="line"><span class="cl"><span class="k">setlocal</span> EnableExtensions
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">echo</span> Starting script
</span></span><span class="line"><span class="cl"><span class="k">call</span> <span class="p">:</span><span class="nl">timestamp</span>
</span></span><span class="line"><span class="cl"><span class="k">echo</span> [<span class="nv">%stamp%</span>] Doing the work
</span></span><span class="line"><span class="cl"><span class="k">exit</span> /b 0
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">:</span><span class="nl">timestamp</span>
</span></span><span class="line"><span class="cl"><span class="k">echo</span> Inside timestamp
</span></span><span class="line"><span class="cl"><span class="k">set</span> <span class="s2">&#34;stamp=</span><span class="nv">%DATE%</span><span class="s2"> </span><span class="nv">%TIME%</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">goto</span> <span class="p">:</span><span class="nl">eof</span></span></span></code></pre></div></div>
  </div>
</div><p>Expected output, with the timestamp varying by locale and clock:</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">Starting script
</span></span><span class="line"><span class="cl">Inside timestamp
</span></span><span class="line"><span class="cl">[&lt;local date&gt; &lt;local time&gt;] Doing the work</span></span></code></pre></div></div>
  </div>
</div><p>The main path calls the helper, resumes after the call, does its work, and exits
before reaching the helper a second time. The helper is now reachable only
through an intentional call.</p>
<p>Moving a subroutine to the bottom <strong>without</strong> the main-path exit is incomplete.
The call returns correctly, but after the main work finishes, execution falls
into the helper again.</p>
<h2 id="what-call-goto-and-exit-b-each-do">What <code>call</code>, <code>goto</code> and <code>exit /b</code> each do<a class="heading-anchor" href="https://looselydecoupled.com/batch-file-exits-when-calling-function/#what-call-goto-and-exit-b-each-do"
     aria-label="Link to section: What call, goto and exit /b each do" data-pagefind-ignore></a>
</h2>
<p><code>call :label</code> starts a new batch context within the same file. When that context
ends, execution resumes at the statement after the call. <code>goto label</code> only jumps;
it doesn&rsquo;t arrange a return. See Microsoft&rsquo;s <a href="https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/call" rel="external noopener" target="_blank">call 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/batch-file-exits-when-calling-function/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>.</p>
<div class="table-wrap" data-scrollable data-label="Table: Command">
  <table>
    <thead>
      <tr><th scope="col">Command</th><th scope="col">Effect</th></tr>
    </thead>
    <tbody>
      <tr><td><code>call :timestamp</code></td><td>Enter the subroutine with a return point</td></tr>
      <tr><td><code>goto timestamp</code></td><td>Jump to the label without a return point</td></tr>
      <tr><td><code>goto :eof</code></td><td>End the current batch context</td></tr>
      <tr><td><code>exit /b 0</code></td><td>End the current batch context and set a success status</td></tr>
      <tr><td><code>exit</code></td><td>Exit the command interpreter</td></tr>
    </tbody>
  </table>
</div>
<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/batch-file-exits-when-calling-function/#i-triangle-alert"/></svg>
    <span>Warning</span>
  </p>
  <p>Plain <code>exit</code> inside a batch file exits the <em>command interpreter</em>, not only the
script. Run it from a Command Prompt you were using and that window closes,
taking any unsaved session state with it. <code>exit /b</code> is almost always what you
want.</p>
</div><p>The <code>/b</code> matters. <code>exit /b</code> ends the current batch context instead of cmd.exe,
as Microsoft&rsquo;s <a href="https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/exit" rel="external noopener" target="_blank">exit 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/batch-file-exits-when-calling-function/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
says. A <code>call :label</code> runs in its own context, so inside a subroutine <code>exit /b</code>
returns to the caller, and at the top level it ends the file. Use an explicit
code when the caller needs a result.</p>
<p>These label-call and <code>:eof</code> examples assume command extensions are enabled, as
they normally are. The repaired file makes that requirement explicit with
<code>setlocal EnableExtensions</code>.</p>
<h2 id="helpers-can-also-go-above-main">Helpers can also go above main<a class="heading-anchor" href="https://looselydecoupled.com/batch-file-exits-when-calling-function/#helpers-can-also-go-above-main"
     aria-label="Link to section: Helpers can also go above main" data-pagefind-ignore></a>
</h2>
<p>There&rsquo;s no rule that subroutines must live at the bottom. If you prefer helpers
first, jump over them on entry:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="BATCH">
  <div class="code-block__head"><span class="code-block__lang">BATCH</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-batch" data-lang="batch"><span class="line"><span class="cl"><span class="p">@</span><span class="k">echo</span> off
</span></span><span class="line"><span class="cl"><span class="k">setlocal</span> EnableExtensions
</span></span><span class="line"><span class="cl"><span class="k">goto</span> <span class="nl">main</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">:</span><span class="nl">timestamp</span>
</span></span><span class="line"><span class="cl"><span class="k">set</span> <span class="s2">&#34;stamp=</span><span class="nv">%DATE%</span><span class="s2"> </span><span class="nv">%TIME%</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">goto</span> <span class="p">:</span><span class="nl">eof</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">:</span><span class="nl">main</span>
</span></span><span class="line"><span class="cl"><span class="k">call</span> <span class="p">:</span><span class="nl">timestamp</span>
</span></span><span class="line"><span class="cl"><span class="k">echo</span> [<span class="nv">%stamp%</span>] Doing the work
</span></span><span class="line"><span class="cl"><span class="k">exit</span> /b 0</span></span></code></pre></div></div>
  </div>
</div><p>Placement is a readability choice. An unambiguous execution path is the fix.</p>
<h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/batch-file-exits-when-calling-function/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<p><strong>The window disappears immediately.</strong> Run the file from an existing Command
Prompt. A window opened only to run a batch file can close when that file
ends; the disappearance alone doesn&rsquo;t tell you which command caused it.</p>
<p><strong>The helper runs twice.</strong> Look for a missing <code>exit /b</code> or <code>goto :eof</code> between
the end of the main work and the first helper label.</p>
<p><strong>The main work never resumes.</strong> Check for <code>goto timestamp</code> where you intended
<code>call :timestamp</code>, or plain <code>exit</code> inside the helper.</p>
<p><strong>I only needed a timestamp in a log.</strong> A separate subroutine may be unnecessary:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="BATCH">
  <div class="code-block__head"><span class="code-block__lang">BATCH</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-batch" data-lang="batch"><span class="line"><span class="cl"><span class="k">echo</span> [<span class="nv">%DATE%</span> <span class="nv">%TIME%</span>] Backup started<span class="p">&gt;&gt;</span><span class="s2">&#34;backup.log&#34;</span></span></span></code></pre></div></div>
  </div>
</div><p>This is a display timestamp, not a locale-independent filename format. Keep
this simple example outside a parenthesized block, where percent-variable
expansion introduces a separate timing issue.</p>
<p>When a batch file stops early, follow the lines it actually executes. A label
that looks like a function declaration is still on that path.</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>Shell</dt> <dd>cmd.exe</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>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>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 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>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>
    
  </channel>
</rss>
