<?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>Guide · Loosely Decoupled</title>
    <link>https://looselydecoupled.com/shape/guide/</link>
    <description>Answers to PowerShell, Windows Server and OpenSSH questions the docs skip, with the reasoning behind them. Answer first, discussion after.</description>
    <language>en-us</language>
    
    <copyright>© 2026 Emmanuel Tsouris</copyright>
    <lastBuildDate>Fri, 16 Oct 2026 00:00:00 +0000</lastBuildDate>
    <atom:link href="https://looselydecoupled.com/shape/guide/index.xml" rel="self" type="application/rss+xml" />
    
    <item>
      <title>Why Is the uv Cache So Large on My Mac?</title>
      <link>https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/</guid>
      <pubDate>Fri, 16 Oct 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>macOS</category>
      <category>Python</category>
      
      <description>Coding agents fill the uv cache when they launch MCP servers with uvx; prune it safely and see how much disk you really get back.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Quit every coding agent and editor that launches MCP servers, then run <code>uv cache prune</code>. A running session holds the cache lock, so prune prints &ldquo;Cache is currently in-use, waiting for other uv processes to finish&rdquo; and, after uv&rsquo;s lock timeout, gives up. Do not use <code>--force</code> to skip it. Expect much less back than prune reports: on this Mac it reported 111.1 GiB (about 119 GB) removed and the disk gained 27.5 GB, most likely because cloned files share blocks.</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">uv</span> <span class="console__token">cache</span> <span class="console__token">size</span> <span class="console__token">-H</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">df</span> <span class="console__token">-h</span> <span class="console__token">/</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">info</span> <span class="console__token">/</span> <span class="console__token">|</span> <span class="console__token">grep</span> <span class="console__token">&#34;Container</span> <span class="console__token">Free</span> <span class="console__token">Space&#34;</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">lsof</span> <span class="console__token">&#34;$(uv</span> <span class="console__token">cache</span> <span class="console__token">dir)/.lock&#34;</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">uv</span> <span class="console__token">cache</span> <span class="console__token">prune</span></span></code></pre>
    </div>
  </div></div>
</div>

<p>My Mac was down to 38.4 GB free on a 1 TB disk. The largest directory on it was a 132.7 GiB uv cache that I had never thought about. The coding agents I use launch MCP servers with <code>uvx</code>, and that was enough to fill it.</p>
<p><code>uvx</code> is uv&rsquo;s command for running a Python tool from a temporary environment, without installing it. If you have never run uv by hand, an agent or editor ran it for you. This page is part of a series on <a href="https://looselydecoupled.com/series/developer-mac-disk-cleanup/">cleaning up a developer Mac&rsquo;s disk</a>. Part one of the series, finding what&rsquo;s filling the disk, is how to know the cache is the cause; you don&rsquo;t need it to follow the steps here.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>uv</dt> <dd>0.11.12</dd></div>
    <div class="tested__row"><dt>macOS</dt> <dd>27.0.1, Apple silicon, APFS</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>
<h2 id="check-the-size-and-location-of-the-cache">Check the size and location of the cache<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#check-the-size-and-location-of-the-cache"
     aria-label="Link to section: Check the size and location of the cache" data-pagefind-ignore></a>
</h2>
<p>The environments <code>uvx</code> builds go in uv&rsquo;s cache. Ask uv for the size and the directory:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">uv</span> <span class="console__token">cache</span> <span class="console__token">size</span> <span class="console__token">-H</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">warning:</span> <span class="console__token">`uv</span> <span class="console__token">cache</span> <span class="console__token">size`</span> <span class="console__token">is</span> <span class="console__token">experimental</span> <span class="console__token">and</span> <span class="console__token">may</span> <span class="console__token">change</span> <span class="console__token">without</span> <span class="console__token">warning.</span> <span class="console__token">Pass</span> <span class="console__token">`--preview-features</span> <span class="console__token">cache-size`</span> <span class="console__token">to</span> <span class="console__token">disable</span> <span class="console__token">this</span> <span class="console__token">warning.</span>
</span><span class="line"><span class="console__token">132.7GiB</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">$ </span><span class="console__token">uv</span> <span class="console__token">cache</span> <span class="console__token">dir</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">/Users/&lt;user&gt;/.cache/uv</span></span></code></pre>
    </div>
  </div></div><p>uv labels <code>uv cache size</code> as experimental, which is the reason for the warning. The number is still the one to start from. The <code>--cache-dir</code> option, the <code>UV_CACHE_DIR</code> and <code>XDG_CACHE_HOME</code> variables and the <code>tool.uv.cache-dir</code> setting move the directory, according to <a href="https://docs.astral.sh/uv/concepts/cache/" rel="external noopener" target="_blank">uv&rsquo;s cache 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/uv-cache-huge-mac-uvx-mcp-servers/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>. Every command below reads the directory from <code>uv cache dir</code>, so it follows a moved cache.</p>
<h2 id="find-what-fills-the-cache">Find what fills the cache<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#find-what-fills-the-cache"
     aria-label="Link to section: Find what fills the cache" data-pagefind-ignore></a>
</h2>
<p>Environments that <code>uvx</code> builds sit in the cache&rsquo;s <code>archive-v0</code> directory. Count its entries, then count the ones that are Python environments, which have a <code>pyvenv.cfg</code> at their root:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ls</span> <span class="console__token">&#34;$(uv</span> <span class="console__token">cache</span> <span class="console__token">dir)&#34;/archive-v0</span> <span class="console__token">|</span> <span class="console__token">wc</span> <span class="console__token">-l</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">find</span> <span class="console__token">&#34;$(uv</span> <span class="console__token">cache</span> <span class="console__token">dir)&#34;/archive-v0</span> <span class="console__token">-maxdepth</span> <span class="console__token">2</span> <span class="console__token">-name</span> <span class="console__token">pyvenv.cfg</span> <span class="console__token">|</span> <span class="console__token">wc</span> <span class="console__token">-l</span></span></code></pre>
    </div>
  </div></div><p>On this Mac, <code>archive-v0</code> held 4,005 entries, and 2,562 of them were environments. To see which MCP servers the environments belong to, count the executables with <code>mcp</code> in their names in the environments&rsquo; <code>bin</code> directories (a server named otherwise needs its own pattern):</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ls</span> <span class="console__token">-d</span> <span class="console__token">&#34;$(uv</span> <span class="console__token">cache</span> <span class="console__token">dir)&#34;/archive-v0/*/bin/*mcp*</span> <span class="console__token">|</span> <span class="console__token">xargs</span> <span class="console__token">-n1</span> <span class="console__token">basename</span> <span class="console__token">|</span> <span class="console__token">sort</span> <span class="console__token">|</span> <span class="console__token">uniq</span> <span class="console__token">-c</span> <span class="console__token">|</span> <span class="console__token">sort</span> <span class="console__token">-rn</span> <span class="console__token">|</span> <span class="console__token">head</span></span></code></pre>
    </div>
  </div></div><p>Nearly all of the environments were copies of three MCP servers, with 1,150, 1,143 and 191 copies, plus 50 of a fourth. One server&rsquo;s environments held only 158 distinct sets of installed packages, and environments built on the same day had the same installed packages.</p>
<h3 id="why-each-launch-can-add-an-environment">Why each launch can add an environment<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#why-each-launch-can-add-an-environment"
     aria-label="Link to section: Why each launch can add an environment" data-pagefind-ignore></a>
</h3>
<p>An MCP server configured as <code>uvx &lt;server&gt;@latest</code> asks uv to resolve the latest version on every launch. With <code>uvx -v</code>, the log shows uv fetching the latest version and revalidating the index. Launching one server did not explain more than a thousand copies, so I measured when a launch adds an environment:</p>
<div class="table-wrap" data-scrollable data-label="Table: Launches">
  <table>
    <thead>
      <tr><th scope="col">Launches</th><th scope="col">Resolution</th><th class="align-right" scope="col">Environments added</th></tr>
    </thead>
    <tbody>
      <tr><td>One</td><td>New (not cached yet)</td><td class="align-right">1</td></tr>
      <tr><td>Four at the same moment</td><td>New</td><td class="align-right">4</td></tr>
      <tr><td>Four at the same moment</td><td>Already cached</td><td class="align-right">0</td></tr>
      <tr><td>Four, <code>@latest</code> and plain</td><td>Already cached</td><td class="align-right">0</td></tr>
    </tbody>
  </table>
</div>
<p>When several sessions start together and the resolution isn&rsquo;t cached yet, each launch builds its own environment. Once the resolution is cached, launches add nothing. That implies pinning a version, instead of <code>@latest</code>, would stop most of the copies. I didn&rsquo;t test that. I also didn&rsquo;t check whether a later release of uv reuses an environment that another process built moments earlier. All of this is uv 0.11.12.</p>
<h2 id="choose-between-uv-cache-prune-and-uv-cache-clean">Choose between uv cache prune and uv cache clean<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#choose-between-uv-cache-prune-and-uv-cache-clean"
     aria-label="Link to section: Choose between uv cache prune and uv cache clean" data-pagefind-ignore></a>
</h2>
<p><code>uv cache prune</code> removes the cache entries nothing uses, and all centralized project environments, which uv recreates as needed. <code>uv cache clean</code> removes every entry and empties the cache; <code>uv cache clean &lt;package&gt;</code> removes one package&rsquo;s entries. Both are described in <a href="https://docs.astral.sh/uv/concepts/cache/" rel="external noopener" target="_blank">uv&rsquo;s cache 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/uv-cache-huge-mac-uvx-mcp-servers/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>. This page uses prune, which here took the cache from 132.7 GiB to 3.9 GiB and left 540 entries in place. I didn&rsquo;t run clean.</p>
<h2 id="quit-every-session-that-holds-the-cache-lock">Quit every session that holds the cache lock<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#quit-every-session-that-holds-the-cache-lock"
     aria-label="Link to section: Quit every session that holds the cache lock" data-pagefind-ignore></a>
</h2>
<p>Each running <code>uvx</code> process holds the cache lock, the <code>.lock</code> file in the cache directory, for as long as its server runs. Cache-modifying commands wait for the lock, so a prune started now waits too. List the holders:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">lsof</span> <span class="console__token">&#34;$(uv</span> <span class="console__token">cache</span> <span class="console__token">dir)/.lock&#34;</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">COMMAND</span>   <span class="console__token">PID</span>    <span class="console__token">USER</span>   <span class="console__token">FD</span>   <span class="console__token">TYPE</span> <span class="console__token">DEVICE</span> <span class="console__token">SIZE/OFF</span>    <span class="console__token">NODE</span> <span class="console__token">NAME</span>
</span><span class="line"><span class="console__token">uv</span>      <span class="console__token">&lt;pid&gt;</span> <span class="console__token">&lt;user&gt;</span>   <span class="console__token">9u</span>   <span class="console__token">REG</span>  <span class="console__token">&lt;dev&gt;</span>        <span class="console__token">0</span> <span class="console__token">&lt;inode&gt;</span> <span class="console__token">/Users/&lt;user&gt;/.cache/uv/.lock</span>
</span><span class="line"><span class="console__token">uv</span>      <span class="console__token">&lt;pid&gt;</span> <span class="console__token">&lt;user&gt;</span>   <span class="console__token">9u</span>   <span class="console__token">REG</span>  <span class="console__token">&lt;dev&gt;</span>        <span class="console__token">0</span> <span class="console__token">&lt;inode&gt;</span> <span class="console__token">/Users/&lt;user&gt;/.cache/uv/.lock</span></span></code></pre>
    </div>
  </div></div><p><code>lsof</code> prints nothing when the lock is free; empty output from a path that is not your cache directory is not a clear lock. Each row is one <code>uvx</code> process. To find the session that started it, look up the parent of each <code>&lt;pid&gt;</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">$ </span><span class="console__token">ps</span> <span class="console__token">-o</span> <span class="console__token">ppid=</span> <span class="console__token">-p</span> <span class="console__token">&lt;pid&gt;</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">ps</span> <span class="console__token">-o</span> <span class="console__token">comm=</span> <span class="console__token">-p</span> <span class="console__token">&lt;ppid&gt;</span></span></code></pre>
    </div>
  </div></div><p>On this Mac, one holder was launched by a Claude Code session and another by the VS Code extension host. Quitting a session or an editor ends its <code>uvx</code> children, which release the lock:</p>
<ol>
<li>Run <code>lsof &quot;$(uv cache dir)/.lock&quot;</code>.</li>
<li>Look up the parent of each <code>&lt;pid&gt;</code>.</li>
<li>Quit that session or editor.</li>
<li>Run <code>lsof</code> again until it prints nothing.</li>
</ol>
<h2 id="record-free-space-before-pruning">Record free space before pruning<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#record-free-space-before-pruning"
     aria-label="Link to section: Record free space before pruning" data-pagefind-ignore></a>
</h2>
<p>The comparison needs readings from before the prune, so take them now and save the output:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">df</span> <span class="console__token">-h</span> <span class="console__token">/</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">info</span> <span class="console__token">/</span> <span class="console__token">|</span> <span class="console__token">grep</span> <span class="console__token">&#34;Container</span> <span class="console__token">Free</span> <span class="console__token">Space&#34;</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">uv</span> <span class="console__token">cache</span> <span class="console__token">size</span> <span class="console__token">-H</span></span></code></pre>
    </div>
  </div></div><h2 id="run-the-prune">Run the prune<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#run-the-prune"
     aria-label="Link to section: Run the prune" data-pagefind-ignore></a>
</h2>
<p>This command deletes cached environments. With nothing running, it removed every environment in the cache, not only the ones that no process referenced. The next launch of each MCP server builds a fresh environment. Run it from a plain Terminal window, not from inside one of the sessions you quit:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">uv</span> <span class="console__token">cache</span> <span class="console__token">prune</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">Pruning</span> <span class="console__token">cache</span> <span class="console__token">at:</span> <span class="console__token">.cache/uv</span>
</span><span class="line"><span class="console__token">Removed</span> <span class="console__token">7278379</span> <span class="console__token">files</span> <span class="console__token">(111.1GiB)</span></span></code></pre>
    </div>
  </div></div><p>It took 409 seconds. Do not interrupt it.</p>
<h2 id="check-the-space-you-got-back">Check the space you got back<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#check-the-space-you-got-back"
     aria-label="Link to section: Check the space you got back" data-pagefind-ignore></a>
</h2>
<p>The free space went up by much less than 111.1 GiB. Run the same three commands again and compare them with the readings you saved:</p>
<div class="table-wrap" data-scrollable data-label="Table: Reading">
  <table>
    <thead>
      <tr><th scope="col">Reading</th><th scope="col">Before</th><th scope="col">After</th></tr>
    </thead>
    <tbody>
      <tr><td><code>df -h /</code>, available</td><td>36Gi</td><td>61Gi</td></tr>
      <tr><td>Container Free Space</td><td>38.4 GB (38,372,454,400 bytes)</td><td>65.8 GB (65,847,853,056 bytes)</td></tr>
      <tr><td><code>uv cache size -H</code></td><td>132.7GiB</td><td>3.9GiB</td></tr>
    </tbody>
  </table>
</div>
<p>The container gained 27.5 GB. uv reported 111.1 GiB removed, which is about 119 GB. uv prints binary units (GiB, 2^30 bytes) and <code>diskutil</code> prints decimal ones (GB), so do not compare the two figures as equals. Even in the same unit, the disk got back about a quarter of what uv reported.</p>
<p>The explanation that fits is the link mode; I didn&rsquo;t test it directly. uv&rsquo;s default on macOS is <code>clone</code>, per <a href="https://docs.astral.sh/uv/reference/settings/#link-mode" rel="external noopener" target="_blank">uv&rsquo;s settings reference<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>, which makes APFS copy-on-write clones. Environments share blocks with each other and with the cached wheels. uv 0.11.12 counts every cloned file at its full size, and deleting a clone frees nothing when another file still uses its blocks. <a href="https://docs.astral.sh/uv/concepts/cache/" rel="external noopener" target="_blank">uv&rsquo;s cache 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/uv-cache-huge-mac-uvx-mcp-servers/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a> describes a <code>cache-physical-space</code> preview feature that accounts for copy-on-write clones; I didn&rsquo;t test it.</p>
<p><code>du -sh</code> agreed with <code>uv cache size</code> at 3.9G after the prune. That is a drop of 128.8 GiB from 132.7GiB, while prune printed 111.1GiB removed; I did not find why the two differ.</p>
<p>A local snapshot could also hold space after a delete. On this Mac, <code>tmutil listlocalsnapshots /</code> listed none, and <code>diskutil apfs listSnapshots</code> showed none on the data volume, so no snapshot was holding any.</p>
<h2 id="confirm-your-mcp-servers-still-start">Confirm your MCP servers still start<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#confirm-your-mcp-servers-still-start"
     aria-label="Link to section: Confirm your MCP servers still start" data-pagefind-ignore></a>
</h2>
<p>The prune left <code>archive-v0</code> with 540 entries and no environments, and emptied <code>environments-v2</code>. Start each agent or editor that uses MCP servers and open a session. Each server builds a fresh environment on its next launch; on this Mac, one appeared at the next session start. Check:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">find</span> <span class="console__token">&#34;$(uv</span> <span class="console__token">cache</span> <span class="console__token">dir)&#34;/archive-v0</span> <span class="console__token">-maxdepth</span> <span class="console__token">2</span> <span class="console__token">-name</span> <span class="console__token">pyvenv.cfg</span> <span class="console__token">|</span> <span class="console__token">wc</span> <span class="console__token">-l</span></span></code></pre>
    </div>
  </div></div><p>A count above zero means at least one server built an environment. Check each agent&rsquo;s MCP server list or status view for errors.</p>
<h2 id="do-this-with-a-coding-agent">Do this with a coding agent<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#do-this-with-a-coding-agent"
     aria-label="Link to section: Do this with a coding agent" data-pagefind-ignore></a>
</h2>
<p>Paste this prompt into your own local coding agent. It measures and reports, and it proposes before anything is removed:</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">Investigate my uv cache on this Mac. Measure and report only; change nothing yet.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">1. Report the cache directory (uv cache dir) and its size (uv cache size -H).
</span></span><span class="line"><span class="cl">2. Report how many entries in archive-v0 are Python environments (a pyvenv.cfg
</span></span><span class="line"><span class="cl">   at the root), and which tools they belong to (the executables in their bin
</span></span><span class="line"><span class="cl">   directories).
</span></span><span class="line"><span class="cl">3. Report free space two ways: df -h / and diskutil info / (Container Free
</span></span><span class="line"><span class="cl">   Space).
</span></span><span class="line"><span class="cl">4. List every process holding the cache lock (lsof on the .lock file in the
</span></span><span class="line"><span class="cl">   cache directory), and for each one its parent process, so I can tell which
</span></span><span class="line"><span class="cl">   agent or editor session to quit.
</span></span><span class="line"><span class="cl">5. Separate what is safe from what is in use: running uv processes, open agent
</span></span><span class="line"><span class="cl">   or editor sessions, and any project whose own virtual environment lives
</span></span><span class="line"><span class="cl">   inside the cache. If this session is itself one of the lock holders, say so
</span></span><span class="line"><span class="cl">   and stop; I will run the prune from a plain Terminal.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Then propose each removal as a separate item, with the command and the current
</span></span><span class="line"><span class="cl">cache size as an upper bound. uv has no dry run, so do not state an expected
</span></span><span class="line"><span class="cl">figure, and note that APFS clones make the real gain smaller. Remove nothing until I approve that item. Remove only
</span></span><span class="line"><span class="cl">through uv&#39;s own command (uv cache prune). Never delete files under the cache
</span></span><span class="line"><span class="cl">directory by hand and never use --force. Afterwards, report free space again
</span></span><span class="line"><span class="cl">with the same two readings and say how far the figure uv printed is from the
</span></span><span class="line"><span class="cl">space actually gained.</span></span></code></pre></div></div>
  </div>
</div><h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="cache-is-currently-in-use-waiting-for-other-uv-processes-to-finish">Cache is currently in-use, waiting for other uv processes to finish<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#cache-is-currently-in-use-waiting-for-other-uv-processes-to-finish"
     aria-label="Link to section: Cache is currently in-use, waiting for other uv processes to finish" 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">Cache</span> <span class="console__token">is</span> <span class="console__token">currently</span> <span class="console__token">in-use,</span> <span class="console__token">waiting</span> <span class="console__token">for</span> <span class="console__token">other</span> <span class="console__token">uv</span> <span class="console__token">processes</span> <span class="console__token">to</span> <span class="console__token">finish</span> <span class="console__token">(use</span> <span class="console__token">`--force`</span> <span class="console__token">to</span> <span class="console__token">override)</span></span></code></pre>
    </div>
  </div></div><p>Another uv process holds the cache lock, and <code>uv cache prune</code> and <code>uv cache clean</code> wait for it. Run <code>lsof &quot;$(uv cache dir)/.lock&quot;</code>, quit the session that owns each holder as described earlier, and run the command again. Nothing was changed while it waited: I stopped a prune that had waited eight seconds, and the cache was unchanged.</p>
<p>These <code>uv cache</code> commands give up after a timeout; <a href="https://docs.astral.sh/uv/concepts/cache/" rel="external noopener" target="_blank">uv&rsquo;s cache 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/uv-cache-huge-mac-uvx-mcp-servers/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a> gives its value and the <code>UV_LOCK_TIMEOUT</code> environment variable that changes it. Waiting longer does not help while a session still runs its server.</p>
<p>Do not use <code>--force</code>. The documentation reserves it for cases where no other uv process is reading or writing the cache, and a running <code>uvx</code> server is exactly such a process.</p>
]]></content:encoded>
    </item>
    
    <item>
      <title>What&#39;s Taking Up All the Space on My Developer Mac?</title>
      <link>https://looselydecoupled.com/mac-disk-full-developer-caches/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/mac-disk-full-developer-caches/</guid>
      <pubDate>Thu, 15 Oct 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>macOS</category>
      <category>Python</category>
      
      <description>Find which developer tool is filling your Mac&#39;s disk, such as uv, Xcode or Docker, and which cleanup applies.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Record the free space, then narrow down one level at a time with <code>du</code> until a
directory belongs to one tool. That tells you which cleanup you need. Clean it
with that tool&rsquo;s own command where it has one, never with <code>rm</code> on a cache a
tool manages, and record the free space again; the cleanup&rsquo;s own report is not what the Mac got 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">$ </span><span class="console__token">diskutil</span> <span class="console__token">info</span> <span class="console__token">/</span> <span class="console__token">|</span> <span class="console__token">grep</span> <span class="console__token">&#34;Container</span> <span class="console__token">Free</span> <span class="console__token">Space&#34;</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">du</span> <span class="console__token">-hd</span> <span class="console__token">1</span> <span class="console__token">~</span> <span class="console__token">2&gt;/dev/null</span> <span class="console__token">|</span> <span class="console__token">sort</span> <span class="console__token">-hr</span> <span class="console__token">|</span> <span class="console__token">head</span> <span class="console__token">-15</span></span></code></pre>
    </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>macOS</dt> <dd>27.0.1, MacBook Pro (Apple silicon)</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>

</div>

<p>My Mac had 37.7 GB free on a 1 TB disk. I expected Xcode to be the reason,
since I keep a full set of iOS simulators, and Xcode did hold 79 GiB. The
largest directory was a 132 GiB uv cache, which I had never thought about: most
of the entries in it were environments <code>uvx</code> had built for MCP servers,
covered in part 2.
Which directory is largest on your Mac depends on what you build and which
tools you run. The method for finding it is below, and the
<a href="https://looselydecoupled.com/series/developer-mac-disk-cleanup/">series</a> covers each cause.</p>
<h2 id="record-the-free-space-before-you-start">Record the free space before you start<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#record-the-free-space-before-you-start"
     aria-label="Link to section: Record the free space before you start" data-pagefind-ignore></a>
</h2>
<p>On a modern Mac, <code>df -h /</code> reports the sealed system volume, so its <code>Used</code>
column is misleading:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">df</span> <span class="console__token">-h</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">Filesystem</span>        <span class="console__token">Size</span>    <span class="console__token">Used</span>   <span class="console__token">Avail</span> <span class="console__token">Capacity</span> <span class="console__token">iused</span> <span class="console__token">ifree</span> <span class="console__token">%iused</span>  <span class="console__token">Mounted</span> <span class="console__token">on</span>
</span><span class="line"><span class="console__token">/dev/disk3s1s1</span>   <span class="console__token">926Gi</span>    <span class="console__token">13Gi</span>    <span class="console__token">40Gi</span>    <span class="console__token">24%</span>    <span class="console__token">484k</span>  <span class="console__token">423M</span>    <span class="console__token">0%</span>   <span class="console__token">/</span></span></code></pre>
    </div>
  </div></div><p><code>/</code> is the sealed system snapshot. Your files live on the Data volume in the
same APFS container. Every volume in a container draws on one pool of free
space, so <code>Used</code> for <code>/</code> counts the system alone, and <code>Avail</code> is the pool. The
container&rsquo;s own figure comes from the startup disk&rsquo;s container only, so an
external APFS drive cannot be mistaken for 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">$ </span><span class="console__token">diskutil</span> <span class="console__token">info</span> <span class="console__token">/</span> <span class="console__token">|</span> <span class="console__token">grep</span> <span class="console__token">&#34;Container</span> <span class="console__token">Free</span> <span class="console__token">Space&#34;</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">Container</span> <span class="console__token">Free</span> <span class="console__token">Space:</span>      <span class="console__token">65.8</span> <span class="console__token">GB</span> <span class="console__token">(65847853056</span> <span class="console__token">Bytes)</span> <span class="console__token">(exactly</span> <span class="console__token">128609088</span> <span class="console__token">512-Byte-Units)</span></span></code></pre>
    </div>
  </div></div><p>This reading was taken after the cleanup, so it does not match the <code>df</code>
output above, which was taken partway through, or the 37.7 GB before any
cleanup. <code>df -h</code> prints GiB and
<code>diskutil</code> prints GB, so the same moment looks different in the two units. Use one of them for every reading, or the difference
between the units looks like progress. A Mac keeps writing while you work, so
treat a change of a gigabyte or two as noise.</p>
<h2 id="find-whats-taking-up-space-with-du">Find what&rsquo;s taking up space with du<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#find-whats-taking-up-space-with-du"
     aria-label="Link to section: Find what’s taking up space with du" data-pagefind-ignore></a>
</h2>
<p><code>du</code> reads sizes and changes nothing. <code>2&gt;/dev/null</code> hides the permission
errors that protected directories print.</p>
<!-- style-check: procedure -->
<ol>
<li>
<p>List the largest directories in your home directory:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">du</span> <span class="console__token">-hd</span> <span class="console__token">1</span> <span class="console__token">~</span> <span class="console__token">2&gt;/dev/null</span> <span class="console__token">|</span> <span class="console__token">sort</span> <span class="console__token">-hr</span> <span class="console__token">|</span> <span class="console__token">head</span> <span class="console__token">-15</span></span></code></pre>
    </div>
  </div></div></li>
