<?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>Host a Steam Dedicated Server on Windows · Loosely Decoupled</title>
    <link>https://looselydecoupled.com/series/steam-dedicated-server-windows/</link>
    <description>Everything you need to run your own Steam dedicated server on Windows, from the first install to moving it to a new machine. The examples use 7 Days to Die, and the same steps work for most Steam game servers.</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/series/steam-dedicated-server-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>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>
    
  </channel>
</rss>