<li>
<p>Skip the first line, which is the total for the directory you ran it on.
Run the same command on the largest directory below it, and repeat until
the directory belongs to one tool. On this Mac, that took three levels:
<code>~/.cache</code>, then <code>~/.cache/uv</code>, then <code>~/.cache/uv/archive-v0</code>.</p>
</li>
<li>
<p>Check the two places that hide the most, even if they didn&rsquo;t top the first
list:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">du</span> <span class="console__token">-hd</span> <span class="console__token">1</span> <span class="console__token">~/Library/Caches</span> <span class="console__token">2&gt;/dev/null</span> <span class="console__token">|</span> <span class="console__token">sort</span> <span class="console__token">-hr</span> <span class="console__token">|</span> <span class="console__token">head</span> <span class="console__token">-15</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">du</span> <span class="console__token">-hd</span> <span class="console__token">1</span> <span class="console__token">~/Library/Developer</span> <span class="console__token">2&gt;/dev/null</span> <span class="console__token">|</span> <span class="console__token">sort</span> <span class="console__token">-hr</span></span></code></pre>
    </div>
  </div></div></li>
<li>
<p>For a package manager, ask the tool where its cache is rather than
assuming a path. pip on macOS uses <code>~/Library/Caches/pip</code>, not
<code>~/.cache/pip</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">$ </span><span class="console__token">uv</span> <span class="console__token">cache</span> <span class="console__token">dir</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">/Users/&lt;user&gt;/.cache/uv</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">$ </span><span class="console__token">python3</span> <span class="console__token">-m</span> <span class="console__token">pip</span> <span class="console__token">cache</span> <span class="console__token">dir</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">/Users/&lt;user&gt;/Library/Caches/pip</span></span></code></pre>
    </div>
  </div></div></li>
</ol>
<!-- style-check: end procedure -->
<p>A <code>du</code> total is an upper bound on what deleting frees. APFS clones share
their blocks with the original until one of them changes, and <code>du</code> counts a
cloned file at full size wherever it appears.</p>
<h2 id="match-the-directory-to-its-cause">Match the directory to its cause<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#match-the-directory-to-its-cause"
     aria-label="Link to section: Match the directory to its cause" data-pagefind-ignore></a>
</h2>
<p>These are the directories that were large on this Mac, and the part of this
series that covers each one:</p>
<div class="table-wrap" data-scrollable data-label="Table: Directory and what fills it">
  <table>
    <thead>
      <tr><th scope="col">Directory and what fills it</th><th class="align-right" scope="col">Size here</th><th scope="col">Covered in</th></tr>
    </thead>
    <tbody>
      <tr><td><code>~/.cache/uv</code><br>Environments that <code>uvx</code> builds for MCP servers</td><td class="align-right">132 GiB</td><td>Part 2</td></tr>
      <tr><td><code>~/Library/Developer/CoreSimulator/Devices</code><br>iOS simulators and the apps installed in them</td><td class="align-right">44 GiB</td><td>Part 5</td></tr>
      <tr><td><code>~/Library/Developer/Xcode/iOS DeviceSupport</code><br>Debug symbols for each device and iOS version you&rsquo;ve connected</td><td class="align-right">35 GiB</td><td>Part 5</td></tr>
      <tr><td><code>~/Library/Containers/com.docker.docker</code><br>Docker Desktop&rsquo;s VM disk, <code>Docker.raw</code></td><td class="align-right">32 GiB</td><td>Part 3</td></tr>
      <tr><td><code>~/.npm/_npx</code><br>Packages that <code>npx</code> runs, MCP servers among them</td><td class="align-right">8.9 GiB</td><td>Part 4</td></tr>
      <tr><td><code>.build</code>, <code>node_modules</code>, <code>.venv</code> in your projects<br>Build output and dependencies, including projects you no longer work on</td><td class="align-right">varies</td><td>Part 6</td></tr>
      <tr><td><code>~/Library/Caches/Homebrew</code><br>Cached downloads of formulae and casks</td><td class="align-right">1.1 GiB</td><td><a href="https://looselydecoupled.com/mac-disk-full-developer-caches/#clear-homebrew-downloads-and-ollama-logs">Below</a></td></tr>
      <tr><td><code>~/.ollama/logs</code><br>Ollama&rsquo;s server and app logs</td><td class="align-right">947 MB</td><td><a href="https://looselydecoupled.com/mac-disk-full-developer-caches/#clear-homebrew-downloads-and-ollama-logs">Below</a></td></tr>
    </tbody>
  </table>
</div>
<p>For the uv cache, read <a href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/">part 2</a>
before you prune: a running coding session holds a lock that makes the prune
wait, and the prune reports far more than the disk gets back. The other parts
are listed on the series page.</p>
<p>Some directories stay whatever their size. A Docker volume holds an app&rsquo;s
data, not cache. An app&rsquo;s <code>Application Support</code> directory holds its state.</p>
<h2 id="clear-homebrew-downloads-and-ollama-logs">Clear Homebrew downloads and Ollama logs<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#clear-homebrew-downloads-and-ollama-logs"
     aria-label="Link to section: Clear Homebrew downloads and Ollama logs" data-pagefind-ignore></a>
</h2>
<p>Two directories on the list need no part of their own.</p>
<p><code>brew cleanup -s</code> removes old versions and the cached downloads of anything no
longer installed. It keeps the downloads of what is installed now, so on this
Mac it freed about 270 MB of the 1.1 GiB:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">brew</span> <span class="console__token">cleanup</span> <span class="console__token">-s</span></span></code></pre>
    </div>
  </div></div><p>An app can keep its data somewhere else and still write its logs to the
internal disk. My Ollama models live on an external SSD, linked into place:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ls</span> <span class="console__token">-ld</span> <span class="console__token">~/.ollama/models</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">lrwxr-xr-x</span>  <span class="console__token">1</span> <span class="console__token">&lt;user&gt;</span>  <span class="console__token">staff</span>  <span class="console__token">...</span> <span class="console__token">/Users/&lt;user&gt;/.ollama/models</span> <span class="console__token">-&gt;</span> <span class="console__token">/Volumes/&lt;drive&gt;/ollama/models</span></span></code></pre>
    </div>
  </div></div><p>The logs aren&rsquo;t linked. List them first:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ls</span> <span class="console__token">-lh</span> <span class="console__token">~/.ollama/logs</span></span></code></pre>
    </div>
  </div></div><p>Here they had reached 947 MB in two files, <code>server-1.log</code> and <code>app-1.log</code>.
Yours may have other names. Ollama has no cleanup command for them, so quit
Ollama and delete the files that list showed, by name:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">rm</span> <span class="console__token">~/.ollama/logs/server-1.log</span> <span class="console__token">~/.ollama/logs/app-1.log</span></span></code></pre>
    </div>
  </div></div><p>Deleting a log leaves the models and the settings as they are.</p>
<p>Ollama wasn&rsquo;t in Open at Login, but App Background Activity, under System
Settings &gt; General &gt; Login Items, allowed it to run in the background. I
turned that off. I didn&rsquo;t check whether background runs are what filled the
logs.</p>
<h2 id="record-the-free-space-again">Record the free space again<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#record-the-free-space-again"
     aria-label="Link to section: Record the free space again" data-pagefind-ignore></a>
</h2>
<p>Run the same command you started with:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">info</span> <span class="console__token">/</span> <span class="console__token">|</span> <span class="console__token">grep</span> <span class="console__token">&#34;Container</span> <span class="console__token">Free</span> <span class="console__token">Space&#34;</span></span></code></pre>
    </div>
  </div></div><p>Here it went from 37.7 GB before any cleanup to 65.8 GB after the uv prune.
The cleanup commands had reported more than 140 GB between them. A cleanup
command reports what it removed from its own storage; only the container&rsquo;s
figure says what the Mac got back.</p>
<h2 id="do-this-with-a-coding-agent">Do this with a coding agent<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#do-this-with-a-coding-agent"
     aria-label="Link to section: Do this with a coding agent" data-pagefind-ignore></a>
</h2>
<p>Paste this prompt into your own local coding agent. It finds and reports, and
it deletes nothing:</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">Find what is taking up space on this Mac&#39;s internal disk. Measure and report
</span></span><span class="line"><span class="cl">only; delete, prune or clean nothing. Stay in my home directory, and don&#39;t
</span></span><span class="line"><span class="cl">follow links to external drives.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">1. Report free space with diskutil info / (Container Free Space) and
</span></span><span class="line"><span class="cl">   df -h /, and say which units each prints.
</span></span><span class="line"><span class="cl">2. Run du -hd 1 on my home directory, then on the largest subdirectory it
</span></span><span class="line"><span class="cl">   lists (not the total line for the directory itself), and keep going down
</span></span><span class="line"><span class="cl">   until each large directory belongs to one tool. Also check
</span></span><span class="line"><span class="cl">   ~/Library/Caches and ~/Library/Developer. Ask package managers for their
</span></span><span class="line"><span class="cl">   cache paths (for example uv cache dir, python3 -m pip cache dir) instead of
</span></span><span class="line"><span class="cl">   assuming them.
</span></span><span class="line"><span class="cl">3. For each directory over 1 GB, report its size, which tool owns it, what
</span></span><span class="line"><span class="cl">   fills it, and the tool&#39;s own cleanup command if it has one.
</span></span><span class="line"><span class="cl">4. Mark anything that is in use or holds work: running processes using the
</span></span><span class="line"><span class="cl">   directory (lsof), open coding-agent or editor sessions, uncommitted or
</span></span><span class="line"><span class="cl">   unpushed changes in any git checkout, Docker volumes, app data under
</span></span><span class="line"><span class="cl">   Application Support. Say what would be lost if each were removed.
</span></span><span class="line"><span class="cl">5. Note that du counts APFS clones at full size, so a total is an upper bound
</span></span><span class="line"><span class="cl">   on what deleting frees.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Then stop and wait. I will choose what to clean.</span></span></code></pre></div></div>
  </div>
</div><h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="free-space-barely-moved-after-a-cleanup">Free space barely moved after a cleanup<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#free-space-barely-moved-after-a-cleanup"
     aria-label="Link to section: Free space barely moved after a cleanup" data-pagefind-ignore></a>
</h3>
<p>Docker reports space inside its VM disk, and <code>du</code> and <code>uv cache size</code> count
cloned files at full size. Measure the container with <code>diskutil info /</code>
before and after; that is the only figure that says what the Mac got back.</p>
<p>With Time Machine on, a local snapshot can keep a deleted file&rsquo;s space in use.
Time Machine keeps hourly snapshots for 24 hours and deletes them as they age
or as space is needed, per <a href="https://support.apple.com/en-us/102154" rel="external noopener" target="_blank">Apple&rsquo;s Time Machine guide<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/mac-disk-full-developer-caches/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>.
List them; on this Mac the list was empty:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">tmutil</span> <span class="console__token">listlocalsnapshots</span> <span class="console__token">/</span></span></code></pre>
    </div>
  </div></div><h3 id="uv-cache-prune-waits-then-times-out">uv cache prune waits, then times out<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#uv-cache-prune-waits-then-times-out"
     aria-label="Link to section: uv cache prune waits, then times out" data-pagefind-ignore></a>
</h3>
<p><code>uv cache prune</code> waits while any other uv process is running, and every <code>uvx</code>
server counts. After five minutes by default (<code>UV_LOCK_TIMEOUT</code>) it gives up;
the <a href="https://docs.astral.sh/uv/concepts/cache/" rel="external noopener" target="_blank">uv cache 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/mac-disk-full-developer-caches/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a> covers
the lock. Quit the coding sessions that started the servers, then run it again.
To see what holds the lock:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">lsof</span> <span class="console__token">&#34;$(uv</span> <span class="console__token">cache</span> <span class="console__token">dir)/.lock&#34;</span></span></code></pre>
    </div>
  </div></div><p><a href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/">Part 2</a> shows how to find which session
holds it.</p>
<h3 id="npm-cache-clean-left-npm-large">npm cache clean left ~/.npm large<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#npm-cache-clean-left-npm-large"
     aria-label="Link to section: npm cache clean left ~/.npm large" data-pagefind-ignore></a>
</h3>
<p><code>npm cache clean --force</code> empties npm&rsquo;s package cache, <code>_cacache</code>. The space
is in <code>_npx</code>, where npx
keeps the packages it runs; part 4 covers it.</p>
]]></content:encoded>
    </item>
    
    <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>Make a Hyperlapse from Keyframes with ffmpeg Without Decoding Every Frame</title>
      <link>https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/</guid>
      <pubDate>Thu, 01 Oct 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Automation</category>
      <category>macOS</category>
      
      <description>With one keyframe per second, -skip_frame nokey decodes 1 frame in 30 and setpts makes each one a frame at 30 fps: a 36-minute drive becomes 72 seconds.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>When a video has one keyframe per second, decode only the keyframes and give
each one a frame at 30 fps. The result plays 30 times faster than the drive:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ffmpeg</span> <span class="console__token">-skip_frame</span> <span class="console__token">nokey</span> <span class="console__token">-i</span> <span class="console__token">&lt;clip&gt;.mp4</span> <span class="console__token">-vf</span> <span class="console__token">&#34;setpts=N/(30*TB)&#34;</span> <span class="console__token">-r</span> <span class="console__token">30</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">-map_metadata</span> <span class="console__token">-1</span> <span class="console__token">-c:v</span> <span class="console__token">hevc_videotoolbox</span> <span class="console__token">-q:v</span> <span class="console__token">65</span> <span class="console__token">-tag:v</span> <span class="console__token">hvc1</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">-color_range</span> <span class="console__token">pc</span> <span class="console__token">-an</span> <span class="console__token">&lt;clip&gt;_30x.mp4</span></span></code></pre>
    </div>
  </div></div><p><code>-skip_frame nokey</code> goes before <code>-i</code>, because it tells the decoder to discard
every frame that isn&rsquo;t a keyframe. <code>setpts=N/(30*TB)</code> gives frame <code>N</code> the
timestamp <code>N/30</code> seconds, so one second of driving becomes one frame of output.</p>
<p>To keep several cameras in sync, render each recording segment separately, pad
every camera&rsquo;s segment with black to the front camera&rsquo;s length, and join the
segments with a stream copy. The script later in this article does that.</p>

</div>

<p>A Rivian R1T films five directions for as long as it&rsquo;s driven. I wanted a
hyperlapse of every drive from a summer of footage, one file per camera, so a
drive could be cut as a multi-camera edit at 30× speed. An ordinary hyperlapse
decodes 30 frames to keep one, and this footage didn&rsquo;t need that: the truck
writes one keyframe every second. Reading only those, a 36-minute drive became
72 seconds of video and took 4.4 minutes to render, with one drive rendering at
a time. The hyperlapses for 218 drives, five cameras each, came to 107 GB.</p>
<p>This is part of a series about reading dashcam video without watching it.
<a href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/">How to sort months of dashcam footage with Claude
Code</a> is the hub, with the prompts and
the checks that drove the whole job.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>ffmpeg</dt> <dd>9.0.1</dd></div>
    <div class="tested__row"><dt>macOS</dt> <dd>27.0, Apple silicon</dd></div>
    <div class="tested__row"><dt>Footage</dt> <dd>Rivian dashcam, H.264, 30 fps, one keyframe per second</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>
<h2 id="why-keyframes-are-enough">Why keyframes are enough<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/#why-keyframes-are-enough"
     aria-label="Link to section: Why keyframes are enough" data-pagefind-ignore></a>
</h2>
<p>The Rivian&rsquo;s clips are H.264 at 30 fps with I- and P-frames only, and one
keyframe (an I-frame) every second. A keyframe decodes on its own. A P-frame
needs the frames before it, so taking every 30th frame of an ordinary decode
still means decoding the other 29.</p>
<p>With <code>-skip_frame nokey</code> the decoder drops the P-frames before decoding them.
What reaches the filter is one picture per second of driving, already spaced
evenly in time. The <code>setpts=N/(30*TB)</code> filter discards their timestamps and numbers them
off at 30 per second. <code>N</code> is the frame&rsquo;s index and <code>TB</code> is the stream&rsquo;s time
base. Without <code>setpts</code>, the output has the same frames, each still held for a
second, and plays at the speed of the drive.</p>
<p>The speed this produces is fixed by the footage: one keyframe per second, shown
at 30 fps, is 30×. Footage with a keyframe every two seconds would come out at
60× from the same command.</p>
<figure><img src="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/hyperlapse-frames-floating-bridge_hu_316ec0d3423d1748.jpg" alt="Eight front-camera frames from a 30× hyperlapse, one per second of output. Each step covers 30 seconds of driving. The frames show the I-90 floating bridge under an overcast sky, the orange light of the Mount Baker tunnel, and the Mercer Island lid." width="1600" height="640" loading="lazy" decoding="async"><figcaption>Eight frames of a hyperlapse, 30 frames apart. Plates blurred.</figcaption>
</figure>
<!-- style-check: procedure -->
<h2 id="check-the-keyframe-rhythm">Check the keyframe rhythm<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/#check-the-keyframe-rhythm"
     aria-label="Link to section: Check the keyframe rhythm" data-pagefind-ignore></a>
</h2>
<p>Before relying on it, count the keyframes in one clip. In a terminal, run:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ffprobe</span> <span class="console__token">-v</span> <span class="console__token">error</span> <span class="console__token">-skip_frame</span> <span class="console__token">nokey</span> <span class="console__token">-select_streams</span> <span class="console__token">v</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">-show_entries</span> <span class="console__token">frame=pts_time</span> <span class="console__token">-of</span> <span class="console__token">csv=p=0</span> <span class="console__token">&lt;clip&gt;.mp4</span> <span class="console__token">|</span> <span class="console__token">head</span> <span class="console__token">-3</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">0.000000</span>
</span><span class="line"><span class="console__token">1.000000</span>
</span><span class="line"><span class="console__token">2.000000</span></span></code></pre>
    </div>
  </div></div><p>Pipe the same command to <code>wc -l</code> instead of <code>head</code> for the count. On a
351-second clip it printed <code>351</code>, and a full 15-minute segment from the truck
has 900. On some drives one camera&rsquo;s keyframes fall at 0.967 s past each
second rather than on it, and which camera varies by drive. The spacing is
still one second, which is what the speed depends on.</p>
<p>If the times are not one second apart, the hyperlapse still renders, but its
speed is the keyframe interval times 30 rather than 30×.</p>
<h2 id="render-one-clip">Render one clip<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/#render-one-clip"
     aria-label="Link to section: Render one clip" data-pagefind-ignore></a>
</h2>
<p>In the folder that holds the clip, run:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ffmpeg</span> <span class="console__token">-nostdin</span> <span class="console__token">-v</span> <span class="console__token">error</span> <span class="console__token">-skip_frame</span> <span class="console__token">nokey</span> <span class="console__token">-i</span> <span class="console__token">&lt;clip&gt;.mp4</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">-vf</span> <span class="console__token">&#34;setpts=N/(30*TB)&#34;</span> <span class="console__token">-r</span> <span class="console__token">30</span> <span class="console__token">-map_metadata</span> <span class="console__token">-1</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">-c:v</span> <span class="console__token">hevc_videotoolbox</span> <span class="console__token">-q:v</span> <span class="console__token">65</span> <span class="console__token">-tag:v</span> <span class="console__token">hvc1</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">-color_range</span> <span class="console__token">pc</span> <span class="console__token">-an</span> <span class="console__token">&lt;clip&gt;_30x.mp4</span></span></code></pre>
    </div>
  </div></div><p>It writes <code>&lt;clip&gt;_30x.mp4</code> beside the source and changes nothing else. On a
351-second clip of a bridge crossing, the output was 351 frames and 11.7
seconds long, 11.2 MB from a 438.8 MB source.</p>
<p>The encoder options are the part to change on another machine:</p>
<ul>
<li><code>hevc_videotoolbox</code> is the Mac&rsquo;s hardware HEVC encoder. The Homebrew build
of ffmpeg used here includes it; <code>ffmpeg -encoders | grep videotoolbox</code>
lists it if yours does.</li>
<li><code>-q:v 65</code> asks it for constant quality rather than a bitrate.</li>
<li><code>-tag:v hvc1</code> labels the HEVC stream the way QuickTime and Apple&rsquo;s players
expect.</li>
<li><code>-color_range pc</code> labels the output full range like the footage, which the
black filler and the join depend on. Apple&rsquo;s encoder ignores
<code>-pix_fmt yuvj420p</code> with a warning, so it isn&rsquo;t set here.</li>
<li><code>-map_metadata -1</code> drops the source&rsquo;s tags, including its GPS fix.</li>
<li><code>-an</code> writes no audio track. The truck records none.</li>
</ul>
<h2 id="pad-each-segment-to-the-front-cameras-length">Pad each segment to the front camera&rsquo;s length<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/#pad-each-segment-to-the-front-cameras-length"
     aria-label="Link to section: Pad each segment to the front camera’s length" data-pagefind-ignore></a>
</h2>
<p>The truck splits a drive into 15-minute segments, one file per camera per
segment. All five cameras&rsquo; files for a segment have the same length, unless a
camera stopped recording. If one did, that camera&rsquo;s segment has fewer
keyframes, its hyperlapse has fewer frames, and every later frame of that angle
plays early. A camera that is missing a whole segment is worse.</p>
<p>When I first rendered every camera of one drive, two came out at 1,303 frames
against the front camera&rsquo;s 2,170. The rear camera had stopped 33
seconds into a segment and recorded the rest of the drive <a href="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/">in a sibling
folder</a>.</p>
<p>Pad every camera&rsquo;s segment to the front camera&rsquo;s frame count with black, so
each segment ends at the same frame on every camera. The numbers in these steps
are an example, a 351-keyframe front segment and a camera that stopped 33
seconds in:</p>
<ol>
<li>
<p>Count the front camera&rsquo;s keyframes for the segment, as in the first step.
On a 351-second segment, that&rsquo;s 351.</p>
</li>
<li>
<p>Count the other camera&rsquo;s keyframes for the same segment. A clip that
stopped 33 seconds in has 33.</p>
</li>
<li>
<p>Render that camera&rsquo;s segment with <code>tpad</code> adding the difference as black
frames, and <code>-frames:v</code> capping it at the front camera&rsquo;s count:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ffmpeg</span> <span class="console__token">-nostdin</span> <span class="console__token">-v</span> <span class="console__token">error</span> <span class="console__token">-skip_frame</span> <span class="console__token">nokey</span> <span class="console__token">-i</span> <span class="console__token">&lt;rear-segment&gt;.mp4</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">-vf</span> <span class="console__token">&#34;setpts=N/(30*TB),tpad=stop=318:color=black&#34;</span> <span class="console__token">-r</span> <span class="console__token">30</span> <span class="console__token">-frames:v</span> <span class="console__token">351</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">-map_metadata</span> <span class="console__token">-1</span> <span class="console__token">-c:v</span> <span class="console__token">hevc_videotoolbox</span> <span class="console__token">-q:v</span> <span class="console__token">65</span> <span class="console__token">-tag:v</span> <span class="console__token">hvc1</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">-color_range</span> <span class="console__token">pc</span> <span class="console__token">-an</span> <span class="console__token">&lt;rear-segment&gt;_30x.mp4</span></span></code></pre>
    </div>
  </div></div></li>
</ol>
<p>The <code>-frames:v</code> cap also trims a camera that recorded slightly longer than the
front one.</p>
<h2 id="fill-a-missing-segment-with-full-range-black">Fill a missing segment with full-range black<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/#fill-a-missing-segment-with-full-range-black"
     aria-label="Link to section: Fill a missing segment with full-range black" data-pagefind-ignore></a>
</h2>
<p>When a camera has no file at all for a segment, render a black part of the same
size and frame count. The front, rear and side cameras are 1920 × 1536; the
Gear Guard bed camera is 1280 × 720.</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ffmpeg</span> <span class="console__token">-nostdin</span> <span class="console__token">-v</span> <span class="console__token">error</span> <span class="console__token">-f</span> <span class="console__token">lavfi</span> <span class="console__token">-i</span> <span class="console__token">&#34;color=black:s=1920x1536:r=30&#34;</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">-frames:v</span> <span class="console__token">351</span> <span class="console__token">-map_metadata</span> <span class="console__token">-1</span> <span class="console__token">-c:v</span> <span class="console__token">hevc_videotoolbox</span> <span class="console__token">-q:v</span> <span class="console__token">65</span> <span class="console__token">-tag:v</span> <span class="console__token">hvc1</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">-color_range</span> <span class="console__token">pc</span> <span class="console__token">-an</span> <span class="console__token">&lt;missing-segment&gt;_30x.mp4</span></span></code></pre>
    </div>
  </div></div><p>The filler must be full range, the same as the footage. <code>-color_range pc</code> is
what makes it so: without it, generated black came out labeled limited range
(<code>tv</code>), and <code>-pix_fmt yuvj420p</code> doesn&rsquo;t help, because Apple&rsquo;s encoder ignores it. The decoded footage is already full
range, so the same options are safe on every part.</p>
<p>A mismatch doesn&rsquo;t make the next step fail. The join is a stream copy, which
converts nothing, and the joined file takes its range label from the first
part. A camera whose file opens on limited-range filler is labeled limited
range throughout, real footage included. That happened to 11 of the more than a thousand
hyperlapse files in my batch, all in folders that held only a fragment of a
drive, and they had to be rendered again. The script checks each part&rsquo;s range before
joining, because the joined file shows only the first part&rsquo;s.</p>
<h2 id="join-the-segments-without-re-encoding">Join the segments without re-encoding<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/#join-the-segments-without-re-encoding"
     aria-label="Link to section: Join the segments without re-encoding" data-pagefind-ignore></a>
</h2>
<p>List the parts in order in a text file, one <code>file</code> line each, then join them
with the concat demuxer and a stream copy:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">printf</span> <span class="console__token">&#34;file</span> <span class="console__token">&#39;%s&#39;\n&#34;</span> <span class="console__token">&lt;part-001&gt;.mp4</span> <span class="console__token">&lt;part-002&gt;.mp4</span> <span class="console__token">&gt;</span> <span class="console__token">list.txt</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">ffmpeg</span> <span class="console__token">-nostdin</span> <span class="console__token">-v</span> <span class="console__token">error</span> <span class="console__token">-f</span> <span class="console__token">concat</span> <span class="console__token">-safe</span> <span class="console__token">0</span> <span class="console__token">-i</span> <span class="console__token">list.txt</span> <span class="console__token">-c</span> <span class="console__token">copy</span> <span class="console__token">&lt;camera&gt;_30x.mp4</span></span></code></pre>
    </div>
  </div></div><p>Every part was encoded with the same options, so the copy only rewrites the
container. It takes seconds.</p>
<h2 id="script-the-whole-drive">Script the whole drive<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/#script-the-whole-drive"
     aria-label="Link to section: Script the whole drive" data-pagefind-ignore></a>
</h2>
<p>The steps combine into one script per camera. It reads the segment list from
the front camera&rsquo;s files, so a camera missing a segment gets black filler for
it. The macOS <code>._</code> files beside the clips on the truck&rsquo;s exFAT drive don&rsquo;t match
its pattern, so they&rsquo;re skipped. The front camera sets every segment&rsquo;s length,
so all five files come out the same length.</p>
<div class="code-block" data-copyable data-copy-label="hyperlapse.sh">
  <div class="code-block__head"><span class="code-block__lang code-block__file">hyperlapse.sh</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">folder</span><span class="o">=</span><span class="nv">$1</span>
</span></span><span class="line"><span class="cl"><span class="nv">camera</span><span class="o">=</span><span class="nv">$2</span>
</span></span><span class="line"><span class="cl"><span class="nv">size</span><span class="o">=</span><span class="si">${</span><span class="nv">3</span><span class="k">:-</span><span class="nv">1920x1536</span><span class="si">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">name</span><span class="o">=</span><span class="k">$(</span>basename <span class="s2">&#34;</span><span class="nv">$folder</span><span class="s2">&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">encode</span><span class="o">=(</span>-map_metadata -1 -c:v hevc_videotoolbox -q:v <span class="m">65</span> -tag:v hvc1
</span></span><span class="line"><span class="cl">        -color_range pc -an<span class="o">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">keyframes<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  ffprobe -v error -skip_frame nokey -select_streams v <span class="se">\
</span></span></span><span class="line"><span class="cl">    -show_entries <span class="nv">frame</span><span class="o">=</span>pts_time -of <span class="nv">csv</span><span class="o">=</span><span class="nv">p</span><span class="o">=</span><span class="m">0</span> <span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span> <span class="p">|</span> wc -l <span class="p">|</span> tr -d <span class="s1">&#39; &#39;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">work</span><span class="o">=</span><span class="k">$(</span>mktemp -d<span class="k">)</span>
</span></span><span class="line"><span class="cl">: &gt; <span class="s2">&#34;</span><span class="nv">$work</span><span class="s2">/list.txt&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">for</span> front in <span class="s2">&#34;</span><span class="nv">$folder</span><span class="s2">/</span><span class="si">${</span><span class="nv">name</span><span class="si">}</span><span class="s2">_frontCenter_&#34;</span>*.mp4<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="nv">segment</span><span class="o">=</span><span class="si">${</span><span class="nv">front</span><span class="p">##*_</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">  <span class="nv">frames</span><span class="o">=</span><span class="k">$(</span>keyframes <span class="s2">&#34;</span><span class="nv">$front</span><span class="s2">&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">  <span class="nv">source</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$folder</span><span class="s2">/</span><span class="si">${</span><span class="nv">name</span><span class="si">}</span><span class="s2">_</span><span class="si">${</span><span class="nv">camera</span><span class="si">}</span><span class="s2">_</span><span class="si">${</span><span class="nv">segment</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nv">part</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$work</span><span class="s2">/</span><span class="nv">$segment</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[[</span> -f <span class="nv">$source</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nv">shortfall</span><span class="o">=</span><span class="k">$((</span> frames <span class="o">-</span> <span class="k">$(</span>keyframes <span class="s2">&#34;</span><span class="nv">$source</span><span class="s2">&#34;</span><span class="k">)</span> <span class="k">))</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="o">((</span> shortfall &lt; <span class="m">0</span> <span class="o">))</span><span class="p">;</span> <span class="k">then</span> <span class="nv">shortfall</span><span class="o">=</span>0<span class="p">;</span> <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    ffmpeg -nostdin -v error -skip_frame nokey -i <span class="s2">&#34;</span><span class="nv">$source</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      -vf <span class="s2">&#34;setpts=N/(30*TB),tpad=stop=</span><span class="si">${</span><span class="nv">shortfall</span><span class="si">}</span><span class="s2">:color=black&#34;</span> -r <span class="m">30</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      -frames:v <span class="s2">&#34;</span><span class="nv">$frames</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">encode</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$part</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">else</span>
</span></span><span class="line"><span class="cl">    ffmpeg -nostdin -v error -f lavfi -i <span class="s2">&#34;color=black:s=</span><span class="si">${</span><span class="nv">size</span><span class="si">}</span><span class="s2">:r=30&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      -frames:v <span class="s2">&#34;</span><span class="nv">$frames</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">encode</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$part</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  <span class="nv">range</span><span class="o">=</span><span class="k">$(</span>ffprobe -v error -select_streams v -show_entries <span class="nv">stream</span><span class="o">=</span>color_range <span class="se">\
</span></span></span><span class="line"><span class="cl">    -of <span class="nv">csv</span><span class="o">=</span><span class="nv">p</span><span class="o">=</span><span class="m">0</span> <span class="s2">&#34;</span><span class="nv">$part</span><span class="s2">&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[[</span> <span class="nv">$range</span> !<span class="o">=</span> pc <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$part</span><span class="s2">: color_range=</span><span class="nv">$range</span><span class="s2">, expected pc&#34;</span> &gt;<span class="p">&amp;</span><span class="m">2</span>
</span></span><span class="line"><span class="cl">    <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;file &#39;</span><span class="nv">$part</span><span class="s2">&#39;&#34;</span> &gt;&gt; <span class="s2">&#34;</span><span class="nv">$work</span><span class="s2">/list.txt&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">ffmpeg -nostdin -v error -f concat -safe <span class="m">0</span> -i <span class="s2">&#34;</span><span class="nv">$work</span><span class="s2">/list.txt&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -c copy <span class="s2">&#34;</span><span class="si">${</span><span class="nv">name</span><span class="si">}</span><span class="s2">_</span><span class="si">${</span><span class="nv">camera</span><span class="si">}</span><span class="s2">_30x.mp4&#34;</span>
</span></span><span class="line"><span class="cl">rm -r <span class="s2">&#34;</span><span class="nv">$work</span><span class="s2">&#34;</span></span></span></code></pre></div></div>
  </div>
</div><p>It writes <code>&lt;drive&gt;_&lt;camera&gt;_30x.mp4</code> in the current folder. Run it once per
camera, from the folder where the hyperlapses belong, and pass <code>1280x720</code> as
the third argument for the Gear Guard camera:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">for</span> <span class="console__token">camera</span> <span class="console__token">in</span> <span class="console__token">frontCenter</span> <span class="console__token">rearCenter</span> <span class="console__token">sideLeft</span> <span class="console__token">sideRight;</span> <span class="console__token">do</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">bash</span> <span class="console__token">hyperlapse.sh</span> <span class="console__token">&lt;drive-folder&gt;</span> <span class="console__token">&#34;$camera&#34;;</span> <span class="console__token">done</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">bash</span> <span class="console__token">hyperlapse.sh</span> <span class="console__token">&lt;drive-folder&gt;</span> <span class="console__token">gearGuard</span> <span class="console__token">1280x720</span></span></code></pre>
    </div>
  </div></div><p>The script assumes the front camera recorded every segment. If it didn&rsquo;t,
the segment is missing from every camera&rsquo;s hyperlapse.</p>
<h2 id="confirm-every-camera-has-the-same-frame-count">Confirm every camera has the same frame count<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/#confirm-every-camera-has-the-same-frame-count"
     aria-label="Link to section: Confirm every camera has the same frame count" data-pagefind-ignore></a>
</h2>
<p>Same length is the whole point of the padding, so count the frames in each
output, and read the pixel format while you&rsquo;re there:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">for</span> <span class="console__token">f</span> <span class="console__token">in</span> <span class="console__token">*_30x.mp4;</span> <span class="console__token">do</span> <span class="console__token">echo</span> <span class="console__token">&#34;$f</span> <span class="console__token">$(ffprobe</span> <span class="console__token">-v</span> <span class="console__token">error</span> <span class="console__token">-select_streams</span> <span class="console__token">v</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">-count_packets</span> <span class="console__token">-of</span> <span class="console__token">compact=p=0</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">-show_entries</span> <span class="console__token">stream=nb_read_packets,pix_fmt,color_range</span> <span class="console__token">&#34;$f&#34;)&#34;;</span> <span class="console__token">done</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">&lt;drive&gt;_frontCenter_30x.mp4</span> <span class="console__token">pix_fmt=yuvj420p|color_range=pc|nb_read_packets=351</span>
</span><span class="line"><span class="console__token">&lt;drive&gt;_gearGuard_30x.mp4</span> <span class="console__token">pix_fmt=yuvj420p|color_range=pc|nb_read_packets=351</span>
</span><span class="line"><span class="console__token">&lt;drive&gt;_rearCenter_30x.mp4</span> <span class="console__token">pix_fmt=yuvj420p|color_range=pc|nb_read_packets=351</span>
</span><span class="line"><span class="console__token">&lt;drive&gt;_sideLeft_30x.mp4</span> <span class="console__token">pix_fmt=yuvj420p|color_range=pc|nb_read_packets=351</span>
</span><span class="line"><span class="console__token">&lt;drive&gt;_sideRight_30x.mp4</span> <span class="console__token">pix_fmt=yuvj420p|color_range=pc|nb_read_packets=351</span></span></code></pre>
    </div>
  </div></div><p>Every camera of a drive prints the same count, and every file prints
<code>yuvj420p</code> and <code>pc</code>. A camera with fewer frames was not padded. The range here
is only the first part&rsquo;s, because a stream-copy join takes its label from the
first part, so the script checks every part&rsquo;s range before it joins them and
stops on one that isn&rsquo;t <code>pc</code>.</p>
<figure><img src="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/hyperlapse-five-cameras-same-frame_hu_49df17819680e522.jpg" alt="Frame 1,970 of each of the five 30× hyperlapse files of one drive. Every tile shows the same instant on the floating bridge: a box truck and a pickup behind the truck appear in both the rearCenter and gearGuard tiles, and the SUV passing on the left appears in frontCenter." width="1600" height="853" loading="lazy" decoding="async"><figcaption>The same frame index from each camera&rsquo;s hyperlapse. Plates blurred.</figcaption>
</figure>
<h2 id="remove-the-location-before-publishing">Remove the location before publishing<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/#remove-the-location-before-publishing"
     aria-label="Link to section: Remove the location before publishing" data-pagefind-ignore></a>
</h2>
<div class="admonition admonition--caution" role="note">
  <p class="admonition__label">
    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/#i-octagon-alert"/></svg>
    <span>Caution</span>
  </p>
  <p>ffmpeg copies the truck&rsquo;s GPS tag to a re-encoded output by default. Every
command here drops it with <code>-map_metadata -1</code>; check the file before you
publish it, as <a href="https://looselydecoupled.com/rivian-dashcam-gps-location-ffprobe/">Read the GPS location from Rivian dashcam video with
ffprobe</a> shows.</p>
</div><p>A hyperlapse runs from the start of a drive to its end. Trim both ends before
you publish one, too.</p>
<!-- style-check: end procedure -->
<h2 id="keyframes-vs-a-setpts-timelapse-of-every-frame">Keyframes vs a setpts timelapse of every frame<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/#keyframes-vs-a-setpts-timelapse-of-every-frame"
     aria-label="Link to section: Keyframes vs a setpts timelapse of every frame" data-pagefind-ignore></a>
</h2>
<p>Reading only keyframes is what makes this fast, and it fixes what the
hyperlapse can be.</p>
<p>On the same 351-second clip, I rendered the 30× hyperlapse twice: once from
keyframes, and once by decoding every frame and keeping every 30th with
<code>select='not(mod(n,30))'</code>. Both came out at 351 frames. The keyframe render
took 4.4 seconds and used about one CPU core. The full decode finished in 7.0
seconds only because it spread across the cores: it used 57 seconds of CPU
time, roughly 13 times the work. On one clip that&rsquo;s a few seconds of waiting.
Across a batch of drives, the CPU time is what adds up.</p>
<p>What the full decode buys you is choice:</p>
<div class="table-wrap" data-scrollable data-label="Table: You want">
  <table>
    <thead>
      <tr><th scope="col">You want</th><th scope="col">Keyframes</th><th scope="col">Every frame</th></tr>
    </thead>
    <tbody>
      <tr><td>30× at 30 fps, from one keyframe per second</td><td>Yes</td><td>Yes, decoding 30 frames per output frame</td></tr>
      <tr><td>60×, 90× or another whole multiple</td><td>Yes, by keeping every second or third keyframe with <code>select</code></td><td>Yes</td></tr>
      <tr><td>Any other speed, such as 20× or 45×</td><td>No</td><td>Yes</td></tr>
      <tr><td>Motion blur from blending neighboring frames</td><td>No</td><td>Yes</td></tr>
      <tr><td>Even spacing on footage with irregular keyframes</td><td>No</td><td>Yes</td></tr>
    </tbody>
  </table>
</div>
<p>For dashcam footage with a steady one-second keyframe, 30× was the speed I
wanted, so keyframes were the right choice. If the speed has to be something
else, decode everything and accept the cost.</p>
<p>The same <code>-skip_frame nokey</code> read is the cheap way to analyze footage, not only
to watch it. When you extract keyframes as raw frames rather than encoding a
video, add <code>-fps_mode passthrough</code>. Without it, ffmpeg duplicates each keyframe
back up to 30 fps, and every frame-to-frame difference you compute reads zero.</p>
]]></content:encoded>
    </item>
    
    <item>
      <title>Cut Multi-Camera Dashcam Clips with ffmpeg Without Re-Encoding, and Keep Them in Sync</title>
      <link>https://looselydecoupled.com/ffmpeg-cut-multi-camera-dashcam-clips-in-sync/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/ffmpeg-cut-multi-camera-dashcam-clips-in-sync/</guid>
      <pubDate>Wed, 30 Sep 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Automation</category>
      
      <description>Stream-copy each dashcam camera and line the files up to the frame, even though each camera&#39;s keyframes fall at their own drifting fraction of a second.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Cut each camera with one pass of ffmpeg&rsquo;s concat demuxer over its source
segments, with <code>-c copy</code>. In the listing, set the first <code>inpoint</code> to that
camera&rsquo;s own keyframe at or before the moment you want, not to the moment
itself:</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">file &#39;&lt;drive-folder&gt;/07_16_26_120000_sideLeft_002.mp4&#39;
</span></span><span class="line"><span class="cl">inpoint 889.966667
</span></span><span class="line"><span class="cl">outpoint 900
</span></span><span class="line"><span class="cl">file &#39;&lt;drive-folder&gt;/07_16_26_120000_sideLeft_003.mp4&#39;
</span></span><span class="line"><span class="cl">inpoint 0
</span></span><span class="line"><span class="cl">outpoint 30</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">$ </span><span class="console__token">ffmpeg</span> <span class="console__token">-f</span> <span class="console__token">concat</span> <span class="console__token">-safe</span> <span class="console__token">0</span> <span class="console__token">-i</span> <span class="console__token">sideLeft.txt</span> <span class="console__token">-c</span> <span class="console__token">copy</span> <span class="console__token">sideLeft.mp4</span></span></code></pre>
    </div>
  </div></div><p>Snapping makes an offset camera&rsquo;s file start a few frames early. Confirm that
every file has a <code>start_time</code> of 0 and the same number of frames plus that
lead. In DaVinci Resolve, set each clip&rsquo;s In point that many frames in, then
build the multicam with the angles synced by In point.</p>

</div>

<p>I wanted the same few minutes of a drive, the crossing of the I-90 floating
bridge, from all five cameras of a Rivian R1T, as five files for a multicam edit.
Re-encoding five streams to get exact cuts costs time and picture quality, so
the cut is a stream copy. A stream copy is exact only where the source allows
it. My first three attempts came out of sync, and each failure is the reason
for one step in the procedure.</p>
<h2 id="how-the-truck-records">How the truck records<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-cut-multi-camera-dashcam-clips-in-sync/#how-the-truck-records"
     aria-label="Link to section: How the truck records" data-pagefind-ignore></a>
</h2>
<p>The truck writes one folder per drive, named <code>MM_DD_YY_HHMMSS</code> in local time.
It holds one file per camera per 15-minute segment,
<code>&lt;drive&gt;_&lt;camera&gt;_&lt;NNN&gt;.mp4</code>, and every segment except the last runs 900 s.
The cameras are <code>frontCenter</code>, <code>rearCenter</code>, <code>sideLeft</code>, <code>sideRight</code>,
and <code>gearGuard</code>.</p>
<p>Three properties of the files decide how they can be cut and synced:</p>
<ul>
<li>The video is H.264 at 30 fps, with I-frames and P-frames only and one
keyframe per second.</li>
<li>The files have no audio track and no timecode track.</li>
<li>All five cameras of a segment have the same 900 s duration; their
<code>creation_time</code> can differ by a couple of seconds, so it can&rsquo;t sync them.</li>
</ul>
<p>With no audio and no timecode, an editor has nothing to sync the angles on but
each file&rsquo;s first frame. The cut has to start every camera on a known frame,
so the edit can line the first frames up.</p>
<figure><img src="https://looselydecoupled.com/ffmpeg-cut-multi-camera-dashcam-clips-in-sync/five-cameras-one-moment_hu_c7373bb90be2c6e0.jpg" alt="One second on the I-90 floating bridge from all five cameras of a synced cut: frontCenter over the hood, rearCenter down the lanes behind, sideLeft and sideRight across the deck, and gearGuard over the truck bed. A red car passing behind appears in the rearCenter, sideLeft and gearGuard tiles at the same instant." width="1600" height="853" loading="lazy" decoding="async"><figcaption>The same frame of each camera&rsquo;s cut of a crossing. Plates blurred.</figcaption>
</figure>
<h2 id="why-stream-copied-cameras-drift">Why stream-copied cameras drift<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-cut-multi-camera-dashcam-clips-in-sync/#why-stream-copied-cameras-drift"
     aria-label="Link to section: Why stream-copied cameras drift" data-pagefind-ignore></a>
</h2>
<p>A stream copy can&rsquo;t start a file on a P-frame, because a P-frame is stored as a
difference from earlier frames that the new file won&rsquo;t contain. It starts on a
keyframe. Two details of this footage turn that rule into drift:</p>
<ul>
<li><strong>Keyframes don&rsquo;t fall at the same instant on every camera.</strong> On most cameras
they fall on whole seconds. On some drives, one or more cameras put them
0.967 s after each second, one frame before the next whole second.</li>
<li><strong>Some cameras&rsquo; groups of pictures run across segment files.</strong> A segment
can open with frames that come before its first keyframe. Those frames
reference frames at the end of the previous file.</li>
</ul>
<p>Each attempt that failed ran into one of these, and the checks in the last
step of the procedure catch each one:</p>
<div class="table-wrap" data-scrollable data-label="Table: Cut">
  <table>
    <thead>
      <tr><th scope="col">Cut</th><th scope="col">What came out</th><th scope="col">Step that prevents it</th></tr>
    </thead>
    <tbody>
      <tr><td>Each segment cut separately, then joined</td><td>The offset camera 1.9 s short, from a late start and frames lost at the join; the timestamps run on without a gap</td><td>Write one concat listing per camera</td></tr>
      <tr><td>One concat pass, the same in-point for every camera</td><td>The offset camera starts at its next keyframe, short by the frames before it</td><td>Snap the in-point to the camera&rsquo;s own keyframe</td></tr>
      <tr><td>An in-point that lands after the keyframe</td><td>The file carries a <code>start_time</code> of 0.967 s instead of 0</td><td>Snap the in-point to the camera&rsquo;s own keyframe</td></tr>
    </tbody>
  </table>
</div>
<p>Equal durations aren&rsquo;t enough. Two files can be the same length and still
start at different moments, so the checks look at frame counts and at
<code>start_time</code> together.</p>
<!-- style-check: procedure -->
<h2 id="find-the-segments-the-window-touches">Find the segments the window touches<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-cut-multi-camera-dashcam-clips-in-sync/#find-the-segments-the-window-touches"
     aria-label="Link to section: Find the segments the window touches" data-pagefind-ignore></a>
</h2>
<p>Express the window in seconds from the start of the drive. Segment <code>NNN</code> covers
drive seconds (NNN − 1) × 900 to NNN × 900. The examples here use a window
chosen to straddle a join, because that&rsquo;s where a cut goes wrong: from 1790 to
1830 s, it touches two segments:</p>
<div class="table-wrap" data-scrollable data-label="Table: Segment">
  <table>
    <thead>
      <tr><th scope="col">Segment</th><th scope="col"><code>inpoint</code> before snapping</th><th scope="col"><code>outpoint</code></th></tr>
    </thead>
    <tbody>
      <tr><td><code>002</code></td><td>890</td><td>900</td></tr>
      <tr><td><code>003</code></td><td>0</td><td>30</td></tr>
    </tbody>
  </table>
</div>
<p>Every segment after the first starts at <code>inpoint 0</code>, and every segment before
the last ends at <code>outpoint 900</code>. Only the first segment&rsquo;s in-point is snapped.</p>
<h2 id="find-each-cameras-keyframe-at-the-in-point">Find each camera&rsquo;s keyframe at the in-point<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-cut-multi-camera-dashcam-clips-in-sync/#find-each-cameras-keyframe-at-the-in-point"
     aria-label="Link to section: Find each camera’s keyframe at the in-point" data-pagefind-ignore></a>
</h2>
<p>Each camera writes a keyframe about once a second, but not at the same
fraction of a second as the others, and not always at the same fraction for a
whole segment. On one drive, the Gear Guard camera&rsquo;s keyframes start on whole
seconds and slip to whole seconds plus 0.933 by the end of the segment:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ffprobe</span> <span class="console__token">-v</span> <span class="console__token">error</span> <span class="console__token">-select_streams</span> <span class="console__token">v</span> <span class="console__token">-skip_frame</span> <span class="console__token">nokey</span> <span class="console__token">\</span>
</span><span class="line"><span class="prompt">&gt;   </span><span class="console__token">-show_entries</span> <span class="console__token">frame=pts_time</span> <span class="console__token">-of</span> <span class="console__token">csv=p=0</span> <span class="console__token">-read_intervals</span> <span class="console__token">%+3</span> <span class="console__token">\</span>
</span><span class="line"><span class="prompt">&gt;   </span><span class="console__token">08_02_26_120000_gearGuard_001.mp4</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">0.000000</span>
</span><span class="line"><span class="console__token">1.000000</span>
</span><span class="line"><span class="console__token">1.966667</span>
</span><span class="line"><span class="console__token">2.966667</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">$ </span><span class="console__token">ffprobe</span> <span class="console__token">-v</span> <span class="console__token">error</span> <span class="console__token">-select_streams</span> <span class="console__token">v</span> <span class="console__token">-skip_frame</span> <span class="console__token">nokey</span> <span class="console__token">\</span>
</span><span class="line"><span class="prompt">&gt;   </span><span class="console__token">-show_entries</span> <span class="console__token">frame=pts_time</span> <span class="console__token">-of</span> <span class="console__token">csv=p=0</span> <span class="console__token">-read_intervals</span> <span class="console__token">887%+3.001</span> <span class="console__token">\</span>
</span><span class="line"><span class="prompt">&gt;   </span><span class="console__token">08_02_26_120000_gearGuard_001.mp4</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">887.933333</span>
</span><span class="line"><span class="console__token">888.933333</span>
</span><span class="line"><span class="console__token">889.933333</span></span></code></pre>
    </div>
  </div></div><p>Across the drives I checked, keyframes sat on whole seconds or at whole seconds
plus anything from 0.733 to 0.967, and most cameras that weren&rsquo;t on whole
seconds drifted within a segment. Which camera is offset, and by how much,
changes from drive to drive. Test your own footage rather than assuming any of
these numbers: list the keyframes in the 3 seconds before the in-point, in the
segment that contains it, for every camera.</p>
<h2 id="snap-the-in-point-to-the-cameras-own-keyframe">Snap the in-point to the camera&rsquo;s own keyframe<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-cut-multi-camera-dashcam-clips-in-sync/#snap-the-in-point-to-the-cameras-own-keyframe"
     aria-label="Link to section: Snap the in-point to the camera’s own keyframe" data-pagefind-ignore></a>
</h2>
<p>For each camera, move the first in-point back to the latest keyframe at or
before it, from the listing near the in-point. On the drive above, 890 becomes
889.933333 for the Gear Guard camera and stays 890 for the others. Measuring
the offset at the start of the segment and applying it at 890 would give
889.966667, which is after the real keyframe.</p>
<p>Snap in drive time, not segment time. When the in-point falls before the first
keyframe of its segment, start on the last keyframe of the previous segment,
because the first frames of a segment reference the tail of the one before it.
Only at the start of a drive, with nothing before it, does such an in-point
become 0.</p>
<p>Rounding back rather than forward is the point. On the drive these examples
come from, a stream copy given 890 on the offset side-left camera started at
its next keyframe, 890.967, so the file opened 0.967 s late and carried that
as its <code>start_time</code>. Given 889.966667, its keyframe there, it started on the
keyframe exactly, at time 0. The snapped camera&rsquo;s first frames come before
the other cameras&rsquo; first frame, by the gap between its keyframe and the
in-point: 1 frame at 0.967, 8 frames at 0.733. A stream copy can&rsquo;t remove
them, so the edit does: set that clip&rsquo;s In point that many frames in.</p>
<figure>
  <img src="https://looselydecoupled.com/ffmpeg-cut-multi-camera-dashcam-clips-in-sync/keyframe-snap-timeline.svg" alt="A timeline from 887.5 s into segment 002 to 2.5 s into segment 003. frontCenter has a keyframe on every whole second, and sideLeft one at every whole second plus 0.967, on both sides of the join at 900. A dashed line marks the requested in-point at 890. On sideLeft, the snapped in-point is the keyframe at 889.967, 33 ms before it; an unsnapped stream copy starts at the next keyframe, 890.967." loading="lazy" decoding="async"><figcaption>Keyframe times as ffprobe reports them for one drive.</figcaption>
</figure>
<h2 id="write-one-concat-listing-per-camera">Write one concat listing per camera<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-cut-multi-camera-dashcam-clips-in-sync/#write-one-concat-listing-per-camera"
     aria-label="Link to section: Write one concat listing per camera" data-pagefind-ignore></a>
</h2>
<p>Write a listing for each camera with one <code>file</code>, <code>inpoint</code>, and <code>outpoint</code> entry
per segment. <code>&lt;drive-folder&gt;</code> stands for the drive folder&rsquo;s absolute path, which
is why the cut later needs <code>-safe 0</code>:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">file &#39;&lt;drive-folder&gt;/07_16_26_120000_sideLeft_002.mp4&#39;
</span></span><span class="line"><span class="cl">inpoint 889.966667
</span></span><span class="line"><span class="cl">outpoint 900
</span></span><span class="line"><span class="cl">file &#39;&lt;drive-folder&gt;/07_16_26_120000_sideLeft_003.mp4&#39;
</span></span><span class="line"><span class="cl">inpoint 0
</span></span><span class="line"><span class="cl">outpoint 30</span></span></code></pre></div></div>
  </div>
</div><p>One listing for both segments is what keeps the frames after the join. The
concat demuxer reads segment <code>003</code> straight after the tail of <code>002</code>, so the
frames at the start of <code>003</code> that reference <code>002</code> still have their reference.
Cutting <code>003</code> on its own drops them, and joining the two pieces afterwards
can&rsquo;t bring them back.</p>
<h2 id="cut-each-camera-with-a-stream-copy">Cut each camera with a stream copy<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-cut-multi-camera-dashcam-clips-in-sync/#cut-each-camera-with-a-stream-copy"
     aria-label="Link to section: Cut each camera with a stream copy" data-pagefind-ignore></a>
</h2>
<p>Run the concat demuxer once per camera. This writes a new file and leaves the
source segments untouched:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ffmpeg</span> <span class="console__token">-f</span> <span class="console__token">concat</span> <span class="console__token">-safe</span> <span class="console__token">0</span> <span class="console__token">-i</span> <span class="console__token">07_16_26_120000_sideLeft.txt</span> <span class="console__token">\</span>
</span><span class="line"><span class="prompt">&gt;   </span><span class="console__token">-c</span> <span class="console__token">copy</span> <span class="console__token">07_16_26_120000_sideLeft.mp4</span></span></code></pre>
    </div>
  </div></div><p>The following script does the last four steps for all five cameras. It takes
the drive folder, the window&rsquo;s start and end in drive seconds, and an output
folder, and writes each camera&rsquo;s listing and clip there:</p>
<div class="code-block" data-copyable data-copy-label="Bash">
  <div class="code-block__head"><span class="code-block__lang">Bash</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/bin/bash
</span></span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl"><span class="nv">folder</span><span class="o">=</span><span class="k">$(</span><span class="nb">cd</span> <span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span> <span class="o">&amp;&amp;</span> <span class="nb">pwd</span><span class="k">)</span> <span class="nv">start</span><span class="o">=</span><span class="nv">$2</span> <span class="nv">end</span><span class="o">=</span><span class="nv">$3</span> <span class="nv">out</span><span class="o">=</span><span class="nv">$4</span>
</span></span><span class="line"><span class="cl"><span class="nv">drive</span><span class="o">=</span><span class="k">$(</span>basename <span class="s2">&#34;</span><span class="nv">$folder</span><span class="s2">&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">segment_seconds</span><span class="o">=</span><span class="m">900</span>
</span></span><span class="line"><span class="cl">mkdir -p <span class="s2">&#34;</span><span class="nv">$out</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Stop with the camera and the file named, rather than ffprobe&#39;s bare error.</span>
</span></span><span class="line"><span class="cl">require<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[[</span> ! -f <span class="nv">$1</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$camera</span><span class="s2">: no file </span><span class="k">$(</span>basename <span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">&#34;</span> &gt;<span class="p">&amp;</span><span class="m">2</span>
</span></span><span class="line"><span class="cl">    <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Print the latest keyframe at or before $3 seconds into file $1, looking back</span>
</span></span><span class="line"><span class="cl"><span class="c1"># $2 seconds; print nothing if there is none in that span.</span>
</span></span><span class="line"><span class="cl">keyframe_at_or_before<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="nb">local</span> <span class="nv">file</span><span class="o">=</span><span class="nv">$1</span> <span class="nv">back</span><span class="o">=</span><span class="nv">$2</span> <span class="nv">at</span><span class="o">=</span><span class="nv">$3</span> from
</span></span><span class="line"><span class="cl">  <span class="nv">from</span><span class="o">=</span><span class="k">$(</span>awk -v <span class="nv">a</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$at</span><span class="s2">&#34;</span> -v <span class="nv">b</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$back</span><span class="s2">&#34;</span> <span class="s1">&#39;BEGIN { f = a - b; if (f &lt; 0) f = 0; print f }&#39;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">  ffprobe -v error -select_streams v -skip_frame nokey <span class="se">\
</span></span></span><span class="line"><span class="cl">    -show_entries <span class="nv">frame</span><span class="o">=</span>pts_time -of <span class="nv">csv</span><span class="o">=</span><span class="nv">p</span><span class="o">=</span><span class="m">0</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    -read_intervals <span class="s2">&#34;</span><span class="si">${</span><span class="nv">from</span><span class="si">}</span><span class="s2">%</span><span class="k">$(</span>awk -v <span class="nv">a</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$at</span><span class="s2">&#34;</span> <span class="s1">&#39;BEGIN { print a + 0.001 }&#39;</span><span class="k">)</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$file</span><span class="s2">&#34;</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    cut -d, -f1 <span class="p">|</span> awk -v <span class="nv">a</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$at</span><span class="s2">&#34;</span> -v <span class="nv">f</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$from</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      <span class="s1">&#39;$1 &gt;= f - 0.0005 &amp;&amp; $1 &lt;= a + 0.0005 { k = $1 } END { if (k != &#34;&#34;) print k }&#39;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">for</span> camera in frontCenter rearCenter sideLeft sideRight gearGuard<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="nv">segment</span><span class="o">=</span><span class="k">$((</span> start <span class="o">/</span> segment_seconds <span class="o">+</span> <span class="m">1</span> <span class="k">))</span>
</span></span><span class="line"><span class="cl">  <span class="nv">local_in</span><span class="o">=</span><span class="k">$((</span> start <span class="o">-</span> <span class="o">(</span>segment <span class="o">-</span> <span class="m">1</span><span class="o">)</span> <span class="o">*</span> segment_seconds <span class="k">))</span>
</span></span><span class="line"><span class="cl">  <span class="nv">file</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$folder</span><span class="s2">/</span><span class="si">${</span><span class="nv">drive</span><span class="si">}</span><span class="s2">_</span><span class="si">${</span><span class="nv">camera</span><span class="si">}</span><span class="s2">_</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%03d&#39;</span> <span class="s2">&#34;</span><span class="nv">$segment</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">.mp4&#34;</span>
</span></span><span class="line"><span class="cl">  require <span class="s2">&#34;</span><span class="nv">$file</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nv">length</span><span class="o">=</span><span class="k">$(</span>ffprobe -v error -show_entries <span class="nv">format</span><span class="o">=</span>duration -of <span class="nv">csv</span><span class="o">=</span><span class="nv">p</span><span class="o">=</span><span class="m">0</span> <span class="s2">&#34;</span><span class="nv">$file</span><span class="s2">&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> awk -v <span class="nv">l</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$length</span><span class="s2">&#34;</span> -v <span class="nv">i</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$local_in</span><span class="s2">&#34;</span> <span class="s1">&#39;BEGIN { exit !(l &lt;= i) }&#39;</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$camera</span><span class="s2">: </span><span class="k">$(</span>basename <span class="s2">&#34;</span><span class="nv">$file</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2"> ends before </span><span class="si">${</span><span class="nv">local_in</span><span class="si">}</span><span class="s2">s&#34;</span> &gt;<span class="p">&amp;</span><span class="m">2</span>
</span></span><span class="line"><span class="cl">    <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  <span class="nv">keyframe</span><span class="o">=</span><span class="k">$(</span>keyframe_at_or_before <span class="s2">&#34;</span><span class="nv">$file</span><span class="s2">&#34;</span> <span class="m">3</span> <span class="s2">&#34;</span><span class="nv">$local_in</span><span class="s2">&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[[</span> -n <span class="nv">$keyframe</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nv">snapped</span><span class="o">=</span><span class="k">$(</span>awk -v <span class="nv">k</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$keyframe</span><span class="s2">&#34;</span> -v <span class="nv">b</span><span class="o">=</span><span class="k">$((</span> <span class="o">(</span>segment <span class="o">-</span> <span class="m">1</span><span class="o">)</span> <span class="o">*</span> segment_seconds <span class="k">))</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      <span class="s1">&#39;BEGIN { printf &#34;%.6f&#34;, b + k }&#39;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">elif</span> <span class="o">((</span> local_in &gt;<span class="o">=</span> <span class="m">3</span> <span class="o">))</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># No keyframe this far into the segment means the file ends before it.</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$camera</span><span class="s2">: </span><span class="k">$(</span>basename <span class="s2">&#34;</span><span class="nv">$file</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2"> ends before </span><span class="si">${</span><span class="nv">local_in</span><span class="si">}</span><span class="s2">s&#34;</span> &gt;<span class="p">&amp;</span><span class="m">2</span>
</span></span><span class="line"><span class="cl">    <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl">  <span class="k">elif</span> <span class="o">((</span> segment &gt; <span class="m">1</span> <span class="o">))</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># The in-point falls before this segment&#39;s first keyframe, so the frames</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># there depend on the previous segment: start on its last keyframe.</span>
</span></span><span class="line"><span class="cl">    <span class="nv">previous</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$folder</span><span class="s2">/</span><span class="si">${</span><span class="nv">drive</span><span class="si">}</span><span class="s2">_</span><span class="si">${</span><span class="nv">camera</span><span class="si">}</span><span class="s2">_</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%03d&#39;</span> <span class="k">$((</span> segment <span class="o">-</span> <span class="m">1</span> <span class="k">)))</span><span class="s2">.mp4&#34;</span>
</span></span><span class="line"><span class="cl">    require <span class="s2">&#34;</span><span class="nv">$previous</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nv">keyframe</span><span class="o">=</span><span class="k">$(</span>keyframe_at_or_before <span class="s2">&#34;</span><span class="nv">$previous</span><span class="s2">&#34;</span> <span class="m">3</span> <span class="s2">&#34;</span><span class="nv">$segment_seconds</span><span class="s2">&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="o">[[</span> -z <span class="nv">$keyframe</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">      <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$camera</span><span class="s2">: segment </span><span class="k">$((</span> segment <span class="o">-</span> <span class="m">1</span> <span class="k">))</span><span class="s2"> ends early&#34;</span> &gt;<span class="p">&amp;</span><span class="m">2</span>
</span></span><span class="line"><span class="cl">      <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    <span class="nv">snapped</span><span class="o">=</span><span class="k">$(</span>awk -v <span class="nv">k</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$keyframe</span><span class="s2">&#34;</span> -v <span class="nv">b</span><span class="o">=</span><span class="k">$((</span> <span class="o">(</span>segment <span class="o">-</span> <span class="m">2</span><span class="o">)</span> <span class="o">*</span> segment_seconds <span class="k">))</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      <span class="s1">&#39;BEGIN { printf &#34;%.6f&#34;, b + k }&#39;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">else</span>
</span></span><span class="line"><span class="cl">    <span class="nv">snapped</span><span class="o">=</span><span class="m">0</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$camera</span><span class="s2"> starts </span><span class="k">$(</span>awk -v <span class="nv">s</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$snapped</span><span class="s2">&#34;</span> -v <span class="nv">a</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$start</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="s1">&#39;BEGIN { printf &#34;%d&#34;, (a - s) * 30 + 0.5 }&#39;</span><span class="k">)</span><span class="s2"> frames early&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nv">first</span><span class="o">=</span><span class="k">$(</span>awk -v <span class="nv">s</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$snapped</span><span class="s2">&#34;</span> -v <span class="nv">n</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$segment_seconds</span><span class="s2">&#34;</span> <span class="s1">&#39;BEGIN { print int(s / n) + 1 }&#39;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">  <span class="nv">last</span><span class="o">=</span><span class="k">$((</span> <span class="o">(</span>end <span class="o">-</span> <span class="m">1</span><span class="o">)</span> <span class="o">/</span> segment_seconds <span class="o">+</span> <span class="m">1</span> <span class="k">))</span>
</span></span><span class="line"><span class="cl">  <span class="nv">listing</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$out</span><span class="s2">/</span><span class="si">${</span><span class="nv">drive</span><span class="si">}</span><span class="s2">_</span><span class="si">${</span><span class="nv">camera</span><span class="si">}</span><span class="s2">.txt&#34;</span>
</span></span><span class="line"><span class="cl">  : &gt; <span class="s2">&#34;</span><span class="nv">$listing</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">for</span> <span class="o">((</span> <span class="nv">segment</span> <span class="o">=</span> first<span class="p">;</span> segment &lt;<span class="o">=</span> last<span class="p">;</span> segment++ <span class="o">))</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="nv">source</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$folder</span><span class="s2">/</span><span class="si">${</span><span class="nv">drive</span><span class="si">}</span><span class="s2">_</span><span class="si">${</span><span class="nv">camera</span><span class="si">}</span><span class="s2">_</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%03d&#39;</span> <span class="s2">&#34;</span><span class="nv">$segment</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">.mp4&#34;</span>
</span></span><span class="line"><span class="cl">    require <span class="s2">&#34;</span><span class="nv">$source</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nv">segment_start</span><span class="o">=</span><span class="k">$((</span> <span class="o">(</span>segment <span class="o">-</span> <span class="m">1</span><span class="o">)</span> <span class="o">*</span> segment_seconds <span class="k">))</span>
</span></span><span class="line"><span class="cl">    <span class="nv">clip_in</span><span class="o">=</span><span class="m">0</span>
</span></span><span class="line"><span class="cl">    <span class="o">((</span> <span class="nv">segment</span> <span class="o">==</span> first <span class="o">))</span> <span class="o">&amp;&amp;</span> <span class="nv">clip_in</span><span class="o">=</span><span class="k">$(</span>awk -v <span class="nv">s</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$snapped</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      -v <span class="nv">b</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$segment_start</span><span class="s2">&#34;</span> <span class="s1">&#39;BEGIN { printf &#34;%.6f&#34;, s - b }&#39;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">    <span class="nv">clip_out</span><span class="o">=</span><span class="k">$((</span> end <span class="o">-</span> segment_start <span class="k">))</span>
</span></span><span class="line"><span class="cl">    <span class="o">((</span> clip_out &gt; segment_seconds <span class="o">))</span> <span class="o">&amp;&amp;</span> <span class="nv">clip_out</span><span class="o">=</span><span class="nv">$segment_seconds</span>
</span></span><span class="line"><span class="cl">    <span class="nv">length</span><span class="o">=</span><span class="k">$(</span>ffprobe -v error -show_entries <span class="nv">format</span><span class="o">=</span>duration -of <span class="nv">csv</span><span class="o">=</span><span class="nv">p</span><span class="o">=</span><span class="m">0</span> <span class="s2">&#34;</span><span class="nv">$source</span><span class="s2">&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> awk -v <span class="nv">l</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$length</span><span class="s2">&#34;</span> -v <span class="nv">o</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$clip_out</span><span class="s2">&#34;</span> <span class="s1">&#39;BEGIN { exit !(l &lt; o - 0.05) }&#39;</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">      <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$camera</span><span class="s2">: </span><span class="k">$(</span>basename <span class="s2">&#34;</span><span class="nv">$source</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2"> ends before </span><span class="si">${</span><span class="nv">clip_out</span><span class="si">}</span><span class="s2">s&#34;</span> &gt;<span class="p">&amp;</span><span class="m">2</span>
</span></span><span class="line"><span class="cl">      <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    <span class="nb">printf</span> <span class="s2">&#34;file &#39;%s&#39;\ninpoint %s\noutpoint %s\n&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      <span class="s2">&#34;</span><span class="nv">$source</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$clip_in</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$clip_out</span><span class="s2">&#34;</span> &gt;&gt; <span class="s2">&#34;</span><span class="nv">$listing</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">done</span>
</span></span><span class="line"><span class="cl">  ffmpeg -nostdin -v error -f concat -safe <span class="m">0</span> -i <span class="s2">&#34;</span><span class="nv">$listing</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    -c copy <span class="s2">&#34;</span><span class="nv">$out</span><span class="s2">/</span><span class="si">${</span><span class="nv">drive</span><span class="si">}</span><span class="s2">_</span><span class="si">${</span><span class="nv">camera</span><span class="si">}</span><span class="s2">.mp4&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">done</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">$ </span><span class="console__token">bash</span> <span class="console__token">cut-window.sh</span> <span class="console__token">&lt;drive-folder&gt;</span> <span class="console__token">1790</span> <span class="console__token">1830</span> <span class="console__token">cuts</span></span></code></pre>
    </div>
  </div></div><p>For each camera, the script prints how many frames before the window&rsquo;s start
its file begins: 0 for a camera with a keyframe on the start, more for an
offset one. Keep that list for the edit.</p>
<p>If a camera has no file for a segment the window touches, or its file ends
before the window does, the script stops and names the camera and the file
rather than writing a short clip. The camera usually <a href="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/">restarted into a sibling
folder</a> partway through the
drive.</p>
<h2 id="verify-the-start-and-frame-count-of-every-camera">Verify the start and frame count of every camera<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-cut-multi-camera-dashcam-clips-in-sync/#verify-the-start-and-frame-count-of-every-camera"
     aria-label="Link to section: Verify the start and frame count of every camera" data-pagefind-ignore></a>
</h2>
<p>In the output folder, print each file&rsquo;s <code>start_time</code>, the number of frames that
decode, and the number of packets in the file:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">for</span> <span class="console__token">clip</span> <span class="console__token">in</span> <span class="console__token">*.mp4;</span> <span class="console__token">do</span>
</span><span class="line"><span class="prompt">&gt;   </span><span class="console__token">printf</span> <span class="console__token">&#39;%s</span> <span class="console__token">&#39;</span> <span class="console__token">&#34;$clip&#34;</span>
</span><span class="line"><span class="prompt">&gt;   </span><span class="console__token">ffprobe</span> <span class="console__token">-v</span> <span class="console__token">error</span> <span class="console__token">-select_streams</span> <span class="console__token">v</span> <span class="console__token">-count_frames</span> <span class="console__token">-count_packets</span> <span class="console__token">\</span>
</span><span class="line"><span class="prompt">&gt;     </span><span class="console__token">-show_entries</span> <span class="console__token">stream=start_time,nb_read_frames,nb_read_packets</span> <span class="console__token">\</span>
</span><span class="line"><span class="prompt">&gt;     </span><span class="console__token">-of</span> <span class="console__token">csv=p=0</span> <span class="console__token">&#34;$clip&#34;</span>
</span><span class="line"><span class="prompt">&gt; </span><span class="console__token">done</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">07_16_26_120000_frontCenter.mp4</span> <span class="console__token">0.000000,1200,1200</span>
</span><span class="line"><span class="console__token">07_16_26_120000_gearGuard.mp4</span> <span class="console__token">0.000000,1200,1200</span>
</span><span class="line"><span class="console__token">07_16_26_120000_rearCenter.mp4</span> <span class="console__token">0.000000,1201,1201</span>
</span><span class="line"><span class="console__token">07_16_26_120000_sideLeft.mp4</span> <span class="console__token">0.000000,1201,1201</span>
</span><span class="line"><span class="console__token">07_16_26_120000_sideRight.mp4</span> <span class="console__token">0.000000,1200,1200</span></span></code></pre>
    </div>
  </div></div><p>The cut is good when all three hold:</p>
<dl class="facts">
  <dt>Start</dt>
  <dd>Every file&rsquo;s <code>start_time</code>, the first column, is <code>0.000000</code>.</dd>
  <dt>Length</dt>
  <dd>The frame counts differ only by each camera&rsquo;s lead, the frames the script reported it starts early.</dd>
  <dt>Join</dt>
  <dd>The decoded frames equal the packets on every file, so every frame after the segment join decoded.</dd>
</dl>

<p>The third check is the one that sees the join. <code>-count_frames</code> decodes the
whole file and counts only the frames that decoded; a frame whose reference is
missing is dropped without an error. The same command on the source segment
<code>003</code> on its own, outside the concat pass, shows the frames that depend on
<code>002</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">$ </span><span class="console__token">ffprobe</span> <span class="console__token">-v</span> <span class="console__token">error</span> <span class="console__token">-select_streams</span> <span class="console__token">v</span> <span class="console__token">-count_frames</span> <span class="console__token">-count_packets</span> <span class="console__token">\</span>
</span><span class="line"><span class="prompt">&gt;   </span><span class="console__token">-show_entries</span> <span class="console__token">stream=nb_read_frames,nb_read_packets</span> <span class="console__token">-of</span> <span class="console__token">csv=p=0</span> <span class="console__token">\</span>
</span><span class="line"><span class="prompt">&gt;   </span><span class="console__token">07_16_26_120000_sideLeft_003.mp4</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">26971,27000</span></span></code></pre>
    </div>
  </div></div><p>Those 29 frames are the ones a per-segment cut loses. Decoding with
<code>ffmpeg -v error … -f null -</code> prints nothing for them, so a silent decode is not
evidence of a clean join. Compare the counts.</p>
<!-- style-check: end procedure -->
<h2 id="build-the-multicam-clip">Build the multicam clip<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-cut-multi-camera-dashcam-clips-in-sync/#build-the-multicam-clip"
     aria-label="Link to section: Build the multicam clip" data-pagefind-ignore></a>
</h2>
<p>In DaVinci Resolve, the leads the script printed are the only alignment the
five files need:</p>
<ol>
<li>Import the five files into the Media Pool.</li>
<li>For each camera with a lead, open its file in the source viewer, step
forward that many frames from the first, and mark an In point.</li>
<li>Select all five, right-click, and choose <strong>Create New Multicam Clip Using
Selected Clips</strong>.</li>
<li>Set <strong>Angle Sync</strong> to <strong>In</strong>.</li>
<li>Create the multicam clip, and put it on a timeline.</li>
<li>Switch the Edit page&rsquo;s source viewer to <strong>Multicam</strong> and cut between angles
during playback.</li>
</ol>
<p><strong>In</strong> lines up each file&rsquo;s In point, which is its first frame unless you
mark one. The other sync methods need something
these files don&rsquo;t carry: <strong>Timecode</strong> needs a timecode track and <strong>Sound</strong>
needs audio. A file whose first frame is 0.967 s later than the others&rsquo; is
0.967 s out of sync after an In-point sync, whatever its <code>start_time</code> says.
That&rsquo;s why the verification step checks the start before the files reach
Resolve.</p>
<h2 id="check-each-clip-for-a-location-before-you-share-it">Check each clip for a location before you share it<a class="heading-anchor" href="https://looselydecoupled.com/ffmpeg-cut-multi-camera-dashcam-clips-in-sync/#check-each-clip-for-a-location-before-you-share-it"
     aria-label="Link to section: Check each clip for a location before you share it" data-pagefind-ignore></a>
</h2>
<p>The concat demuxer drops the truck&rsquo;s GPS fix, but other ways of cutting keep
it; <a href="https://looselydecoupled.com/rivian-dashcam-gps-location-ffprobe/">Read the GPS location from Rivian dashcam video with
ffprobe</a> covers which. Check the file
you&rsquo;re about to share:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">exiftool</span> <span class="console__token">-G1</span> <span class="console__token">-a</span> <span class="console__token">&#34;-gps*&#34;</span> <span class="console__token">&#34;-location*&#34;</span> <span class="console__token">07_16_26_120000_sideLeft.mp4</span></span></code></pre>
    </div>
  </div></div><p>Empty output means no location field was found.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>ffmpeg</dt> <dd>9.0.1, 9.0.2</dd></div>
    <div class="tested__row"><dt>macOS</dt> <dd>27.0</dd></div>
    <div class="tested__row"><dt>Footage</dt> <dd>Rivian dashcam, five cameras, 900 s segments</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
    <item>
      <title>Test AccessDenied in AWS PowerShell with a Role That Has No Permissions</title>
      <link>https://looselydecoupled.com/aws-access-denied-test-empty-iam-role/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/aws-access-denied-test-empty-iam-role/</guid>
      <pubDate>Sun, 27 Sep 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>AWS</category>
      <category>PowerShell</category>
      
      <description>Watch your script handle a real AccessDenied, raised with a throwaway role&#39;s temporary keys, without changing your own permissions or writing any data.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Create a role with no permissions that your account can assume, switch the
session to its temporary keys, and run the script. Every call it makes that needs a
permission is denied for real:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$identity</span>  <span class="p">=</span> <span class="nb">Get-STSCallerIdentity</span>
</span></span><span class="line"><span class="cl"><span class="nv">$partition</span> <span class="p">=</span> <span class="nv">$identity</span><span class="p">.</span><span class="py">Arn</span><span class="p">.</span><span class="py">Split</span><span class="p">(</span><span class="s1">&#39;:&#39;</span><span class="p">)[</span><span class="mf">1</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nv">$account</span>   <span class="p">=</span> <span class="nv">$identity</span><span class="p">.</span><span class="py">Account</span>
</span></span><span class="line"><span class="cl"><span class="nv">$trust</span> <span class="p">=</span> <span class="s1">&#39;{&#34;Version&#34;:&#34;2012-10-17&#34;,&#34;Statement&#34;:[{&#34;Effect&#34;:&#34;Allow&#34;,&#34;Principal&#34;:{&#34;AWS&#34;:&#34;arn:&#39;</span> <span class="p">+</span> <span class="nv">$partition</span> <span class="p">+</span> <span class="s1">&#39;:iam::&#39;</span> <span class="p">+</span> <span class="nv">$account</span> <span class="p">+</span> <span class="s1">&#39;:root&#34;},&#34;Action&#34;:&#34;sts:AssumeRole&#34;}]}&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">New-IAMRole</span> <span class="n">-RoleName</span> <span class="n">denytest</span> <span class="n">-AssumeRolePolicyDocument</span> <span class="nv">$trust</span>
</span></span><span class="line"><span class="cl"><span class="nv">$previous</span> <span class="p">=</span> <span class="nb">Get-AWSCredential</span>
</span></span><span class="line"><span class="cl"><span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="c"># A new role took about eight seconds to become assumable in testing.</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Start-Sleep</span> <span class="n">-Seconds</span> <span class="mf">8</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$delay</span> <span class="p">=</span> <span class="mf">1</span>
</span></span><span class="line"><span class="cl">    <span class="k">foreach</span> <span class="p">(</span><span class="nv">$attempt</span> <span class="k">in</span> <span class="mf">1</span><span class="p">.</span><span class="mf">.6</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nv">$keys</span> <span class="p">=</span> <span class="p">(</span><span class="nb">Use-STSRole</span> <span class="n">-RoleArn</span> <span class="s2">&#34;arn:</span><span class="nv">${partition}</span><span class="s2">:iam::</span><span class="nv">${account}</span><span class="s2">:role/denytest&#34;</span> <span class="n">-RoleSessionName</span> <span class="n">denytest</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span><span class="p">).</span><span class="py">Credentials</span>
</span></span><span class="line"><span class="cl">            <span class="k">break</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="c"># Retry only the denial a new role gives; rethrow anything else, and the last denial.</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="nv">$_</span><span class="p">.</span><span class="py">Exception</span><span class="p">.</span><span class="py">InnerException</span><span class="p">.</span><span class="py">ErrorCode</span> <span class="o">-ne</span> <span class="s1">&#39;AccessDenied&#39;</span> <span class="o">-or</span> <span class="nv">$attempt</span> <span class="o">-eq</span> <span class="mf">6</span><span class="p">)</span> <span class="p">{</span> <span class="k">throw</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="nb">Start-Sleep</span> <span class="n">-Seconds</span> <span class="nv">$delay</span>
</span></span><span class="line"><span class="cl">            <span class="nv">$delay</span> <span class="p">*=</span> <span class="mf">2</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Set-AWSCredential</span> <span class="n">-Credential</span> <span class="nv">$keys</span>
</span></span><span class="line"><span class="cl">    <span class="p">./</span><span class="nb">Your-Script</span><span class="p">.</span><span class="py">ps1</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">finally</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="c"># Restore your own credentials first; deleting the role needs them.</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nv">$previous</span><span class="p">)</span> <span class="p">{</span> <span class="nb">Set-AWSCredential</span> <span class="n">-Credential</span> <span class="nv">$previous</span> <span class="p">}</span> <span class="k">else</span> <span class="p">{</span> <span class="nb">Clear-AWSCredential</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Remove-IAMRole</span> <span class="n">-RoleName</span> <span class="n">denytest</span> <span class="n">-Force</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p>AWS.Tools wraps the AWS error in an <code>InvalidOperationException</code>; the
<code>AccessDenied</code> code is on <code>$_.Exception.InnerException</code>, not on
<code>$_.Exception</code>.</p>

</div>

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

</div>

<p>Driving from July to September filled the truck&rsquo;s 2 TB dashcam drive, a
<a href="https://www.amazon.com/dp/B08HN37XC1?tag=loosely-decoupled-20" rel="external noopener sponsored" target="_blank">SanDisk Extreme Portable SSD<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/triage-dashcam-footage-claude-code/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
(affiliate link): 240 drive folders,
1,958 GB, five cameras recording on every trip. I wanted to keep only what might
make a video, and the backup drive couldn&rsquo;t hold all of it. Watching it wasn&rsquo;t an
option. I directed Claude Code in plain language instead. It sorted the drives
by where they went using GPS, found the I-90 floating bridge by the color of the tunnel
lights, cut synced clips, and rendered a hyperlapse of every drive.</p>
<figure><img src="https://looselydecoupled.com/triage-dashcam-footage-claude-code/five-cameras-floating-bridge_hu_c7373bb90be2c6e0.jpg" alt="Five fisheye views from the same second on the I-90 floating bridge: front, rear, both sides and the Gear Guard camera over the truck bed. A red car passing behind appears in three views at once." width="1600" height="853" loading="lazy" decoding="async"><figcaption>The five cameras at one second of a crossing.</figcaption>
</figure>
<p>Almost every failure in that work was caught by a check: a size comparison, a
frame count, a positive control, a contact sheet, or me. One failure was caught by
nothing for hours. The prompts in this guide are general versions of the ones I
typed, rewritten for your own footage, and each one carries the guardrail that
the failure taught.</p>
<h2 id="signals-already-in-the-files">Signals already in the files<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#signals-already-in-the-files"
     aria-label="Link to section: Signals already in the files" data-pagefind-ignore></a>
</h2>
<p>A Rivian writes one folder per drive and one MP4 per camera per 15-minute segment.
Two things in those files do most of the work, and neither needs anyone to watch
the video:</p>
<ul>
<li><strong>GPS.</strong> The container&rsquo;s <code>location</code> tag holds one fix per segment, taken at its
start. That&rsquo;s the only GPS in the file; there&rsquo;s no per-second track.</li>
<li><strong>Keyframes.</strong> Every camera writes one keyframe per second. Decoding only the
keyframes, downscaled to 64 × 48, gives a per-second record of motion and color
without decoding the frames between them.</li>
</ul>
<p>Read the tag from any clip:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ffprobe</span> <span class="console__token">-v</span> <span class="console__token">error</span> <span class="console__token">-show_entries</span> <span class="console__token">format_tags=location</span> <span class="console__token">-of</span> <span class="console__token">default=nw=1:nk=1</span> <span class="console__token">&lt;clip&gt;.mp4</span></span></code></pre>
    </div>
  </div></div><p>It prints a signed latitude immediately followed by a signed longitude, such as
<code>47.589462-122.2655</code>. Some tags end in <code>/</code> and most don&rsquo;t, and <code>0,0</code> means the truck
had no fix. <a href="https://looselydecoupled.com/rivian-dashcam-gps-location-ffprobe/">Read the GPS location from Rivian dashcam video with
ffprobe</a> covers every spelling. Other
dashcams store GPS differently, which is why the first prompt asks the agent to
find out rather than assume.</p>
<h2 id="tools-and-rules-for-every-stage">Tools and rules for every stage<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#tools-and-rules-for-every-stage"
     aria-label="Link to section: Tools and rules for every stage" data-pagefind-ignore></a>
</h2>
<p>You need:</p>
<ul>
<li>Claude Code, run in a terminal on the machine that has the footage mounted</li>
<li>The <code>ffmpeg</code> and <code>ffprobe</code> commands on the <code>PATH</code></li>
<li>The <code>exiftool</code> command, for the privacy stage</li>
<li>A second drive with room for whatever you keep</li>
</ul>
<p>Every prompt carries three rules, worded for its stage. They&rsquo;re repeated on
purpose, so that a prompt pasted on its own still carries them:</p>
<ul>
<li><strong>One drive first.</strong> A bug found on the first drive costs one drive&rsquo;s time. The
same bug found late in a batch costs every drive before it.</li>
<li><strong>Break the check on purpose.</strong> A check that has never failed proves nothing.
The agent damages its own input or output, confirms the check catches it, and
then restores it.</li>
<li><strong>Say what couldn&rsquo;t be verified.</strong> Matching file sizes aren&rsquo;t matching content,
and a report that doesn&rsquo;t say so reads as if they were.</li>
</ul>
<!-- style-check: procedure -->
<h2 id="survey-every-drive-without-changing-anything">Survey every drive without changing anything<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#survey-every-drive-without-changing-anything"
     aria-label="Link to section: Survey every drive without changing anything" data-pagefind-ignore></a>
</h2>
<p>In Claude Code, from the folder that holds the drive folders, paste:</p>
<!-- style-check: off -->
<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">The folder &lt;footage-folder&gt; holds video from my truck&#39;s dashcam: one subfolder per drive, and one MP4 per camera per segment. Survey it without changing anything.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">For every drive folder, use ffprobe to read each segment&#39;s duration and GPS location tag. Report each drive&#39;s start time, length, GPS points, the distance between its two farthest points, and its size on disk. Ignore any ._ files macOS has left beside the clips on a FAT32 or exFAT drive.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Rules:
</span></span><span class="line"><span class="cl">- Read only. Do not move, rename, re-encode, or delete anything.
</span></span><span class="line"><span class="cl">- Run on one drive first and show me the result before you run the rest.
</span></span><span class="line"><span class="cl">- The location tag is written in more than one format. Parse it permissively, and treat 0,0 as no fix, not as a place.
</span></span><span class="line"><span class="cl">- Before you run the whole batch, feed your parser a malformed tag on purpose and confirm that it reports the drive instead of skipping it silently.
</span></span><span class="line"><span class="cl">- Cache everything you read in one file, so that later steps don&#39;t read the drives again.
</span></span><span class="line"><span class="cl">- Then propose a way to sort the drives into routine and unusual, and wait. I&#39;ll tell you my regular routes.
</span></span><span class="line"><span class="cl">- Finish with a list of the drives you couldn&#39;t read and anything you couldn&#39;t verify.</span></span></code></pre></div></div>
  </div>
</div><!-- style-check: on -->
<p><strong>What went wrong here.</strong> I asked for road trips, and the agent ranked drives by
distance. The ranking itself showed there were none: no drive in the batch went
far. Distance was the wrong question, and only I could supply the right one: the
corridor across the I-90 bridges that most of the drives used. With that, the
classification became a corridor test. A drive whose every GPS point falls
inside a box around that corridor is a corridor drive; anything else is worth a
look. The test found 168 corridor drives, 1,594 GB, and 14 off the corridor
worth keeping,
171 GB. That&rsquo;s why the prompt ends by
waiting for you. Your routes are context the agent can&rsquo;t get from the files.</p>
<p>The parser also failed on its first real run, on a tag that ended in <code>/</code>. The
fix was a pattern that finds two signed numbers wherever they sit.
Expect the same from your own camera&rsquo;s metadata, and test both spellings.</p>
<h2 id="move-the-drives-you-keep">Move the drives you keep<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#move-the-drives-you-keep"
     aria-label="Link to section: Move the drives you keep" data-pagefind-ignore></a>
</h2>
<p>Decide which drives to keep from the survey, then paste:</p>
<!-- style-check: off -->
<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">Move these drives from &lt;footage-folder&gt; to &lt;backup-drive&gt;/&lt;kept-folder&gt;: &lt;list of drive folders&gt;.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Rules:
</span></span><span class="line"><span class="cl">- Before you copy anything, compare the total size with the free space on &lt;backup-drive&gt;, and stop if it won&#39;t fit.
</span></span><span class="line"><span class="cl">- Copy first. After each drive, compare every file&#39;s size on the destination with the source.
</span></span><span class="line"><span class="cl">- Do not delete anything from the source until I say so. Show me the drives whose sizes all match, and wait.
</span></span><span class="line"><span class="cl">- When I confirm, remove the source files with a tool that removes only what it has copied, such as rsync --remove-source-files. Do not rely on shell-specific glob features.
</span></span><span class="line"><span class="cl">- Skip any folder that another process, such as Finder, is still writing to or moving.
</span></span><span class="line"><span class="cl">- Report the empty folders that a move leaves behind. Do not delete them, and make every later step skip them.
</span></span><span class="line"><span class="cl">- Move one drive first and show me the result.
</span></span><span class="line"><span class="cl">- Before the batch, truncate a copy in a scratch folder and confirm your size check reports it.
</span></span><span class="line"><span class="cl">- Say plainly that a size match isn&#39;t a content match, and list anything you couldn&#39;t verify.</span></span></code></pre></div></div>
  </div>
</div><!-- style-check: on -->
<div class="admonition admonition--warning" role="note">
  <p class="admonition__label">
    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#i-triangle-alert"/></svg>
    <span>Warning</span>
  </p>
  <p>Do not let an agent delete source footage in the same step that copies it. Copy,
verify on the destination, and approve the removal yourself. A move that copies
and then fails to delete costs disk space; a move that deletes and then fails to
copy costs the footage.</p>
</div><p><strong>What went wrong here.</strong> A queued move used a <code>zsh</code> glob qualifier that the
shell Claude Code runs commands in doesn&rsquo;t enable. It would have copied every file
and deleted none. Inspecting the running process caught it, and the move switched
to <code>rsync --remove-source-files</code>, which removes only what it copied. Later, a
cleanup command of the form <code>rm -f $B/*.jpg</code> was blocked by Claude Code&rsquo;s own
guard, because an unset <code>$B</code> would expand the path to <code>/*</code>. The cleanup wasn&rsquo;t
needed, and the guard was right to stop it.</p>
<p>The moves also left empty drive folders behind. Nothing failed because of them
until the hyperlapse run, in a later stage, which is why this prompt asks for
them to be reported now.</p>
<h2 id="find-a-landmark-from-computed-signals">Find a landmark from computed signals<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#find-a-landmark-from-computed-signals"
     aria-label="Link to section: Find a landmark from computed signals" data-pagefind-ignore></a>
</h2>
<p>GPS places a drive to within a 15-minute segment, which is too coarse to find a
bridge crossing. The picture has to do it, and the cheap way to read the picture
is to compute a few numbers per second once and reuse them. Paste:</p>
<!-- style-check: off -->
<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">I want the moment in each drive where the truck passes &lt;landmark&gt;. The GPS has one point per segment, which is too coarse, so find it from the picture.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">1. From keyframes only, downscaled to 64x48, compute per-second signals for each camera: the mean change in green from the previous keyframe, and the mean red, green, and blue. Cache them per drive and per camera, so that no later step decodes video again.
</span></span><span class="line"><span class="cl">2. Propose a signal that marks &lt;landmark&gt;, and explain why it should separate the landmark from things that look like it.
</span></span><span class="line"><span class="cl">3. Propose candidates. Do not pick for me.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Rules:
</span></span><span class="line"><span class="cl">- Read only on &lt;footage-folder&gt;. Write only under &lt;work-folder&gt;.
</span></span><span class="line"><span class="cl">- When you extract keyframes as raw frames, make sure ffmpeg doesn&#39;t duplicate them back up to the source frame rate.
</span></span><span class="line"><span class="cl">- Before you scan everything, calibrate the signal on a drive where I know the landmark is present and a drive where I know it isn&#39;t. If both read the same, suspect your extraction before the signal.
</span></span><span class="line"><span class="cl">- Run on one drive first.
</span></span><span class="line"><span class="cl">- Build a contact sheet with one row per drive, labeled with the drive&#39;s name, and one frame per candidate in time order. Write a picks file with your best guess for each drive, which I can edit. Then wait for me.
</span></span><span class="line"><span class="cl">- Ignore the first and last few minutes of each drive, where the truck is parking or leaving.
</span></span><span class="line"><span class="cl">- If a result contradicts something already confirmed, check your tool before you report the contradiction: extract the same frame two ways and compare them.
</span></span><span class="line"><span class="cl">- List the drives with no candidate, and anything you couldn&#39;t verify.</span></span></code></pre></div></div>
  </div>
</div><!-- style-check: on -->
<p>For a tunnel, the signal turned out to be color. Auto-exposure keeps a tunnel&rsquo;s
brightness close to daylight&rsquo;s, but sodium lighting tips the front camera&rsquo;s red/blue
ratio above 1.0. A run of 25–150 s above that ratio, away from the first and last
3 minutes of the drive, found a tunnel on 71 of the 79 bridge drives
scanned. Seven of the eight misses were after dark. <a href="https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/">Finding a bridge in
dashcam video by the color of tunnel
lights</a> is the full account,
including what didn&rsquo;t work.</p>
<figure><img src="https://looselydecoupled.com/triage-dashcam-footage-claude-code/bridge-daylight-vs-tunnel-sodium_hu_addb249c3b37c46e.jpg" alt="Two front-camera frames side by side. On the floating bridge in daylight the picture is blue-gray, with a red/blue ratio of 0.81. Inside the Mount Baker tunnel it is orange-brown under the sodium lamps, with a ratio of 1.37, and the road and cars are still well exposed." width="1600" height="640" loading="lazy" decoding="async"><figcaption>Mean red over mean blue for each whole frame, from the same drive. Plates blurred.</figcaption>
</figure>
<p><strong>What went wrong here.</strong> The first calibration read zero change between every
pair of keyframes, parked or moving. A signal that&rsquo;s flat on a known positive and
a known negative is a broken instrument, not a finding. The cause was ffmpeg
duplicating keyframes back up to 30 fps, so consecutive frames were identical.
<code>-fps_mode passthrough</code> fixed it. That bug is why the prompt asks for a known
positive and a known negative before the scan.</p>
<p>The automatic pick, one tunnel chosen by its position in the drive, was right
about two times in three. The rest landed on other tunnels. The contact sheet is
what turned an unreliable detector into a usable one. It showed one frame per
candidate, taken from the bridge side of the tunnel. From it, I confirmed 68
crossings, couldn&rsquo;t confirm two night drives, and found none on one. The first sheet was a flat grid
of every candidate frame, and it was easy to match a frame to the wrong drive.
One row per drive makes that mistake visible.</p>
<figure><img src="https://looselydecoupled.com/triage-dashcam-footage-claude-code/review-sheet-one-row-per-drive_hu_bceefb337d7a67ac.jpg" alt="Three rows of a review contact sheet, one drive per row, each tile a frame from the bridge side of one tunnel candidate. Each row has a floating-bridge tile among freeway tiles, so the right pick stands out at a glance. The first row’s last three tiles are replaced by gray blocks." width="1600" height="768" loading="lazy" decoding="async"><figcaption>Three rows of a review sheet. Some frames are removed.</figcaption>
</figure>
<p>The worst wrong claim of the whole session came at this stage. Partway through,
the agent reported that the picks for some drives were the wrong tunnel, the Mercer
Island lid instead of the Mount Baker tunnel. The contact sheets had been built
with an output-side <code>-ss</code>, which ffmpeg applied after tiling, so the sheets showed
a different window from the one the picks named. Extracting one frame two ways
exposed it. The picks had been right, and the claim was retracted.</p>
<h2 id="cut-synced-clips-and-render-hyperlapses">Cut synced clips and render hyperlapses<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#cut-synced-clips-and-render-hyperlapses"
     aria-label="Link to section: Cut synced clips and render hyperlapses" data-pagefind-ignore></a>
</h2>
<p>With picks confirmed, paste:</p>
<!-- style-check: off -->
<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">For each confirmed pick in &lt;picks-file&gt;, cut a clip from every camera, from &lt;seconds-before&gt; seconds before the landmark to &lt;seconds-after&gt; seconds after it, without re-encoding. Then render a 30x hyperlapse of every drive for every camera, decoding keyframes only.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Rules:
</span></span><span class="line"><span class="cl">- Read only on &lt;footage-folder&gt;. Write only under &lt;work-folder&gt;.
</span></span><span class="line"><span class="cl">- A stream copy can start only on a keyframe. Measure each camera&#39;s keyframe timing first; do not assume the cameras share it.
</span></span><span class="line"><span class="cl">- A camera&#39;s first frames in a segment file can depend on frames at the end of the previous file. Cut across segment files in one pass, and check the joins.
</span></span><span class="line"><span class="cl">- After cutting, compare every camera&#39;s frame count, duration, and start_time. Same length isn&#39;t the same as in sync.
</span></span><span class="line"><span class="cl">- Pad a camera that stopped early with black frames, per segment, so that every camera stays the same length as the front camera. Give the black the footage&#39;s pixel format and color range, and check the range of every joined file.
</span></span><span class="line"><span class="cl">- Do not trust folder names or file timestamps for timing. If a camera restarted into another folder, measure its offset from cameras that see the same scene, in more than one window. If that offset doesn&#39;t land on one of the camera&#39;s keyframes, re-encode only that camera, cut at the exact frame, and tell me which files were re-encoded.
</span></span><span class="line"><span class="cl">- Test on one drive first, and include a drive where a camera stopped early.
</span></span><span class="line"><span class="cl">- Before the batch, shift one camera by a second on purpose and confirm that your sync check fails. Then restore it.
</span></span><span class="line"><span class="cl">- In the batch, one bad drive must be logged and skipped, not stop the run. Write each output to a temporary name and rename it when it&#39;s complete, so that a rerun resumes.
</span></span><span class="line"><span class="cl">- Time the test drive and estimate the batch from that.
</span></span><span class="line"><span class="cl">- If the run will outlive this session, start a watcher that tells me when the run ends or stops making progress.
</span></span><span class="line"><span class="cl">- List the drives that were skipped or padded, and anything you couldn&#39;t verify.</span></span></code></pre></div></div>
  </div>
</div><!-- style-check: on -->
<p>Each keyframe becomes one frame at 30 fps, so a hyperlapse plays at 30× speed. A
36-minute drive became 72 s and took 4.4 minutes to render. The hyperlapses of
218 drives, five cameras each, came to 107 GB. <a href="https://looselydecoupled.com/ffmpeg-cut-multi-camera-dashcam-clips-in-sync/">Cut multi-camera dashcam clips
with ffmpeg</a> and <a href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/">make a
hyperlapse from keyframes with ffmpeg</a> cover each
step in full.</p>
<p><strong>What went wrong here.</strong> On the first drive I cut, the side-left camera came out
short. On other drives it&rsquo;s a different camera, or none, so the prompt measures
every camera. The frame-count comparison caught it twice:</p>
<ol>
<li>Its GOPs run across segment files. Cutting each segment separately and joining
them dropped the frames that reference the previous file: the camera came out
1.9 s short, with a jump at the join. One concat-demuxer pass over the source
segments keeps them.</li>
<li>It was still 29 frames short, because its keyframes fall at n + 0.967 s, not on
whole seconds like the other cameras&rsquo;. Landing each camera&rsquo;s in-point exactly
on its own keyframe makes every file start at time 0. An offset camera
starts a few frames early, and setting that clip&rsquo;s In point that many frames
in lines it up with the others.</li>
</ol>
<figure><img src="https://looselydecoupled.com/triage-dashcam-footage-claude-code/synced-clip-five-cameras_hu_321fa648b5047a9c.jpg" alt="The five cameras of a finished synced clip at the same second on the floating bridge. A container truck passing on the left fills the front and side-left views, and the pickup behind appears in the rear and Gear Guard views." width="1600" height="853" loading="lazy" decoding="async"><figcaption>One frame from each camera&rsquo;s cut file, all at the same timestamp. Plates blurred.</figcaption>
</figure>
<p>A third check, on <code>start_time</code>, caught a version that had the right length but
started 0.967 s late, which an editor might ignore.</p>
<p>The hyperlapse test drive caught two more. Two cameras came out at 1,303 frames
against the front camera&rsquo;s 2,170. The first guess was the black filler&rsquo;s pixel
format; the real cause was the rear camera stopping 33 s into a segment. The fix
is to pad every segment to the front camera&rsquo;s length. The filler has a trap of
its own: it has to be full range like the footage, which takes <code>-color_range pc</code>;
Apple&rsquo;s hardware encoder ignores <code>-pix_fmt yuvj420p</code>. A mismatched part doesn&rsquo;t make the stream-copy join fail.
The joined file takes the first part&rsquo;s label, so a camera whose file opens on
filler was labeled limited range throughout: 11 of more than a thousand hyperlapse files, found
and re-rendered later. Across 233 drive folders, six hyperlapse sets needed
black-filled segments.</p>
<p>On that same drive, the rear and side-left cameras had restarted into a sibling
folder whose name was 34 s later. The name was wrong by more than a second.
Correlating motion between the Gear Guard camera and the restarted rear camera,
which both face backward, measured 32.7 s ± 0.1 s across four independent
windows. Front against rear disagreed by 0.9 s between windows, because those two
cameras don&rsquo;t see the same scene. <a href="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/">Rivian dashcam camera missing from a
drive</a> covers the restart.</p>
<p>Then the batch failed, and no check caught it. Background jobs in the session
stopped at a time limit, so the run was relaunched detached. A detached process
sends no notice when it ends. It crashed at 10:11 on one of the empty folders the
moves had left behind, and it sat dead until 16:48, when I asked what was next:
more than six and a half hours. Two
fixes came out of it, and both are in the prompt: an empty or bad drive is now a
logged skip, and a long run gets a watcher.</p>
<h2 id="keep-location-out-of-anything-you-publish">Keep location out of anything you publish<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#keep-location-out-of-anything-you-publish"
     aria-label="Link to section: Keep location out of anything you publish" data-pagefind-ignore></a>
</h2>
<p>Paste, for each file you mean to share:</p>
<!-- style-check: off -->
<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">Prepare &lt;export-file&gt; for publishing.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Rules:
</span></span><span class="line"><span class="cl">- Treat GPS in any clip as private. List every metadata field that holds a location, with: exiftool -G1 -a &#34;-gps*&#34; &#34;-location*&#34; &lt;file&gt;
</span></span><span class="line"><span class="cl">- First run that command on an original clip from the truck and confirm that it finds the location. Then run it on the export.
</span></span><span class="line"><span class="cl">- If the export carries a location, write a copy without it, run the same command again, and show me the empty result.
</span></span><span class="line"><span class="cl">- List the first and last frames of every clip, and flag any frame from either end of a drive, any license plate, and any face. Do not decide for me what to blur or cut.
</span></span><span class="line"><span class="cl">- Also list creation dates, and any drive date or time in file names. Strip them with -map_metadata -1 and rename the files, unless I say otherwise.
</span></span><span class="line"><span class="cl">- List what this check can&#39;t see, such as a street sign or a house number in the picture.</span></span></code></pre></div></div>
  </div>
</div><!-- style-check: on -->
<p>Run the check on every export rather than reasoning about which files carry a
tag. In this work the answer differed by tool. The raw clips carry it. The
hyperlapse tool I used copies the first source file&rsquo;s metadata, GPS included,
so the set stays traceable; the <a href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/">published hyperlapse
script</a> strips it. Clips cut with the concat demuxer came
out without it, but a plain <code>-c copy</code> of a single file keeps it, and
<code>-map_metadata -1</code> removes it. Show only
public landmarks on screen, and never frames from either end of a drive.</p>
<figure><img src="https://looselydecoupled.com/triage-dashcam-footage-claude-code/interchange-curve-sunset_hu_6d94b69d2acd59a0.jpg" alt="A side-camera fisheye frame on the I-5/I-90 interchange curve at sunset: the low sun flares at the left edge, and the downtown Seattle skyline stands above the ramps and trees." width="1600" height="1280" loading="lazy" decoding="async"><figcaption>A public landmark mid-drive, raw fisheye, which keeps the sun in frame.</figcaption>
</figure>
<div class="admonition admonition--caution" role="note">
  <p class="admonition__label">
    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#i-octagon-alert"/></svg>
    <span>Caution</span>
  </p>
  <p>A clip from the truck&rsquo;s drive carries a GPS fix for the start of every segment, and
the first segment of a drive usually starts at home. Run the <code>exiftool</code> check on
every file before it leaves your machine.</p>
</div><!-- style-check: end procedure -->
<h2 id="detectors-that-didnt-work">Detectors that didn&rsquo;t work<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#detectors-that-didnt-work"
     aria-label="Link to section: Detectors that didn’t work" data-pagefind-ignore></a>
</h2>
<p>Four ideas failed, and each failure says something about the signal rather than
the threshold:</p>
<ul>
<li><strong>Parked footage.</strong> The hypothesis was that recording while parked padded every
drive. The measurement found 23 GB of 1,760 GB: with Gear Guard turned off, the
truck stops recording seconds after parking. The scan still paid for itself, because its per-second
signals fed every detector after it.</li>
<li><strong>Hard stops.</strong> The detector returned 875 candidates, and all 12 at the top
were turns into parking lots or ordinary stops. Turning changes the picture as
much as braking does.</li>
<li><strong>Traffic jams.</strong> Crawling traffic still changes the picture, because the
neighboring cars move.</li>
<li><strong>Garage boundaries by color.</strong> An overcast sky is as gray as a concrete
ceiling. It worked on a clear day and failed on a cloudy one, which is the case
to test first.</li>
</ul>
<p>One detector worked with a person in the loop. Close passes, read as a
one-sided spike on a side camera while the front camera shows steady driving,
were swamped by overpass shadows until the frame&rsquo;s overall brightness change was
subtracted. Of the top 60, 37 were real passes, and a contact sheet sorted them.
A human pass was cheaper than a better detector.</p>
<h2 id="failures-and-the-checks-that-caught-them">Failures and the checks that caught them<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#failures-and-the-checks-that-caught-them"
     aria-label="Link to section: Failures and the checks that caught them" data-pagefind-ignore></a>
</h2>
<div class="table-wrap" data-scrollable data-label="Table: Failure">
  <table>
    <thead>
      <tr><th scope="col">Failure</th><th scope="col">Caught by</th></tr>
    </thead>
    <tbody>
      <tr><td>No road trips in the footage</td><td>The distance ranking itself</td></tr>
      <tr><td>GPS parser failed on a tag ending in <code>/</code></td><td>An exception on the first run</td></tr>
      <tr><td>Move would copy but never delete</td><td>Inspecting the running process</td></tr>
      <tr><td><code>rm</code> with a possibly unset variable</td><td>Claude Code&rsquo;s command guard</td></tr>
      <tr><td>Every frame difference read zero</td><td>A known parked and a known moving segment</td></tr>
      <tr><td>Automatic tunnel pick wrong about a third of the time</td><td>A one-row-per-drive contact sheet</td></tr>
      <tr><td>Claim that the picks were the wrong tunnel</td><td>Extracting one frame two ways</td></tr>
      <tr><td>One camera short in a synced cut, 1.9 s and then 29 frames</td><td>Frame counts across cameras</td></tr>
      <tr><td>The same camera starting 0.967 s late</td><td>Inspecting <code>start_time</code></td></tr>
      <tr><td>Hyperlapse cameras at 1,303 frames, not 2,170</td><td>Frame counts on a test drive with a stopped camera</td></tr>
      <tr><td>Folder name said 34 s; the offset was 32.7 s</td><td>Motion correlation in four windows</td></tr>
      <tr><td>Detached run dead for more than six and a half hours</td><td>Nothing, until I asked; now a watcher</td></tr>
      <tr><td>Hard stops, jams, and garage color</td><td>A contact sheet, a count, and an overcast day</td></tr>
    </tbody>
  </table>
</div>
<h2 id="each-stage-in-depth">Each stage in depth<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#each-stage-in-depth"
     aria-label="Link to section: Each stage in depth" data-pagefind-ignore></a>
</h2>
<p>Each later part of this series takes one stage and stands on its own:</p>
<ol start="2">
<li><a href="https://looselydecoupled.com/rivian-dashcam-gps-location-ffprobe/">Read the GPS location from Rivian dashcam video with
ffprobe</a>: the tag&rsquo;s spellings, a
parser, and how to strip it.</li>
<li><a href="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/">Rivian dashcam camera missing from a drive: it restarted in another
folder</a>: finding the sibling
folder and measuring its real offset.</li>
<li><a href="https://looselydecoupled.com/ffmpeg-cut-multi-camera-dashcam-clips-in-sync/">Cut multi-camera dashcam clips with ffmpeg without re-encoding, and keep them
in sync</a>: snapping each
camera to its own keyframe.</li>
<li><a href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/">Make a hyperlapse from keyframes with ffmpeg without decoding every
frame</a>: the 30× render, the padding, and the
full-range filler.</li>
<li><a href="https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/">Finding a bridge in dashcam video by the color of tunnel
lights</a>: the detector, the
review sheet, and what didn&rsquo;t work.</li>
</ol>
]]></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>How to Install AWS Tools for PowerShell for All Users on Windows</title>
      <link>https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/</guid>
      <pubDate>Mon, 07 Sep 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>AWS</category>
      <category>S3</category>
      <category>PowerShell</category>
      <category>Windows Server</category>
      <category>Automation</category>
      
      <description>You end up with AWS.Tools in Program Files, where a task running as SYSTEM finds it; SYSTEM&#39;s module path never includes your Documents folder.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Open the PowerShell edition your automation uses <strong>as Administrator</strong>, then
install the modular S3 package for all users:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Install-Module</span> <span class="n">-Name</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="py">Installer</span> <span class="n">-MinimumVersion</span> <span class="mf">2.0</span><span class="p">.</span><span class="py">0</span> <span class="n">-MaximumVersion</span> <span class="mf">2.9</span><span class="p">.</span><span class="py">999</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-Repository</span> <span class="n">PSGallery</span> <span class="n">-Scope</span> <span class="n">AllUsers</span> <span class="n">-Force</span>
</span></span><span class="line"><span class="cl"><span class="nb">Import-Module</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="py">Installer</span> <span class="n">-MinimumVersion</span> <span class="mf">2.0</span><span class="p">.</span><span class="py">0</span> <span class="n">-MaximumVersion</span> <span class="mf">2.9</span><span class="p">.</span><span class="py">999</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl"><span class="nb">Install-AWSToolsModule</span> <span class="n">-Name</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="py">S3</span> <span class="n">-Version</span> <span class="s1">&#39;5.*&#39;</span> <span class="n">-Scope</span> <span class="n">AllUsers</span></span></span></code></pre></div></div>
  </div>
</div><p>Swap <code>AWS.Tools.S3</code> for the service module your script calls. An older 1.x
installer may already be there, and 1.x has no <code>-Version</code> parameter, so the
last line would fail with it. Without <code>-Force</code>, <code>Install-Module</code> only warns
that 1.x is already installed and leaves it. With <code>-Force</code> it installs 2.x
beside it, and the import line loads 2.x rather than 1.x. <code>-MaximumVersion 2.9.999</code> on both lines, as in AWS&rsquo;s own install command, keeps a future 3.x
out of a workflow written for 2.x.
Keep <code>-Version '5.*'</code>. Without it the installer takes the
latest major version, which can carry breaking changes, as AWS&rsquo;s
<a href="https://aws.amazon.com/blogs/developer/aws-tools-installer-v2-is-now-generally-available/" rel="external noopener" target="_blank">Installer v2 announcement<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
warns. With <code>-Name</code> it installs only the modules you name plus
<code>AWS.Tools.Common</code>, and leaves other AWS.Tools modules at their versions, so
name every module your tasks use together if they should move in step.</p>
<p>In Windows PowerShell 5.1, run this first:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$protocol</span> <span class="p">=</span> <span class="p">[</span><span class="no">Net.ServicePointManager</span><span class="p">]::</span><span class="n">SecurityProtocol</span>
</span></span><span class="line"><span class="cl"><span class="nv">$tls12</span> <span class="p">=</span> <span class="p">[</span><span class="no">Net.SecurityProtocolType</span><span class="p">]::</span><span class="n">Tls12</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nv">$protocol</span> <span class="o">-ne</span> <span class="s1">&#39;SystemDefault&#39;</span> <span class="o">-and</span> <span class="o">-not</span> <span class="p">(</span><span class="nv">$protocol</span> <span class="o">-band</span> <span class="nv">$tls12</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="no">Net.ServicePointManager</span><span class="p">]::</span><span class="n">SecurityProtocol</span> <span class="p">=</span> <span class="nv">$protocol</span> <span class="o">-bor</span> <span class="nv">$tls12</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="nb">Install-PackageProvider</span> <span class="n">-Name</span> <span class="n">NuGet</span> <span class="n">-MinimumVersion</span> <span class="mf">2.8</span><span class="p">.</span><span class="py">5</span><span class="p">.</span><span class="py">201</span> <span class="n">-Force</span> <span class="p">|</span> <span class="nb">Out-Null</span></span></span></code></pre></div></div>
  </div>
</div><p>The <code>if</code> adds TLS 1.2 only when the session has neither it nor the operating
system&rsquo;s default. It never removes a protocol, and it leaves the OS default
alone, as Microsoft&rsquo;s
<a href="https://learn.microsoft.com/en-us/dotnet/framework/network-programming/tls#recommendations" rel="external noopener" target="_blank">TLS guidance<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
recommends, since that default can negotiate newer versions. The gallery needs
TLS 1.2, as Microsoft&rsquo;s
<a href="https://devblogs.microsoft.com/powershell/powershell-gallery-tls-support/" rel="external noopener" target="_blank">PowerShell Gallery TLS post<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
explains, and so does the NuGet provider download, which is why the check
comes first. On the test server it changed nothing. The last line installs the
NuGet provider, and does nothing where it&rsquo;s already in place. If the gallery still can&rsquo;t be reached after both, TLS
isn&rsquo;t the cause; look at a proxy, DNS or a firewall. An install that reaches
the gallery and then fails is a different problem, such as the .NET or
PowerShellGet version the package requires.</p>
<p>Verify from a fresh session under the account that runs the task:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Import-Module</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="py">S3</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl"><span class="p">(</span><span class="nb">Get-Module</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="n">S3</span><span class="p">).</span><span class="n">ModuleBase</span></span></span></code></pre></div></div>
  </div>
</div><p>The path should start <code>$env:ProgramFiles\WindowsPowerShell\Modules\</code> for
Windows PowerShell, or <code>$env:ProgramFiles\PowerShell\Modules\</code> for PowerShell 7.
That&rsquo;s <code>C:\Program Files</code> for a 64-bit host and <code>C:\Program Files (x86)</code> for a
32-bit one.</p>
<div class="admonition admonition--note" role="note">
  <p class="admonition__label">
    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/#i-info"/></svg>
    <span>Note</span>
  </p>
  <p>Module availability and AWS authentication are separate problems that fail at
different points. A task that imports the module and then reports access
denied has finished with this article.</p>
</div>
</div>

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

<p>I wanted to repurpose my old Mac, a 2011 iMac, by putting Linux Mint on it.
balenaEtcher stopped at &ldquo;Starting&hellip;&rdquo; on my Apple Silicon MacBook Pro. I
wrote the installer drive with <code>dd</code> instead, and the iMac booted from it.
<code>dd</code> ships with macOS, so nothing needs installing. It copies the image onto
the device block for block, which is what Etcher&rsquo;s helper does in code.</p>
<!-- style-check: procedure -->
<h2 id="1-identify-the-usb-device">1. Identify the USB device<a class="heading-anchor" href="https://looselydecoupled.com/bootable-linux-usb-macos-dd/#1-identify-the-usb-device"
     aria-label="Link to section: 1. Identify the USB device" data-pagefind-ignore></a>
</h2>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">list</span></span></code></pre>
    </div>
  </div></div><p>Find the external disk in the output. Its number replaces <code>N</code> in the commands
that follow:</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">/dev/diskN (external, physical):</span></span></code></pre></div></div>
  </div>
</div><p>Before going further, confirm both:</p>
<ul>
<li>The header reads <code>external, physical</code>. Internal disks read <code>internal, physical</code>, and APFS containers read <code>synthesized</code>. Neither is your USB
drive.</li>
<li>The size matches your USB drive.</li>
</ul>
<div class="admonition admonition--warning" role="note">
  <p class="admonition__label">
    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/bootable-linux-usb-macos-dd/#i-triangle-alert"/></svg>
    <span>Warning</span>
  </p>
  <p><code>dd</code> writes to whatever device you name, with no confirmation and no undo.
An internal disk named by mistake is overwritten from block zero. Read the
identifier off <code>diskutil list</code> for this drive on this machine; disk numbers
change between boots and between ports.</p>
</div><h2 id="2-unmount-the-disk">2. Unmount the disk<a class="heading-anchor" href="https://looselydecoupled.com/bootable-linux-usb-macos-dd/#2-unmount-the-disk"
     aria-label="Link to section: 2. Unmount the disk" data-pagefind-ignore></a>
</h2>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">unmountDisk</span> <span class="console__token">/dev/diskN</span></span></code></pre>
    </div>
  </div></div><p>Unmount, not eject. An ejected disk is removed from <code>/dev</code>. An unmounted disk
stays in <code>/dev</code> with no volume mounted, so <code>dd</code> can open it for writing.</p>
<h2 id="3-write-the-iso">3. Write the ISO<a class="heading-anchor" href="https://looselydecoupled.com/bootable-linux-usb-macos-dd/#3-write-the-iso"
     aria-label="Link to section: 3. Write the ISO" data-pagefind-ignore></a>
</h2>
<p>This command erases everything on the drive and replaces it with the image.
<code>sudo</code> prompts for your administrator password.</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">sudo</span> <span class="console__token">dd</span> <span class="console__token">if=&#34;$HOME/Downloads/linuxmint-22.3-cinnamon-64bit.iso&#34;</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">of=/dev/rdiskN</span> <span class="console__token">bs=1m</span> <span class="console__token">status=progress</span></span></code></pre>
    </div>
  </div></div><p>Three details in that command:</p>
<ul>
<li><code>&quot;$HOME/Downloads/...&quot;</code>, not <code>~/Downloads/...</code>. The default macOS shell,
zsh, doesn&rsquo;t expand a tilde after <code>if=</code>, and <code>dd</code> reports the file as
missing. <code>$HOME</code> expands in every shell.</li>
<li><code>rdiskN</code>, not <code>diskN</code>. The <code>r</code> prefix names the raw device, which
bypasses the buffer cache and writes faster on macOS.</li>
<li><code>status=progress</code> prints the bytes written as it goes, so a slow write
shows progress instead of looking stalled.</li>
</ul>
<p>On this drive, the sustained rate was about 7.6 MB/s, and the 3 GB image took
about seven minutes. That rate is the drive&rsquo;s own write speed, not a fault.</p>
<h2 id="4-confirm-it-finished">4. Confirm it finished<a class="heading-anchor" href="https://looselydecoupled.com/bootable-linux-usb-macos-dd/#4-confirm-it-finished"
     aria-label="Link to section: 4. Confirm it finished" data-pagefind-ignore></a>
</h2>
<p><code>dd</code> reports the total when it completes:</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">3091660800 bytes transferred in 404.24 secs (7648047 bytes/sec)</span></span></code></pre></div></div>
  </div>
</div><p>Compare the byte count with the ISO&rsquo;s size:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">stat</span> <span class="console__token">-f</span> <span class="console__token">%z</span> <span class="console__token">&#34;$HOME/Downloads/linuxmint-22.3-cinnamon-64bit.iso&#34;</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">3091660800</span></span></code></pre>
    </div>
  </div></div><p>The two numbers match and no I/O error was printed, so the write is
complete. <code>dd</code> doesn&rsquo;t read the drive back to verify it the way Etcher
does. Booting from the drive, later in this article, is the verification.</p>
<p>When <code>dd</code> releases the device, macOS might report that the disk isn&rsquo;t
readable and offer to initialize it. Choose Ignore or Eject. Initialize opens
Disk Utility, where erasing the drive would undo the write.</p>
<h2 id="5-eject">5. Eject<a class="heading-anchor" href="https://looselydecoupled.com/bootable-linux-usb-macos-dd/#5-eject"
     aria-label="Link to section: 5. Eject" data-pagefind-ignore></a>
</h2>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">eject</span> <span class="console__token">/dev/diskN</span></span></code></pre>
    </div>
  </div></div><!-- style-check: end procedure -->
<h2 id="what-finder-shows-afterward">What Finder shows afterward<a class="heading-anchor" href="https://looselydecoupled.com/bootable-linux-usb-macos-dd/#what-finder-shows-afterward"
     aria-label="Link to section: What Finder shows afterward" data-pagefind-ignore></a>
</h2>
<p>Finder shows unfamiliar partitions, or a fraction of the drive&rsquo;s capacity.
That&rsquo;s expected: the ISO was written as a raw disk image, not as a file system
that macOS can mount. On a 32 GB drive, the image uses about 3 GB, and the
rest is outside any partition in this layout. Don&rsquo;t change anything on the
drive until you&rsquo;ve booted from it.</p>
<p>To return the drive to ordinary use later, erase it as exFAT. This removes the
Linux image:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">eraseDisk</span> <span class="console__token">ExFAT</span> <span class="console__token">USB</span> <span class="console__token">GPT</span> <span class="console__token">/dev/diskN</span></span></code></pre>
    </div>
  </div></div><!-- style-check: procedure -->
<h2 id="boot-the-usb-drive">Boot the USB drive<a class="heading-anchor" href="https://looselydecoupled.com/bootable-linux-usb-macos-dd/#boot-the-usb-drive"
     aria-label="Link to section: Boot the USB drive" data-pagefind-ignore></a>
</h2>
<p>An Intel Mac, such as the 2011 iMac, can boot from this drive. An Apple
Silicon Mac can&rsquo;t.</p>
<p>On an Intel Mac, hold Option during startup and select the external entry.
The entry is usually labeled &ldquo;EFI Boot&rdquo;, though the label depends on the firmware
and on how the drive is enumerated.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>macOS</dt> <dd>Tahoe 26.3.1, MacBook Pro (Apple Silicon)</dd></div>
    <div class="tested__row"><dt>Boot target</dt> <dd>iMac (2011)</dd></div>
    <div class="tested__row"><dt>USB</dt> <dd>PNY 32 GB USB 3.0</dd></div>
    <div class="tested__row"><dt>ISO</dt> <dd>Linux Mint 22.3 Cinnamon 64-bit</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>March 2026</dd></div>
  </dl>
</div>
<!-- style-check: end procedure -->
]]></content:encoded>
    </item>
    
    <item>
      <title>Back Up a 7 Days to Die Dedicated Server to Amazon S3 with PowerShell</title>
      <link>https://looselydecoupled.com/backup-7-days-to-die-server-s3/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/backup-7-days-to-die-server-s3/</guid>
      <pubDate>Fri, 06 Feb 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>AWS</category>
      <category>S3</category>
      <category>Windows Server</category>
      <category>PowerShell</category>
      <category>Automation</category>
      
      <description>The world lands in S3 every morning as an archive that restores onto a fresh server, and the task&#39;s result says whether that morning&#39;s copy arrived.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>A daily scheduled task runs a PowerShell script as SYSTEM. The script zips
<code>Saves</code> and <code>serverconfig.xml</code>, writes the archive to S3 with
<code>Write-S3Object</code> as <code>STANDARD_IA</code>, and keeps three archives locally and
thirty days in the bucket. Every part is stock: <code>Compress-Archive</code>, AWS
Tools for PowerShell, Task Scheduler.</p>
<p>SYSTEM has its own profile, so install the modules and write the credentials
where it can reach them, not where your own session puts them.</p>

</div>

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

</div>

<p>A question on <a href="https://www.reddit.com/r/PowerShell/comments/1p5vxxg/what_does_it_mean_to_learnknow_powershell" rel="external noopener" target="_blank">r/PowerShell<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/what-does-this-powershell-script-do/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
put it plainly: someone needed to export names from Exchange, found a script,
pasted it in, and it worked. They had no idea what it did, and wondered
whether they could say they knew PowerShell.</p>
<p>To me, knowing PowerShell means you can sit at a prompt with a repetitive
task and find your way to automating it: the basics, plus knowing how to find
the right cmdlets. Writing your own modules is where that leads, not the
entry requirement. If you&rsquo;re learning PowerShell, reading the scripts you
already use this way is a faster start than memorizing cmdlets. And the way
to find the right cmdlets is the same way to understand a script someone else wrote: ask PowerShell itself. Every step
below is one of those questions.</p>
<p>The example is a one-liner of the sort people paste: list the three largest
files under the current folder.</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">gci </span><span class="n">-r</span> <span class="o">-File</span> <span class="p">|</span> <span class="nb">sort </span><span class="n">Length</span> <span class="n">-Desc</span> <span class="p">|</span> <span class="nb">select </span><span class="n">-First</span> <span class="mf">3</span> <span class="n">Name</span><span class="p">,</span> <span class="vm">@</span><span class="p">{</span><span class="n">n</span><span class="p">=</span><span class="s1">&#39;MB&#39;</span><span class="p">;</span><span class="n">e</span><span class="p">={[</span><span class="no">math</span><span class="p">]::</span><span class="n">Round</span><span class="p">(</span><span class="nv">$_</span><span class="p">.</span><span class="n">Length</span><span class="p">/</span><span class="mf">1</span><span class="p">MB,</span><span class="mf">1</span><span class="p">)}}</span></span></span></code></pre></div></div>
  </div>
</div><!-- style-check: procedure -->
<h2 id="find-out-what-each-command-is">Find out what each command is<a class="heading-anchor" href="https://looselydecoupled.com/what-does-this-powershell-script-do/#find-out-what-each-command-is"
     aria-label="Link to section: Find out what each command is" data-pagefind-ignore></a>
</h2>
<p>A pipeline is commands joined by <code>|</code>. Before anything else, ask what each name
runs. <code>Get-Command</code> resolves aliases and shows where a command comes from:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">Get-Command</span> <span class="console__token">gci,</span> <span class="console__token">sort,</span> <span class="console__token">select</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line">
</span><span class="line"><span class="console__token">CommandType</span>     <span class="console__token">Name</span>                                               <span class="console__token">Version</span>    <span class="console__token">Source</span>
</span><span class="line"><span class="console__token">-----------</span>     <span class="console__token">----</span>                                               <span class="console__token">-------</span>    <span class="console__token">------</span>
</span><span class="line"><span class="console__token">Alias</span>           <span class="console__token">gci</span> <span class="console__token">-&gt;</span> <span class="console__token">Get-ChildItem</span>
</span><span class="line"><span class="console__token">Application</span>     <span class="console__token">sort</span>                                               <span class="console__token">0.0.0.0</span>    <span class="console__token">/usr/bin/sort</span>
</span><span class="line"><span class="console__token">Alias</span>           <span class="console__token">select</span> <span class="console__token">-&gt;</span> <span class="console__token">Select-Object</span></span></code></pre>
    </div>
  </div></div><p><code>gci</code> and <code>select</code> are aliases for cmdlets. <code>sort</code> isn&rsquo;t: on macOS, PowerShell
has no <code>sort</code> alias, so the name runs the Unix <code>sort</code> program, which doesn&rsquo;t
understand <code>-Desc</code>. A one-liner written on Windows, where <code>sort</code> means
<code>Sort-Object</code>, fails here. Replace each alias with the full name it stands
for, and the script reads the same everywhere:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Get-ChildItem</span> <span class="n">-Recurse</span> <span class="o">-File</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Sort-Object</span> <span class="n">-Property</span> <span class="n">Length</span> <span class="n">-Descending</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Select-Object</span> <span class="n">-First</span> <span class="mf">3</span> <span class="n">-Property</span> <span class="n">Name</span><span class="p">,</span> <span class="vm">@</span><span class="p">{</span><span class="n">Name</span> <span class="p">=</span> <span class="s1">&#39;MB&#39;</span><span class="p">;</span> <span class="n">Expression</span> <span class="p">=</span> <span class="p">{</span> <span class="p">[</span><span class="no">math</span><span class="p">]::</span><span class="n">Round</span><span class="p">(</span><span class="nv">$_</span><span class="p">.</span><span class="py">Length</span> <span class="p">/</span> <span class="mf">1</span><span class="p">MB,</span> <span class="mf">1</span><span class="p">)</span> <span class="p">}}</span></span></span></code></pre></div></div>
  </div>
</div><p><code>Get-Alias -Definition Get-ChildItem</code> works the other way, listing every
short name for a cmdlet. The same command decodes the two names that look
least like commands, <code>?</code> and <code>%</code>:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">Get-Alias</span> <span class="console__token">-Definition</span> <span class="console__token">Where-Object,</span> <span class="console__token">ForEach-Object</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line">
</span><span class="line"><span class="console__token">CommandType</span>     <span class="console__token">Name</span>                                               <span class="console__token">Version</span>    <span class="console__token">Source</span>
</span><span class="line"><span class="console__token">-----------</span>     <span class="console__token">----</span>                                               <span class="console__token">-------</span>    <span class="console__token">------</span>
</span><span class="line"><span class="console__token">Alias</span>           <span class="console__token">?</span> <span class="console__token">-&gt;</span> <span class="console__token">Where-Object</span>
</span><span class="line"><span class="console__token">Alias</span>           <span class="console__token">where</span> <span class="console__token">-&gt;</span> <span class="console__token">Where-Object</span>
</span><span class="line"><span class="console__token">Alias</span>           <span class="console__token">%</span> <span class="console__token">-&gt;</span> <span class="console__token">ForEach-Object</span>
</span><span class="line"><span class="console__token">Alias</span>           <span class="console__token">foreach</span> <span class="console__token">-&gt;</span> <span class="console__token">ForEach-Object</span></span></code></pre>
    </div>
  </div></div><p>Inside their braces, <code>$_</code> is the object the pipeline is handing over at that
moment.</p>
<h2 id="preview-what-a-command-would-change">Preview what a command would change<a class="heading-anchor" href="https://looselydecoupled.com/what-does-this-powershell-script-do/#preview-what-a-command-would-change"
     aria-label="Link to section: Preview what a command would change" data-pagefind-ignore></a>
</h2>
<p>Before running any part of the script, find the commands that change
something. Most built-in cmdlets that delete, move or overwrite take
<code>-WhatIf</code>, which reports what they would do instead of doing it:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">Get-ChildItem</span> <span class="console__token">-Recurse</span> <span class="console__token">-File</span> <span class="console__token">-Filter</span> <span class="console__token">*.log</span> <span class="console__token">|</span> <span class="console__token">Remove-Item</span> <span class="console__token">-WhatIf</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">What</span> <span class="console__token">if:</span> <span class="console__token">Performing</span> <span class="console__token">the</span> <span class="console__token">operation</span> <span class="console__token">&#34;Remove</span> <span class="console__token">File&#34;</span> <span class="console__token">on</span> <span class="console__token">target</span> <span class="console__token">&#34;&lt;folder&gt;/logs/app.log&#34;.</span>
</span><span class="line"><span class="console__token">What</span> <span class="console__token">if:</span> <span class="console__token">Performing</span> <span class="console__token">the</span> <span class="console__token">operation</span> <span class="console__token">&#34;Remove</span> <span class="console__token">File&#34;</span> <span class="console__token">on</span> <span class="console__token">target</span> <span class="console__token">&#34;&lt;folder&gt;/logs/old.log&#34;.</span></span></code></pre>
    </div>
  </div></div><p><code>-WhatIf</code> works only on commands that support it: cmdlets, and functions
declared with <code>SupportsShouldProcess</code>. On macOS, <code>rm</code>, <code>mv</code> and <code>cp</code> aren&rsquo;t
aliases but the system programs, which don&rsquo;t know <code>-WhatIf</code> at all, so a
Windows script&rsquo;s <code>rm -WhatIf</code> doesn&rsquo;t preview anything; <code>Get-Command</code> shows
which you have. A function can also accept <code>-WhatIf</code> and still change things
outside its <code>ShouldProcess</code> check, so read its body too.</p>
<h2 id="read-the-help-and-its-examples">Read the help and its examples<a class="heading-anchor" href="https://looselydecoupled.com/what-does-this-powershell-script-do/#read-the-help-and-its-examples"
     aria-label="Link to section: Read the help and its examples" data-pagefind-ignore></a>
</h2>
<p><code>Get-Help</code> explains a cmdlet, and <code>-Examples</code> shows it in use. A fresh
PowerShell installation has no local help files, so install them once:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Update-Help</span> <span class="n">-Scope</span> <span class="n">CurrentUser</span>
</span></span><span class="line"><span class="cl"><span class="nb">Get-Help</span> <span class="nb">Sort-Object</span> <span class="n">-Examples</span></span></span></code></pre></div></div>
  </div>
</div><p><code>Update-Help</code> downloads the help for every installed module; add <code>-Module</code>
with a module name to fetch only one.</p>
<h2 id="run-the-pipeline-one-stage-at-a-time">Run the pipeline one stage at a time<a class="heading-anchor" href="https://looselydecoupled.com/what-does-this-powershell-script-do/#run-the-pipeline-one-stage-at-a-time"
     aria-label="Link to section: Run the pipeline one stage at a time" data-pagefind-ignore></a>
</h2>
<p>Run only the stages that read. A cmdlet&rsquo;s verb is a first clue, not a
guarantee: <code>Get</code> and <code>Select</code> usually read, while <code>Remove</code>, <code>Set</code>, <code>New</code>,
<code>Move</code>, <code>Copy</code>, <code>Rename</code>, <code>Clear</code>, <code>Out</code>, <code>Export</code> and <code>Invoke</code> change things
and get <code>-WhatIf</code> first, as shown earlier. Read any <code>{ }</code> block too: the code
inside <code>Where-Object</code> or <code>ForEach-Object</code> can do anything.</p>
<p>Run the first reading command alone and look at what it produces, then add
the next <code>|</code> stage and run it again. Each stage&rsquo;s output is the next stage&rsquo;s
input, so this shows what every command contributes. <code>Select-Object</code> here
only picks the columns to show:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">Get-ChildItem</span> <span class="console__token">-Recurse</span> <span class="console__token">-File</span> <span class="console__token">|</span> <span class="console__token">Select-Object</span> <span class="console__token">-Property</span> <span class="console__token">Name,</span> <span class="console__token">Length</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line">
</span><span class="line"><span class="console__token">Name</span>              <span class="console__token">Length</span>
</span><span class="line"><span class="console__token">----</span>              <span class="console__token">------</span>
</span><span class="line"><span class="console__token">notes.txt</span>           <span class="console__token">4000</span>
</span><span class="line"><span class="console__token">photo.jpg</span>        <span class="console__token">3300000</span>
</span><span class="line"><span class="console__token">app.log</span>          <span class="console__token">2100000</span>
</span><span class="line"><span class="console__token">old.log</span>           <span class="console__token">800000</span>
</span><span class="line"><span class="console__token">archive.zip</span>     <span class="console__token">12600000</span>
</span><span class="line"><span class="console__token">q3-summary.xlsx</span>  <span class="console__token">4200000</span></span></code></pre>
    </div>
  </div></div><p>Add the sort, and the largest files rise to the top:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">Get-ChildItem</span> <span class="console__token">-Recurse</span> <span class="console__token">-File</span> <span class="console__token">|</span> <span class="console__token">Sort-Object</span> <span class="console__token">-Property</span> <span class="console__token">Length</span> <span class="console__token">-Descending</span> <span class="console__token">|</span> <span class="console__token">Select-Object</span> <span class="console__token">-Property</span> <span class="console__token">Name,</span> <span class="console__token">Length</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line">
</span><span class="line"><span class="console__token">Name</span>              <span class="console__token">Length</span>
</span><span class="line"><span class="console__token">----</span>              <span class="console__token">------</span>
</span><span class="line"><span class="console__token">archive.zip</span>     <span class="console__token">12600000</span>
</span><span class="line"><span class="console__token">q3-summary.xlsx</span>  <span class="console__token">4200000</span>
</span><span class="line"><span class="console__token">photo.jpg</span>        <span class="console__token">3300000</span>
</span><span class="line"><span class="console__token">app.log</span>          <span class="console__token">2100000</span>
</span><span class="line"><span class="console__token">old.log</span>           <span class="console__token">800000</span>
</span><span class="line"><span class="console__token">notes.txt</span>           <span class="console__token">4000</span></span></code></pre>
    </div>
  </div></div><h2 id="see-what-the-objects-carry">See what the objects carry<a class="heading-anchor" href="https://looselydecoupled.com/what-does-this-powershell-script-do/#see-what-the-objects-carry"
     aria-label="Link to section: See what the objects carry" data-pagefind-ignore></a>
</h2>
<p>PowerShell passes objects between commands, not text. <code>Get-Member</code> lists what
an object holds, which is where a name like <code>Length</code> in the example comes
from:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">Get-ChildItem</span> <span class="console__token">-File</span> <span class="console__token">|</span> <span class="console__token">Get-Member</span> <span class="console__token">-MemberType</span> <span class="console__token">Property</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line">
</span><span class="line">   <span class="console__token">TypeName:</span> <span class="console__token">System.IO.FileInfo</span>
</span><span class="line">
</span><span class="line"><span class="console__token">Name</span>              <span class="console__token">MemberType</span> <span class="console__token">Definition</span>
</span><span class="line"><span class="console__token">----</span>              <span class="console__token">----------</span> <span class="console__token">----------</span>
</span><span class="line"><span class="console__token">Attributes</span>        <span class="console__token">Property</span>   <span class="console__token">System.IO.FileAttributes</span> <span class="console__token">Attributes</span> <span class="console__token">{get;set;}</span>
</span><span class="line"><span class="console__token">CreationTime</span>      <span class="console__token">Property</span>   <span class="console__token">datetime</span> <span class="console__token">CreationTime</span> <span class="console__token">{get;set;}</span>
</span><span class="line"><span class="console__token">…</span>
</span><span class="line"><span class="console__token">Length</span>            <span class="console__token">Property</span>   <span class="console__token">long</span> <span class="console__token">Length</span> <span class="console__token">{get;}</span>
</span><span class="line"><span class="console__token">LinkTarget</span>        <span class="console__token">Property</span>   <span class="console__token">string</span> <span class="console__token">LinkTarget</span> <span class="console__token">{get;}</span>
</span><span class="line"><span class="console__token">Name</span>              <span class="console__token">Property</span>   <span class="console__token">string</span> <span class="console__token">Name</span> <span class="console__token">{get;}</span>
</span><span class="line"><span class="console__token">UnixFileMode</span>      <span class="console__token">Property</span>   <span class="console__token">System.IO.UnixFileMode</span> <span class="console__token">UnixFileMode</span> <span class="console__token">{get;set;}</span></span></code></pre>
    </div>
  </div></div><h2 id="find-the-cmdlet-for-your-own-task">Find the cmdlet for your own task<a class="heading-anchor" href="https://looselydecoupled.com/what-does-this-powershell-script-do/#find-the-cmdlet-for-your-own-task"
     aria-label="Link to section: Find the cmdlet for your own task" data-pagefind-ignore></a>
</h2>
<p>The same questions work forward, from a task to the command that does it.
Cmdlet names are a verb and a noun, so search by either:</p>
<!-- style-check: off -->
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">Get-Command</span> <span class="console__token">-Noun</span> <span class="console__token">Item</span> <span class="console__token">-Module</span> <span class="console__token">Microsoft.PowerShell.Management</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line">
</span><span class="line"><span class="console__token">CommandType</span>     <span class="console__token">Name</span>                                               <span class="console__token">Version</span>    <span class="console__token">Source</span>
</span><span class="line"><span class="console__token">-----------</span>     <span class="console__token">----</span>                                               <span class="console__token">-------</span>    <span class="console__token">------</span>
</span><span class="line"><span class="console__token">Cmdlet</span>          <span class="console__token">Clear-Item</span>                                         <span class="console__token">7.0.0.0</span>    <span class="console__token">Microsoft.PowerShell.…</span>
</span><span class="line"><span class="console__token">Cmdlet</span>          <span class="console__token">Copy-Item</span>                                          <span class="console__token">7.0.0.0</span>    <span class="console__token">Microsoft.PowerShell.…</span>
</span><span class="line"><span class="console__token">Cmdlet</span>          <span class="console__token">Get-Item</span>                                           <span class="console__token">7.0.0.0</span>    <span class="console__token">Microsoft.PowerShell.…</span>
</span><span class="line"><span class="console__token">Cmdlet</span>          <span class="console__token">Invoke-Item</span>                                        <span class="console__token">7.0.0.0</span>    <span class="console__token">Microsoft.PowerShell.…</span>
</span><span class="line"><span class="console__token">Cmdlet</span>          <span class="console__token">Move-Item</span>                                          <span class="console__token">7.0.0.0</span>    <span class="console__token">Microsoft.PowerShell.…</span>
</span><span class="line"><span class="console__token">Cmdlet</span>          <span class="console__token">New-Item</span>                                           <span class="console__token">7.0.0.0</span>    <span class="console__token">Microsoft.PowerShell.…</span>
</span><span class="line"><span class="console__token">Cmdlet</span>          <span class="console__token">Remove-Item</span>                                        <span class="console__token">7.0.0.0</span>    <span class="console__token">Microsoft.PowerShell.…</span>
</span><span class="line"><span class="console__token">Cmdlet</span>          <span class="console__token">Rename-Item</span>                                        <span class="console__token">7.0.0.0</span>    <span class="console__token">Microsoft.PowerShell.…</span>
</span><span class="line"><span class="console__token">Cmdlet</span>          <span class="console__token">Set-Item</span>                                           <span class="console__token">7.0.0.0</span>    <span class="console__token">Microsoft.PowerShell.…</span></span></code></pre>
    </div>
  </div></div><!-- style-check: on -->
<p><code>Get-Command -Verb Sort, Select, Group</code> finds the commands that sort, pick and
group, plus <code>Select-String</code> and <code>Select-Xml</code> for searching text and XML. From there, <code>Get-Help</code> and <code>Get-Member</code> take you the rest of the way.</p>
<!-- style-check: end procedure -->
<h2 id="expected-result">Expected result<a class="heading-anchor" href="https://looselydecoupled.com/what-does-this-powershell-script-do/#expected-result"
     aria-label="Link to section: Expected result" data-pagefind-ignore></a>
</h2>
<p>The one-liner becomes a script you can read, and running it gives the same
answer it was copied to give:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">Get-ChildItem</span> <span class="console__token">-Recurse</span> <span class="console__token">-File</span> <span class="console__token">|</span> <span class="console__token">Sort-Object</span> <span class="console__token">-Property</span> <span class="console__token">Length</span> <span class="console__token">-Descending</span> <span class="console__token">|</span> <span class="console__token">Select-Object</span> <span class="console__token">-First</span> <span class="console__token">3</span> <span class="console__token">-Property</span> <span class="console__token">Name,</span> <span class="console__token">@{Name</span> <span class="console__token">=</span> <span class="console__token">&#39;MB&#39;;</span> <span class="console__token">Expression</span> <span class="console__token">=</span> <span class="console__token">{</span> <span class="console__token">[math]::Round($_.Length</span> <span class="console__token">/</span> <span class="console__token">1MB,</span> <span class="console__token">1)</span> <span class="console__token">}}</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line">
</span><span class="line"><span class="console__token">Name</span>                <span class="console__token">MB</span>
</span><span class="line"><span class="console__token">----</span>                <span class="console__token">--</span>
</span><span class="line"><span class="console__token">archive.zip</span>     <span class="console__token">12.000</span>
</span><span class="line"><span class="console__token">q3-summary.xlsx</span>  <span class="console__token">4.000</span>
</span><span class="line"><span class="console__token">photo.jpg</span>        <span class="console__token">3.100</span></span></code></pre>
    </div>
  </div></div><h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/what-does-this-powershell-script-do/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="sort-invalid-option----d"><code>sort: invalid option -- D</code><a class="heading-anchor" href="https://looselydecoupled.com/what-does-this-powershell-script-do/#sort-invalid-option----d"
     aria-label="Link to section: sort: invalid option -- D" data-pagefind-ignore></a>
</h3>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">sort:</span> <span class="console__token">invalid</span> <span class="console__token">option</span> <span class="console__token">--</span> <span class="console__token">D</span>
</span><span class="line"><span class="console__token">Usage:</span> <span class="console__token">sort</span> <span class="console__token">[-bcCdfigMmnrsuz]</span> <span class="console__token">[-kPOS1[,POS2]</span> <span class="console__token">...</span> <span class="console__token">]</span> <span class="console__token">[+POS1</span> <span class="console__token">[-POS2]]</span> <span class="console__token">[-S</span> <span class="console__token">memsize]</span> <span class="console__token">[-T</span> <span class="console__token">tmpdir]</span> <span class="console__token">[-t</span> <span class="console__token">separator]</span> <span class="console__token">[-o</span> <span class="console__token">outfile]</span> <span class="console__token">[--batch-size</span> <span class="console__token">size]</span> <span class="console__token">[--files0-from</span> <span class="console__token">file]</span> <span class="console__token">[--heapsort]</span> <span class="console__token">[--mergesort]</span> <span class="console__token">[--radixsort]</span> <span class="console__token">[--qsort]</span> <span class="console__token">[--mmap]</span> <span class="console__token">[--parallel</span> <span class="console__token">thread_no]</span> <span class="console__token">[--human-numeric-sort]</span> <span class="console__token">[--version-sort]</span> <span class="console__token">[--random-sort</span> <span class="console__token">[--random-source</span> <span class="console__token">file]]</span> <span class="console__token">[--compress-program</span> <span class="console__token">program]</span> <span class="console__token">[file</span> <span class="console__token">...]</span></span></code></pre>
    </div>
  </div></div><p>The script was written for Windows, where <code>sort</code> is an alias for
<code>Sort-Object</code>. On macOS the name runs the system&rsquo;s <code>sort</code> program instead.
Use the full cmdlet name:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Sort-Object</span> <span class="n">-Property</span> <span class="n">Length</span> <span class="n">-Descending</span></span></span></code></pre></div></div>
  </div>
</div><h3 id="get-help-shows-only-partial-help"><code>Get-Help</code> shows only partial help<a class="heading-anchor" href="https://looselydecoupled.com/what-does-this-powershell-script-do/#get-help-shows-only-partial-help"
     aria-label="Link to section: Get-Help shows only partial help" data-pagefind-ignore></a>
</h3>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Get-Help</span> <span class="console__token">cannot</span> <span class="console__token">find</span> <span class="console__token">the</span> <span class="console__token">Help</span> <span class="console__token">files</span> <span class="console__token">for</span> <span class="console__token">this</span> <span class="console__token">cmdlet</span> <span class="console__token">on</span> <span class="console__token">this</span> <span class="console__token">computer.</span> <span class="console__token">It</span> <span class="console__token">is</span> <span class="console__token">displaying</span> <span class="console__token">only</span> <span class="console__token">partial</span> <span class="console__token">help.</span></span></code></pre>
    </div>
  </div></div><p>The help files aren&rsquo;t installed. Run <code>Update-Help</code> once, or read the same page
in a browser:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Get-Help</span> <span class="nb">Sort-Object</span> <span class="n">-Online</span></span></span></code></pre></div></div>
  </div>
</div><div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>PowerShell</dt> <dd>7.6.6</dd></div>
    <div class="tested__row"><dt>OS</dt> <dd>macOS 27.0</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
    <item>
      <title>Export Every Published Post From a Blogger Blog as JSON With the API in Python</title>
      <link>https://looselydecoupled.com/export-blogger-posts-json-python/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/export-blogger-posts-json-python/</guid>
      <pubDate>Sat, 06 Dec 2025 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Python</category>
      <category>Automation</category>
      
      <description>You end up with a folder of JSON files, one per published post and named by its date and title, with the HTML body beside each when you want it separate.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Blogger&rsquo;s own export is one XML file. The v3 API gives you every published
post as a record, with its labels, dates, URL and HTML body, and the script
below writes each one to disk as JSON:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">python</span> <span class="console__token">export_blogger_posts.py</span> <span class="console__token">--blog-url</span> <span class="console__token">https://example.blogspot.com</span> <span class="console__token">--write-html</span></span></code></pre>
    </div>
  </div></div><p>The first run opens a browser to authorize a read-only scope and caches the
token; every run after that is silent. It needs a Desktop OAuth client from
Google Cloud, which takes the loopback redirect without any setup. A web-type
client is what stops most first runs: it needs <code>http://localhost:8080/</code> in its
authorized redirect URIs, or the consent screen ends in
<code>redirect_uri_mismatch</code>.</p>

</div>

<p>I started a blog on Blogger in 2001 and wrote there until 2012. Sixty-nine
posts, mostly VBScript and SQL for Systems Management Server, and I wanted
them as files before doing anything else with them. One record per post, the
HTML body separate from the metadata, in a shape a script can read. Blogger&rsquo;s
Settings page offers a single Atom XML file of the whole blog. The API offers
the posts.</p>
<!-- style-check: procedure -->
<h2 id="1-create-an-oauth-client">1. Create an OAuth client<a class="heading-anchor" href="https://looselydecoupled.com/export-blogger-posts-json-python/#1-create-an-oauth-client"
     aria-label="Link to section: 1. Create an OAuth client" data-pagefind-ignore></a>
</h2>
<p>The Blogger API needs a Google Cloud project with the Blogger API enabled and
an OAuth client of type <strong>Desktop</strong>. A web client works too, only if
<code>http://localhost:8080/</code> is in its authorized redirect URIs; a Desktop client
allows loopback redirects without listing them. Download the client JSON.</p>
<p>Two settings on the consent screen matter for a personal tool. The app can
stay in testing, and your own Google account has to be listed as a test user,
or sign-in fails with <code>access_denied</code> before the consent screen. An app
in testing gets
<a href="https://developers.google.com/identity/protocols/oauth2#expiration" rel="external noopener" target="_blank">short-lived refresh tokens<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/export-blogger-posts-json-python/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>,
so a later run opens the browser again; the script treats a failed refresh as
a first run.</p>
<div class="admonition admonition--warning" role="note">
  <p class="admonition__label">
    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/export-blogger-posts-json-python/#i-triangle-alert"/></svg>
    <span>Warning</span>
  </p>
  <p>The client JSON and the cached token are credentials. If the script lives in
a repository, add <code>client_secret*.json</code> and <code>.blogger_token.json</code> to its
<code>.gitignore</code> before the first commit, not after.</p>
</div><h2 id="2-install-the-libraries">2. Install the libraries<a class="heading-anchor" href="https://looselydecoupled.com/export-blogger-posts-json-python/#2-install-the-libraries"
     aria-label="Link to section: 2. Install the libraries" data-pagefind-ignore></a>
</h2>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">python3</span> <span class="console__token">-m</span> <span class="console__token">venv</span> <span class="console__token">.venv</span> <span class="console__token">&amp;&amp;</span> <span class="console__token">source</span> <span class="console__token">.venv/bin/activate</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">python</span> <span class="console__token">-m</span> <span class="console__token">pip</span> <span class="console__token">install</span> <span class="console__token">google-api-python-client</span> <span class="console__token">google-auth-oauthlib</span> <span class="console__token">python-dotenv</span></span></code></pre>
    </div>
  </div></div><p><code>python-dotenv</code> is optional; the script loads a <code>.env</code> when the module is
present and reads the environment either way.</p>
<h2 id="3-the-python-script">3. The Python script<a class="heading-anchor" href="https://looselydecoupled.com/export-blogger-posts-json-python/#3-the-python-script"
     aria-label="Link to section: 3. The Python script" data-pagefind-ignore></a>
</h2>
<div class="code-block" data-copyable data-copy-label="export_blogger_posts.py">
  <div class="code-block__head"><span class="code-block__lang code-block__file">export_blogger_posts.py</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="ch">#!/usr/bin/env python3</span>
</span></span><span class="line"><span class="cl"><span class="s2">&#34;&#34;&#34;Export every published post from a Blogger blog as one JSON file per post.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">__future__</span> <span class="kn">import</span> <span class="n">annotations</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">argparse</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">json</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">os</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">pathlib</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">sys</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">typing</span> <span class="kn">import</span> <span class="n">Any</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">google.auth.exceptions</span> <span class="kn">import</span> <span class="n">RefreshError</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">google.auth.transport.requests</span> <span class="kn">import</span> <span class="n">Request</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">google.oauth2.credentials</span> <span class="kn">import</span> <span class="n">Credentials</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">google_auth_oauthlib.flow</span> <span class="kn">import</span> <span class="n">InstalledAppFlow</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">googleapiclient.discovery</span> <span class="kn">import</span> <span class="n">build</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">googleapiclient.errors</span> <span class="kn">import</span> <span class="n">HttpError</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">SCOPES</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;https://www.googleapis.com/auth/blogger.readonly&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">parse_args</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="n">argparse</span><span class="o">.</span><span class="n">Namespace</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="kn">from</span> <span class="nn">dotenv</span> <span class="kn">import</span> <span class="n">load_dotenv</span>
</span></span><span class="line"><span class="cl">        <span class="n">load_dotenv</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="k">except</span> <span class="ne">ImportError</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">pass</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span> <span class="o">=</span> <span class="n">argparse</span><span class="o">.</span><span class="n">ArgumentParser</span><span class="p">(</span><span class="n">description</span><span class="o">=</span><span class="s2">&#34;Export Blogger posts to JSON files.&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;--client-secret-file&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">default</span><span class="o">=</span><span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;BLOGGER_CLIENT_SECRET_FILE&#34;</span><span class="p">,</span> <span class="s2">&#34;client_secret.json&#34;</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="n">parser</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;--token-file&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">default</span><span class="o">=</span><span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;BLOGGER_TOKEN_FILE&#34;</span><span class="p">,</span> <span class="s2">&#34;.blogger_token.json&#34;</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="n">parser</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;--blog-id&#34;</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;BLOGGER_BLOG_ID&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;--blog-url&#34;</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;BLOGGER_BLOG_URL&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;--output-dir&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">default</span><span class="o">=</span><span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;BLOGGER_OUTPUT_DIR&#34;</span><span class="p">,</span> <span class="s2">&#34;exported_posts&#34;</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="n">parser</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;--auth-port&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nb">type</span><span class="o">=</span><span class="nb">int</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">default</span><span class="o">=</span><span class="nb">int</span><span class="p">(</span><span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;BLOGGER_AUTH_PORT&#34;</span><span class="p">,</span> <span class="s2">&#34;8080&#34;</span><span class="p">)),</span>
</span></span><span class="line"><span class="cl">        <span class="n">help</span><span class="o">=</span><span class="s2">&#34;Loopback port for the OAuth redirect; fixed so it is authorized once.&#34;</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="n">parser</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;--write-html&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">action</span><span class="o">=</span><span class="s2">&#34;store_true&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">help</span><span class="o">=</span><span class="s2">&#34;Also write each post&#39;s HTML body as a .html file.&#34;</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">return</span> <span class="n">parser</span><span class="o">.</span><span class="n">parse_args</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">get_credentials</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">client_secret</span><span class="p">:</span> <span class="n">pathlib</span><span class="o">.</span><span class="n">Path</span><span class="p">,</span> <span class="n">token_path</span><span class="p">:</span> <span class="n">pathlib</span><span class="o">.</span><span class="n">Path</span><span class="p">,</span> <span class="n">auth_port</span><span class="p">:</span> <span class="nb">int</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Credentials</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">creds</span> <span class="o">=</span> <span class="kc">None</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">token_path</span><span class="o">.</span><span class="n">is_file</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Owner-only on macOS and Linux, including a token left by an older,</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># looser run. Windows ignores the mode; the file keeps its folder&#39;s.</span>
</span></span><span class="line"><span class="cl">        <span class="n">token_path</span><span class="o">.</span><span class="n">chmod</span><span class="p">(</span><span class="mo">0o600</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">creds</span> <span class="o">=</span> <span class="n">Credentials</span><span class="o">.</span><span class="n">from_authorized_user_file</span><span class="p">(</span><span class="n">token_path</span><span class="p">,</span> <span class="n">SCOPES</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">creds</span> <span class="ow">and</span> <span class="n">creds</span><span class="o">.</span><span class="n">expired</span> <span class="ow">and</span> <span class="n">creds</span><span class="o">.</span><span class="n">refresh_token</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="n">creds</span><span class="o">.</span><span class="n">refresh</span><span class="p">(</span><span class="n">Request</span><span class="p">())</span>
</span></span><span class="line"><span class="cl">        <span class="k">except</span> <span class="n">RefreshError</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="c1"># An app in testing expires its refresh tokens; consent again.</span>
</span></span><span class="line"><span class="cl">            <span class="n">creds</span> <span class="o">=</span> <span class="kc">None</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">creds</span> <span class="ow">or</span> <span class="ow">not</span> <span class="n">creds</span><span class="o">.</span><span class="n">valid</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">flow</span> <span class="o">=</span> <span class="n">InstalledAppFlow</span><span class="o">.</span><span class="n">from_client_secrets_file</span><span class="p">(</span><span class="n">client_secret</span><span class="p">,</span> <span class="n">SCOPES</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">creds</span> <span class="o">=</span> <span class="n">flow</span><span class="o">.</span><span class="n">run_local_server</span><span class="p">(</span><span class="n">port</span><span class="o">=</span><span class="n">auth_port</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># A new token file is created owner-only rather than tightened after.</span>
</span></span><span class="line"><span class="cl">        <span class="n">fd</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">open</span><span class="p">(</span><span class="n">token_path</span><span class="p">,</span> <span class="n">os</span><span class="o">.</span><span class="n">O_WRONLY</span> <span class="o">|</span> <span class="n">os</span><span class="o">.</span><span class="n">O_CREAT</span> <span class="o">|</span> <span class="n">os</span><span class="o">.</span><span class="n">O_TRUNC</span><span class="p">,</span> <span class="mo">0o600</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">with</span> <span class="n">os</span><span class="o">.</span><span class="n">fdopen</span><span class="p">(</span><span class="n">fd</span><span class="p">,</span> <span class="s2">&#34;w&#34;</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s2">&#34;utf-8&#34;</span><span class="p">)</span> <span class="k">as</span> <span class="n">token_file</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">token_file</span><span class="o">.</span><span class="n">write</span><span class="p">(</span><span class="n">creds</span><span class="o">.</span><span class="n">to_json</span><span class="p">())</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">creds</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">resolve_blog_id</span><span class="p">(</span><span class="n">service</span><span class="p">:</span> <span class="n">Any</span><span class="p">,</span> <span class="n">blog_id</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">,</span> <span class="n">blog_url</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">blog_id</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">blog_id</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">blog_url</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">raise</span> <span class="ne">ValueError</span><span class="p">(</span><span class="s2">&#34;Either BLOGGER_BLOG_ID or BLOGGER_BLOG_URL must be provided.&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">service</span><span class="o">.</span><span class="n">blogs</span><span class="p">()</span><span class="o">.</span><span class="n">getByUrl</span><span class="p">(</span><span class="n">url</span><span class="o">=</span><span class="n">blog_url</span><span class="p">)</span><span class="o">.</span><span class="n">execute</span><span class="p">()[</span><span class="s2">&#34;id&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">fetch_posts</span><span class="p">(</span><span class="n">service</span><span class="p">:</span> <span class="n">Any</span><span class="p">,</span> <span class="n">blog_id</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]]:</span>
</span></span><span class="line"><span class="cl">    <span class="n">posts</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]]</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl">    <span class="n">params</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;blogId&#34;</span><span class="p">:</span> <span class="n">blog_id</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;maxResults&#34;</span><span class="p">:</span> <span class="mi">500</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;fetchBodies&#34;</span><span class="p">:</span> <span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;status&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;LIVE&#34;</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="n">page_token</span> <span class="o">=</span> <span class="kc">None</span>
</span></span><span class="line"><span class="cl">    <span class="k">while</span> <span class="kc">True</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">page_token</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">params</span><span class="p">[</span><span class="s2">&#34;pageToken&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">page_token</span>
</span></span><span class="line"><span class="cl">        <span class="n">response</span> <span class="o">=</span> <span class="n">service</span><span class="o">.</span><span class="n">posts</span><span class="p">()</span><span class="o">.</span><span class="n">list</span><span class="p">(</span><span class="o">**</span><span class="n">params</span><span class="p">)</span><span class="o">.</span><span class="n">execute</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="n">posts</span><span class="o">.</span><span class="n">extend</span><span class="p">(</span><span class="n">response</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;items&#34;</span><span class="p">,</span> <span class="p">[]))</span>
</span></span><span class="line"><span class="cl">        <span class="n">page_token</span> <span class="o">=</span> <span class="n">response</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;nextPageToken&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="n">page_token</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span> <span class="n">posts</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">slugify</span><span class="p">(</span><span class="n">text</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">slug</span> <span class="o">=</span> <span class="s2">&#34;&#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">ch</span><span class="o">.</span><span class="n">lower</span><span class="p">()</span> <span class="k">if</span> <span class="n">ch</span><span class="o">.</span><span class="n">isalnum</span><span class="p">()</span> <span class="k">else</span> <span class="s2">&#34;-&#34;</span> <span class="k">for</span> <span class="n">ch</span> <span class="ow">in</span> <span class="n">text</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="s2">&#34;-&#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="nb">filter</span><span class="p">(</span><span class="kc">None</span><span class="p">,</span> <span class="n">slug</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="s2">&#34;-&#34;</span><span class="p">)))</span> <span class="ow">or</span> <span class="s2">&#34;untitled&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">write_posts</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">posts</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]],</span> <span class="n">output_dir</span><span class="p">:</span> <span class="n">pathlib</span><span class="o">.</span><span class="n">Path</span><span class="p">,</span> <span class="n">write_html</span><span class="p">:</span> <span class="nb">bool</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">output_dir</span><span class="o">.</span><span class="n">mkdir</span><span class="p">(</span><span class="n">parents</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">exist_ok</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">post</span> <span class="ow">in</span> <span class="n">posts</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">title</span> <span class="o">=</span> <span class="n">post</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;title&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="s2">&#34;untitled&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="n">published</span> <span class="o">=</span> <span class="p">(</span><span class="n">post</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;published&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="s2">&#34;&#34;</span><span class="p">)[:</span><span class="mi">10</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">        <span class="n">parts</span> <span class="o">=</span> <span class="p">[</span><span class="n">published</span><span class="p">,</span> <span class="n">slugify</span><span class="p">(</span><span class="n">title</span><span class="p">),</span> <span class="n">post</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;id&#34;</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl">        <span class="n">base_name</span> <span class="o">=</span> <span class="s2">&#34;-&#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">part</span> <span class="k">for</span> <span class="n">part</span> <span class="ow">in</span> <span class="n">parts</span> <span class="k">if</span> <span class="n">part</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">keys</span> <span class="o">=</span> <span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="s2">&#34;id&#34;</span><span class="p">,</span> <span class="s2">&#34;status&#34;</span><span class="p">,</span> <span class="s2">&#34;title&#34;</span><span class="p">,</span> <span class="s2">&#34;url&#34;</span><span class="p">,</span> <span class="s2">&#34;published&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="s2">&#34;updated&#34;</span><span class="p">,</span> <span class="s2">&#34;labels&#34;</span><span class="p">,</span> <span class="s2">&#34;author&#34;</span><span class="p">,</span> <span class="s2">&#34;content&#34;</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="n">record</span> <span class="o">=</span> <span class="p">{</span><span class="n">key</span><span class="p">:</span> <span class="n">post</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">key</span><span class="p">)</span> <span class="k">for</span> <span class="n">key</span> <span class="ow">in</span> <span class="n">keys</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">(</span><span class="n">output_dir</span> <span class="o">/</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">base_name</span><span class="si">}</span><span class="s2">.json&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">write_text</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="n">json</span><span class="o">.</span><span class="n">dumps</span><span class="p">(</span><span class="n">record</span><span class="p">,</span> <span class="n">indent</span><span class="o">=</span><span class="mi">2</span><span class="p">,</span> <span class="n">ensure_ascii</span><span class="o">=</span><span class="kc">False</span><span class="p">),</span> <span class="n">encoding</span><span class="o">=</span><span class="s2">&#34;utf-8&#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="n">write_html</span> <span class="ow">and</span> <span class="n">post</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;content&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="p">(</span><span class="n">output_dir</span> <span class="o">/</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">base_name</span><span class="si">}</span><span class="s2">.html&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">write_text</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">                <span class="n">post</span><span class="p">[</span><span class="s2">&#34;content&#34;</span><span class="p">],</span> <span class="n">encoding</span><span class="o">=</span><span class="s2">&#34;utf-8&#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></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">main</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">args</span> <span class="o">=</span> <span class="n">parse_args</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">client_secret</span> <span class="o">=</span> <span class="n">pathlib</span><span class="o">.</span><span class="n">Path</span><span class="p">(</span><span class="n">args</span><span class="o">.</span><span class="n">client_secret_file</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">client_secret</span><span class="o">.</span><span class="n">exists</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="n">sys</span><span class="o">.</span><span class="n">exit</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="sa">f</span><span class="s2">&#34;Client secret file not found at </span><span class="si">{</span><span class="n">client_secret</span><span class="si">}</span><span class="s2">; &#34;</span>
</span></span><span class="line"><span class="cl">            <span class="s2">&#34;set BLOGGER_CLIENT_SECRET_FILE.&#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></span><span class="line"><span class="cl">        <span class="n">creds</span> <span class="o">=</span> <span class="n">get_credentials</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="n">client_secret</span><span class="p">,</span> <span class="n">pathlib</span><span class="o">.</span><span class="n">Path</span><span class="p">(</span><span class="n">args</span><span class="o">.</span><span class="n">token_file</span><span class="p">),</span> <span class="n">args</span><span class="o">.</span><span class="n">auth_port</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">service</span> <span class="o">=</span> <span class="n">build</span><span class="p">(</span><span class="s2">&#34;blogger&#34;</span><span class="p">,</span> <span class="s2">&#34;v3&#34;</span><span class="p">,</span> <span class="n">credentials</span><span class="o">=</span><span class="n">creds</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">blog_id</span> <span class="o">=</span> <span class="n">resolve_blog_id</span><span class="p">(</span><span class="n">service</span><span class="p">,</span> <span class="n">args</span><span class="o">.</span><span class="n">blog_id</span><span class="p">,</span> <span class="n">args</span><span class="o">.</span><span class="n">blog_url</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">posts</span> <span class="o">=</span> <span class="n">fetch_posts</span><span class="p">(</span><span class="n">service</span><span class="p">,</span> <span class="n">blog_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">write_posts</span><span class="p">(</span><span class="n">posts</span><span class="p">,</span> <span class="n">pathlib</span><span class="o">.</span><span class="n">Path</span><span class="p">(</span><span class="n">args</span><span class="o">.</span><span class="n">output_dir</span><span class="p">),</span> <span class="n">args</span><span class="o">.</span><span class="n">write_html</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;Exported </span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">posts</span><span class="p">)</span><span class="si">}</span><span class="s2"> posts to </span><span class="si">{</span><span class="n">args</span><span class="o">.</span><span class="n">output_dir</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">except</span> <span class="n">HttpError</span> <span class="k">as</span> <span class="n">err</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">sys</span><span class="o">.</span><span class="n">exit</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;Blogger API error: </span><span class="si">{</span><span class="n">err</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="vm">__name__</span> <span class="o">==</span> <span class="s2">&#34;__main__&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">main</span><span class="p">()</span></span></span></code></pre></div></div>
  </div>
</div><p>Three decisions in it are worth knowing before you run it.</p>
<p><strong>The redirect port is explicit.</strong> <code>run_local_server</code> listens on 8080 unless
told otherwise, and a web-type client rejects a redirect it was not told
about. The flag keeps the port in sight, so the URI you authorize on a web
client and the one the script uses can&rsquo;t drift apart. A Desktop client does
not need the entry.</p>
<p><strong>The scope is read-only.</strong> <code>blogger.readonly</code> is all an export needs. The
token this caches cannot post, edit or delete, so a leaked
<code>.blogger_token.json</code> is a smaller problem than it could be. On macOS and
Linux the script also makes the file readable by you alone. On Windows it
takes the permissions of the folder it&rsquo;s in, because
<a href="https://docs.python.org/3/library/os.html#os.chmod" rel="external noopener" target="_blank">Python&rsquo;s <code>os.chmod</code><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/export-blogger-posts-json-python/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a> can
set only the read-only flag there, so keep it in a folder only you can read.</p>
<p><strong>The blog is found by URL.</strong> <code>blogs().getByUrl</code> turns the address you know
into the numeric ID the rest of the API takes. Nothing numeric has to be
copied out of the Blogger dashboard. Pass <code>--blog-id</code> if you have it already.</p>
<h2 id="4-run-it">4. Run it<a class="heading-anchor" href="https://looselydecoupled.com/export-blogger-posts-json-python/#4-run-it"
     aria-label="Link to section: 4. Run it" data-pagefind-ignore></a>
</h2>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">export</span> <span class="console__token">BLOGGER_CLIENT_SECRET_FILE=client_secret.json</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">python</span> <span class="console__token">export_blogger_posts.py</span> <span class="console__token">--blog-url</span> <span class="console__token">https://example.blogspot.com</span> <span class="console__token">--write-html</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">Exported</span> <span class="console__token">69</span> <span class="console__token">posts</span> <span class="console__token">to</span> <span class="console__token">exported_posts</span></span></code></pre>
    </div>
  </div></div><p>The first run opens a browser for consent and writes <code>.blogger_token.json</code>.
Each post becomes <code>YYYY-MM-DD-&lt;title-slug&gt;-&lt;postId&gt;.json</code>, and with
<code>--write-html</code> a <code>.html</code> beside it holding the body alone:</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">exported_posts/
</span></span><span class="line"><span class="cl">  2001-08-15-sql-to-return-identity-when-inserting-2676633582946536328.json
</span></span><span class="line"><span class="cl">  2001-08-15-sql-to-return-identity-when-inserting-2676633582946536328.html
</span></span><span class="line"><span class="cl">  ...</span></span></code></pre></div></div>
  </div>
</div><p>Each record carries what the API returns for the post, with the body last:</p>
<div class="code-block" data-copyable data-copy-label="JSON">
  <div class="code-block__head"><span class="code-block__lang">JSON</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;4298533488206221767&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;status&#34;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;title&#34;</span><span class="p">:</span> <span class="s2">&#34;Reading the SCCM Client GUID&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;url&#34;</span><span class="p">:</span> <span class="s2">&#34;http://example.blogspot.com/2012/03/reading-sccm-client-guid.html&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;published&#34;</span><span class="p">:</span> <span class="s2">&#34;2012-03-27T15:43:00-07:00&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;updated&#34;</span><span class="p">:</span> <span class="s2">&#34;2012-04-30T12:00:20-07:00&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;labels&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;PowerShell&#34;</span><span class="p">,</span> <span class="s2">&#34;SCCM&#34;</span><span class="p">,</span> <span class="s2">&#34;VBScript&#34;</span><span class="p">,</span> <span class="s2">&#34;WMI&#34;</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;author&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;displayName&#34;</span><span class="p">:</span> <span class="s2">&#34;...&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;content&#34;</span><span class="p">:</span> <span class="s2">&#34;&lt;p&gt;...&lt;/p&gt;&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p><code>status</code> came back null on every post in this export. The API sets it only
for admin-level requests, and the script doesn&rsquo;t make one.</p>
<!-- style-check: end procedure -->
<h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/export-blogger-posts-json-python/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="redirect_uri_mismatch-on-the-consent-screen"><code>redirect_uri_mismatch</code> on the consent screen<a class="heading-anchor" href="https://looselydecoupled.com/export-blogger-posts-json-python/#redirect_uri_mismatch-on-the-consent-screen"
     aria-label="Link to section: redirect_uri_mismatch on the consent screen" data-pagefind-ignore></a>
</h3>
<p>The client is web-type and
<code>http://localhost:8080/</code> isn&rsquo;t in its authorized redirects; the trailing
slash is part of the match. Add it, or recreate the client as Desktop.</p>
<h3 id="access_denied-before-the-consent-screen"><code>access_denied</code> before the consent screen<a class="heading-anchor" href="https://looselydecoupled.com/export-blogger-posts-json-python/#access_denied-before-the-consent-screen"
     aria-label="Link to section: access_denied before the consent screen" data-pagefind-ignore></a>
</h3>
<p>The app is in testing and your
account isn&rsquo;t a test user. Add it under the OAuth consent screen.</p>
<h3 id="fewer-posts-than-expected">Fewer posts than expected<a class="heading-anchor" href="https://looselydecoupled.com/export-blogger-posts-json-python/#fewer-posts-than-expected"
     aria-label="Link to section: Fewer posts than expected" data-pagefind-ignore></a>
</h3>
<p>The script asks for 500 posts a page and follows <code>nextPageToken</code> until the
API stops sending one. It asks for published posts only, so drafts and
scheduled posts aren&rsquo;t in the count.</p>
<h3 id="old-files-after-a-second-export">Old files after a second export<a class="heading-anchor" href="https://looselydecoupled.com/export-blogger-posts-json-python/#old-files-after-a-second-export"
     aria-label="Link to section: Old files after a second export" data-pagefind-ignore></a>
</h3>
<p>The script writes into the output folder and never removes anything. A post
renamed since the last run leaves its old file beside the new one, and an
unpublished post keeps its file. Export into an empty folder, with
<code>--output-dir</code>, when the folder has to match the blog.</p>
<h3 id="client-secret-file-not-found"><code>Client secret file not found</code><a class="heading-anchor" href="https://looselydecoupled.com/export-blogger-posts-json-python/#client-secret-file-not-found"
     aria-label="Link to section: Client secret file not found" data-pagefind-ignore></a>
</h3>
<p>The path is read from
<code>BLOGGER_CLIENT_SECRET_FILE</code> or <code>--client-secret-file</code> and is relative to the
directory you run from, not the script&rsquo;s.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>Python</dt> <dd>3.13</dd></div>
    <div class="tested__row"><dt>google-api-python-client</dt> <dd>2.187</dd></div>
    <div class="tested__row"><dt>google-auth-oauthlib</dt> <dd>1.2</dd></div>
    <div class="tested__row"><dt>Blog</dt> <dd>69 posts, 2001 to 2012</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>December 2025</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
    <item>
      <title>PowerShell Parameter Validation for Safer Scripts</title>
      <link>https://looselydecoupled.com/powershell-parameter-validation/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/powershell-parameter-validation/</guid>
      <pubDate>Wed, 15 Jan 2025 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>PowerShell</category>
      <category>Code quality</category>
      <category>Automation</category>
      
      <description>Validation attributes stop a PowerShell script before it runs on bad input, but ValidateSet and ValidatePattern ignore case unless told not to.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Add a validation attribute to each parameter: <code>ValidateSet</code> for a fixed list,
<code>ValidatePattern</code> for a format, <code>ValidateRange</code> for a number and
<code>ValidateScript</code> for anything else. PowerShell checks every value a caller
passes before the script&rsquo;s first line runs, and stops with an error naming the
parameter. It never checks a default, so keep each default inside its own rule:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="c">#</span><span class="k">Requires</span><span class="w"> </span><span class="kd">-Version</span><span class="na"> 7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">param</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="nb">Parameter</span><span class="p">(</span><span class="na">Mandatory</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="nb">ValidateSet</span><span class="p">(</span><span class="s1">&#39;dev&#39;</span><span class="p">,</span> <span class="s1">&#39;staging&#39;</span><span class="p">,</span> <span class="s1">&#39;prod&#39;</span><span class="p">,</span> <span class="n">IgnoreCase</span> <span class="p">=</span> <span class="vm">$false</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="no">string</span><span class="p">]</span><span class="nv">$Environment</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="nb">Parameter</span><span class="p">(</span><span class="na">Mandatory</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="nb">ValidatePattern</span><span class="p">(</span><span class="s1">&#39;^app-[a-z0-9-]+\z&#39;</span><span class="p">,</span> <span class="n">Options</span> <span class="p">=</span> <span class="s1">&#39;None&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">ErrorMessage</span> <span class="p">=</span> <span class="s1">&#39;{0} is not a service name. Use app- and lowercase letters, digits or hyphens.&#39;</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="no">string</span><span class="p">]</span><span class="nv">$ServiceName</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="nb">ValidateRange</span><span class="p">(</span><span class="mf">1</span><span class="p">,</span> <span class="mf">365</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="no">int</span><span class="p">]</span><span class="nv">$DaysToKeep</span> <span class="p">=</span> <span class="mf">30</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="nb">Parameter</span><span class="p">(</span><span class="na">Mandatory</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="nb">ValidateScript</span><span class="p">({</span> <span class="nb">Test-Path</span> <span class="n">-Path</span> <span class="nv">$_</span> <span class="n">-PathType</span> <span class="n">Container</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="n">ErrorMessage</span> <span class="p">=</span> <span class="s1">&#39;Folder {0} does not exist.&#39;</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="no">string</span><span class="p">]</span><span class="nv">$LogRoot</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span></span></span></code></pre></div></div>
  </div>
</div><p><code>ValidateSet</code> and <code>ValidatePattern</code> ignore case by default, so <code>Prod</code> passes a
set of <code>prod</code>. <code>IgnoreCase = $false</code> and <code>Options = 'None'</code> make them exact.
Windows PowerShell 5.1 can&rsquo;t parse this <code>param</code> block, because <code>ErrorMessage</code>
exists from PowerShell 6 on; drop that argument to run it there.</p>

</div>

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

</div>

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

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

</div>

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

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

</div>

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

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

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

</div>

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

</div>

<p>On a machine that has winget, <code>winget install Microsoft.VisualStudioCode --scope machine --silent</code>
does this job with less to know. Windows Server 2022 doesn&rsquo;t ship winget.
VS Code&rsquo;s Windows installer is an Inno
Setup package, and Inno Setup has had stable command-line switches for
decades. The whole job is fetching the right build, running it with those
switches, and knowing which of its optional tasks to turn on or off.</p>
<!-- style-check: procedure -->
<h2 id="download-the-system-installer">Download the system installer<a class="heading-anchor" href="https://looselydecoupled.com/install-vscode-silently-powershell/#download-the-system-installer"
     aria-label="Link to section: Download the system installer" data-pagefind-ignore></a>
</h2>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$installer</span> <span class="p">=</span> <span class="nb">Join-Path</span> <span class="nv">$env:TEMP</span> <span class="s1">&#39;VSCodeSetup-x64.exe&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Invoke-WebRequest</span> <span class="n">-OutFile</span> <span class="nv">$installer</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-Uri</span> <span class="s1">&#39;https://code.visualstudio.com/sha/download?build=stable&amp;os=win32-x64&#39;</span></span></span></code></pre></div></div>
  </div>
</div><p>That URL is Microsoft&rsquo;s documented direct-download link for the current stable
<strong>system</strong> installer, which puts VS Code under <code>Program Files</code> for every
account on the machine. Change <code>os=win32-x64</code> to <code>os=win32-x64-user</code> for the
per-user installer, which needs no elevation and installs under one account&rsquo;s
<code>%LocalAppData%</code>. For a server or a shared workstation the system build is the
one you want.</p>
<p>On Windows PowerShell 5.1 the download is faster with the progress bar off:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$ProgressPreference</span> <span class="p">=</span> <span class="s1">&#39;SilentlyContinue&#39;</span></span></span></code></pre></div></div>
  </div>
</div><h2 id="run-it-without-a-window">Run it without a window<a class="heading-anchor" href="https://looselydecoupled.com/install-vscode-silently-powershell/#run-it-without-a-window"
     aria-label="Link to section: Run it without a window" data-pagefind-ignore></a>
</h2>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Start-Process</span> <span class="n">-FilePath</span> <span class="nv">$installer</span> <span class="n">-Wait</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-ArgumentList</span> <span class="s1">&#39;/VERYSILENT&#39;</span><span class="p">,</span> <span class="s1">&#39;/NORESTART&#39;</span><span class="p">,</span> <span class="s1">&#39;/MERGETASKS=!runcode,addtopath&#39;</span></span></span></code></pre></div></div>
  </div>
</div><p>Inno Setup reads its switches before it draws anything, so <code>/VERYSILENT</code> with
<code>/NORESTART</code> is a complete unattended install with no answer file. <code>-Wait</code>
matters. Without it the script moves on while the installer is still
running, and anything after — a settings file, an extension install — races
it.</p>
<p>The switches:</p>
<div class="table-wrap" data-scrollable data-label="Table: Switch">
  <table>
    <thead>
      <tr><th scope="col">Switch</th><th scope="col">Effect</th></tr>
    </thead>
    <tbody>
      <tr><td><code>/VERYSILENT</code></td><td>No wizard, no progress window. <code>/SILENT</code> shows progress only.</td></tr>
      <tr><td><code>/NORESTART</code></td><td>Never reboot, even when the installer requests one.</td></tr>
      <tr><td><code>/MERGETASKS=…</code></td><td>Turn the optional tasks on or off; a leading <code>!</code> turns one off.</td></tr>
    </tbody>
  </table>
</div>
<p>The installer offers six tasks. After install, <code>runcode</code> launches the editor,
<code>addtopath</code> edits PATH, and <code>associatewithfiles</code> registers Code as an editor
for supported file types. The other three, <code>desktopicon</code>,
<code>addcontextmenufiles</code> and <code>addcontextmenufolders</code>, add the shortcut and the
&ldquo;Open with Code&rdquo; entries in Explorer. On a headless install <code>runcode</code> leaves an
editor window nobody will ever see, and it is on by default, so turn it off.
Turning <code>runcode</code> off leaves every other task as it was, because <code>/MERGETASKS</code>
applies your changes on top of the installer&rsquo;s defaults. The <code>/TASKS</code> switch
would replace them wholesale.</p>
<h2 id="verify">Verify<a class="heading-anchor" href="https://looselydecoupled.com/install-vscode-silently-powershell/#verify"
     aria-label="Link to section: Verify" data-pagefind-ignore></a>
</h2>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Remove-Item</span> <span class="nv">$installer</span>
</span></span><span class="line"><span class="cl"><span class="nb">Test-Path</span> <span class="s2">&#34;</span><span class="nv">$env:ProgramFiles</span><span class="s2">\Microsoft VS Code\Code.exe&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">&amp;</span> <span class="s2">&#34;</span><span class="nv">$env:ProgramFiles</span><span class="s2">\Microsoft VS Code\bin\code.cmd&#34;</span> <span class="p">-</span><span class="n">-version</span></span></span></code></pre></div></div>
  </div>
</div><p>The version check calls the launcher by path because the PATH change from
<code>addtopath</code> is not visible in the session that ran the installer.</p>
<!-- style-check: end procedure -->
<h2 id="expected-result">Expected result<a class="heading-anchor" href="https://looselydecoupled.com/install-vscode-silently-powershell/#expected-result"
     aria-label="Link to section: Expected result" data-pagefind-ignore></a>
</h2>
<p><code>Code.exe</code> exists under <code>Program Files\Microsoft VS Code</code>, the uninstall entry
reports the version you expected, and <code>code --version</code> works in a new shell.
A new one, because <code>addtopath</code> edits the machine PATH and the session that ran
the installer still has the old one.</p>
<h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/install-vscode-silently-powershell/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="script-hangs-at-start-process">Script hangs at <code>Start-Process</code><a class="heading-anchor" href="https://looselydecoupled.com/install-vscode-silently-powershell/#script-hangs-at-start-process"
     aria-label="Link to section: Script hangs at Start-Process" data-pagefind-ignore></a>
</h3>
<p>The installer is showing a dialog you can&rsquo;t see. Usually VS Code is already
running under this account and Inno Setup is asking whether to close it. Stop
<code>Code.exe</code> first, or add <code>/CLOSEAPPLICATIONS</code>.</p>
<h3 id="access-denied-under-program-files">Access denied under <code>Program Files</code><a class="heading-anchor" href="https://looselydecoupled.com/install-vscode-silently-powershell/#access-denied-under-program-files"
     aria-label="Link to section: Access denied under Program Files" data-pagefind-ignore></a>
</h3>
<p>The session isn&rsquo;t elevated. The system installer needs it; the user installer
doesn&rsquo;t.</p>
<h3 id="code-isnt-recognized-after-the-install"><code>code</code> isn&rsquo;t recognized after the install<a class="heading-anchor" href="https://looselydecoupled.com/install-vscode-silently-powershell/#code-isnt-recognized-after-the-install"
     aria-label="Link to section: code isn’t recognized after the install" data-pagefind-ignore></a>
</h3>
<p>Either <code>addtopath</code> wasn&rsquo;t in <code>/MERGETASKS</code>, or you&rsquo;re in the same shell that
ran the installer. Open a new one. On Windows Server the change reaches
services and scheduled tasks only after they restart.</p>
<h3 id="download-is-a-few-kilobytes-of-html">Download is a few kilobytes of HTML<a class="heading-anchor" href="https://looselydecoupled.com/install-vscode-silently-powershell/#download-is-a-few-kilobytes-of-html"
     aria-label="Link to section: Download is a few kilobytes of HTML" data-pagefind-ignore></a>
</h3>
<p>The download URL redirected to an error page, from a proxy or an outbound rule
blocking the CDN. Check the signature before running it:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Get-AuthenticodeSignature</span> <span class="nv">$installer</span> <span class="p">|</span> <span class="nb">Select-Object</span> <span class="n">Status</span><span class="p">,</span> <span class="n">SignerCertificate</span></span></span></code></pre></div></div>
  </div>
</div><p>The real installer reports <code>Valid</code>, signed by Microsoft Corporation. Anything
else means the file isn&rsquo;t the installer; don&rsquo;t run it.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>OS</dt> <dd>Windows Server 2019 (10.0.17763)</dd></div>
    <div class="tested__row"><dt>PowerShell</dt> <dd>Windows PowerShell 5.1</dd></div>
    <div class="tested__row"><dt>VS Code</dt> <dd>1.139.0 upgraded to 1.140.0</dd></div>
    <div class="tested__row"><dt>Installer</dt> <dd>system, Inno Setup</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
  </channel>
</rss>
