<?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>Loosely Decoupled</title>
    <link>https://looselydecoupled.com/</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>Sat, 17 Oct 2026 00:00:00 +0000</lastBuildDate>
    <atom:link href="https://looselydecoupled.com/index.xml" rel="self" type="application/rss+xml" />
    
    <item>
      <title>Why Doesn&#39;t My Claude Code Plugin Load in a Cloud Session?</title>
      <link>https://looselydecoupled.com/claude-code-plugin-not-loading-cloud-session/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/claude-code-plugin-not-loading-cloud-session/</guid>
      <pubDate>Sat, 17 Oct 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Automation</category>
      <category>DevOps</category>
      
      <description>The install worked in my test cloud session only because it had the plugin&#39;s private repository attached. Sessions without it failed silently.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Claude Code cloud sessions don&rsquo;t install the plugins a repository&rsquo;s <code>.claude/settings.json</code> turns on, and their GitHub proxy only serves the repositories attached to the session. So a plugin from a private marketplace doesn&rsquo;t load there. Its skill still can. Zip the skill folder, upload it under <strong>Customize</strong> &gt; <strong>Skills</strong> on claude.ai, and turn it on. In a new cloud session with nothing attached, it showed up as <code>anthropic-skills:&lt;skill&gt;</code>. The trade-offs: you upload again for every release, and the skill loads in every session on your account. A missing skill doesn&rsquo;t raise an error. Claude still answers, but without the skill&rsquo;s process, so ask the session to list its skills.</p>

</div>

<p>I was working on a project spread across several private repositories, and I created a skill I wanted to share between them as a Claude Code plugin. My plan was a private GitHub repository that&rsquo;s both the plugin and its marketplace, so the skill stays in sync on my laptop and in cloud sessions. Most of my work happens in Claude Code on the web, because I like that it keeps the work off my laptop, runs in isolation, and is there on my phone. I tested the install commands by hand in a cloud session, then put them in the environment&rsquo;s setup script, and the test worked. In the next real session the skill never loaded, and I got no error, not even a small one. Here&rsquo;s how I tracked down the cause, what I tried that didn&rsquo;t work, and the two workarounds that did.</p>
<h2 id="skill-doesnt-load-in-a-cloud-session-and-theres-no-error">Skill doesn&rsquo;t load in a cloud session, and there&rsquo;s no error<a class="heading-anchor" href="https://looselydecoupled.com/claude-code-plugin-not-loading-cloud-session/#skill-doesnt-load-in-a-cloud-session-and-theres-no-error"
     aria-label="Link to section: Skill doesn’t load in a cloud session, and there’s no error" data-pagefind-ignore></a>
</h2>
<p>The session starts normally. You paste the notes the skill is built for, and Claude gives a reasonable, generic answer instead of following the skill.</p>
<p>That&rsquo;s what makes it easy to miss. When a skill doesn&rsquo;t load, Claude still answers, and nothing hints that a skill should have run. The only clue I had was that the answer was good but didn&rsquo;t follow the skill&rsquo;s steps.</p>
<p>My test had passed only because of how that session was set up. The session I tested in had the skills repository attached as one of its sources. The real sessions had only the repository I was working in. I also never confirmed that the saved setup script still had the install commands in it. Its log only showed output from another installer.</p>
<p>A second session found the cause. I messaged the failing session from the one where I built the plugin and asked it to inspect its own environment: the plugin list, the contents of <code>~/.claude</code>, and the settings files. It reported that no marketplace had ever been added.</p>
<h2 id="cloud-session-cant-reach-a-private-repository-that-isnt-attached">Cloud session can&rsquo;t reach a private repository that isn&rsquo;t attached<a class="heading-anchor" href="https://looselydecoupled.com/claude-code-plugin-not-loading-cloud-session/#cloud-session-cant-reach-a-private-repository-that-isnt-attached"
     aria-label="Link to section: Cloud session can’t reach a private repository that isn’t attached" data-pagefind-ignore></a>
</h2>
<p>A cloud session reaches GitHub through a proxy, and the <a href="https://code.claude.com/docs/en/cloud-environments#github-proxy" rel="external noopener" target="_blank">cloud environments 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/claude-code-plugin-not-loading-cloud-session/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a> says it serves GitHub API requests only for the repositories attached to the session. Git behaved the same way for me. In a fresh session with only the consuming repository attached, I tried to reach the skills repository:</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">git</span> <span class="console__token">ls-remote</span> <span class="console__token">https://github.com/&lt;owner&gt;/&lt;skills-repo&gt;.git</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">fatal:</span> <span class="console__token">could</span> <span class="console__token">not</span> <span class="console__token">read</span> <span class="console__token">Username</span> <span class="console__token">for</span> <span class="console__token">&#39;https://github.com&#39;:</span> <span class="console__token">terminal</span> <span class="console__token">prompts</span> <span class="console__token">disabled</span></span></code></pre>
    </div>
  </div></div><p>No credentials were supplied for it, so git asked for a username and gave up, even though the Claude GitHub App had access to that repository on my account. I didn&rsquo;t run the same command against the attached repository to compare. The same session hit the limit again, this time with nothing to do with plugins: <code>npm ci</code> couldn&rsquo;t fetch the project&rsquo;s private SDK, which lives in another private repository that wasn&rsquo;t attached.</p>
<p>Here&rsquo;s what that means for each way of installing the plugin:</p>
<ul>
<li><strong>Repository settings:</strong> The <a href="https://code.claude.com/docs/en/cloud-environments#what-carries-over-from-your-setup" rel="external noopener" target="_blank">documentation<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/claude-code-plugin-not-loading-cloud-session/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a> lists plugins and marketplaces declared in a repo&rsquo;s <code>.claude/settings.json</code> as not available in cloud sessions. I never put the plugin there, so this is what the docs say, not something I tested.</li>
<li><strong>Setup-script install:</strong> I never confirmed my install commands ran, and the log was empty. If the script can only reach attached repositories, like the session, it had nothing to fetch. I haven&rsquo;t tested that.</li>
<li><strong>Plugin installed mid-session:</strong> In one session, installing from an attached copy left the plugin enabled but not loaded, and the cloud UI has <code>/reload-skills</code> but no <code>/reload-plugins</code>. In an earlier session the same install did make the skill appear. I don&rsquo;t know what was different.</li>
</ul>
<h2 id="plugin-added-on-claudeai-never-reaches-the-session">Plugin added on claude.ai never reaches the session<a class="heading-anchor" href="https://looselydecoupled.com/claude-code-plugin-not-loading-cloud-session/#plugin-added-on-claudeai-never-reaches-the-session"
     aria-label="Link to section: Plugin added on claude.ai never reaches the session" data-pagefind-ignore></a>
</h2>
<p>Your claude.ai account has a plugins page too. Under <strong>Customize</strong> &gt; <strong>Plugins</strong> &gt; <strong>Add</strong> &gt; <strong>Add marketplace</strong> &gt; <strong>Add from a repository</strong>, claude.ai takes a private GitHub repository once the Claude GitHub App can read it. The <a href="https://claude.com/docs/plugins/overview" rel="external noopener" target="_blank">plugins 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/claude-code-plugin-not-loading-cloud-session/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a> says a plugin you add there downloads to Claude Code as a synced plugin, which the <a href="https://code.claude.com/docs/en/plugins/loading#synced-plugins" rel="external noopener" target="_blank">plugin loading 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/claude-code-plugin-not-loading-cloud-session/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a> names <code>&lt;name&gt;@synced</code>. That reference says synced plugins load in Cowork and in signed-in terminal sessions. It doesn&rsquo;t mention cloud sessions, although mine did get Anthropic&rsquo;s synced plugins.</p>
<figure><img src="https://looselydecoupled.com/claude-code-plugin-not-loading-cloud-session/add-marketplace_hu_cc27f6804a5ca6de.png" alt="The Add marketplace dialog on claude.ai, with a URL field that takes a GitHub owner/repo or a Git repository URL." width="704" height="264" loading="lazy" decoding="async"><figcaption>Customize, Plugins, Add, Add marketplace, Add from a repository.</figcaption>
</figure>
<p>The marketplace synced and the plugin showed as added, with no error. In the ten minutes I watched, it never showed up anywhere:</p>
<ul>
<li><strong>Cloud session, started five minutes after the add:</strong> The synced manifest under <code>~/.claude/plugins/synced/</code> listed 10 plugins, all from Anthropic&rsquo;s <code>knowledge-work-plugins</code> marketplace. Mine wasn&rsquo;t there.</li>
<li><strong>Terminal on my Mac, started ten minutes after the add:</strong> It printed <code>Plugins changed. Run /reload-plugins to activate.</code>, and the rewritten manifest listed 4 plugins from the same Anthropic marketplace. Mine wasn&rsquo;t there either.</li>
<li><strong><code>claude plugin marketplace list</code>:</strong> Its <code>From claude.ai:</code> section listed only <strong>My Uploads</strong> and the Anthropic Directory.</li>
</ul>
<p>One caveat: my Mac already had the same plugin installed from the command line, which may have affected that result. The cloud session had no local copy, so its result is clean. <strong>Add</strong> &gt; <strong>Upload plugin</strong> is a second way to put a plugin on your account. I didn&rsquo;t try it.</p>
<p>An open issue, <a href="https://github.com/anthropics/claude-code/issues/92031" rel="external noopener" target="_blank">anthropics/claude-code#92031<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/claude-code-plugin-not-loading-cloud-session/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>, describes the same thing from another account: plugins installed on claude.ai never reach cloud sessions, while skills enabled on the account do. That&rsquo;s the route in the next section.</p>
<h2 id="upload-the-skill-to-your-claudeai-account">Upload the skill to your claude.ai account<a class="heading-anchor" href="https://looselydecoupled.com/claude-code-plugin-not-loading-cloud-session/#upload-the-skill-to-your-claudeai-account"
     aria-label="Link to section: Upload the skill to your claude.ai account" data-pagefind-ignore></a>
</h2>
<p>A skill doesn&rsquo;t need to be a plugin to reach a cloud session. The <a href="https://code.claude.com/docs/en/skills#skills-in-cowork-and-cloud-sessions" rel="external noopener" target="_blank">skills 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/claude-code-plugin-not-loading-cloud-session/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a> says cloud sessions load the skills enabled for your claude.ai account, synced at session start.</p>
<ol>
<li>
<p>On your machine, zip the skill folder, with its <code>SKILL.md</code> inside the folder. To build the zip from a release tag in the skills repository, 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">git</span> <span class="console__token">archive</span> <span class="console__token">--format=zip</span> <span class="console__token">--prefix=&lt;skill&gt;/</span> <span class="console__token">-o</span> <span class="console__token">&lt;skill&gt;.zip</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">&lt;tag&gt;:plugins/&lt;plugin&gt;/skills/&lt;skill&gt;</span></span></code></pre>
    </div>
  </div></div></li>
<li>
<p>On claude.ai, go to <strong>Customize</strong> &gt; <strong>Skills</strong> &gt; <strong>Add</strong> &gt; <strong>Upload skill</strong>, upload the zip, and turn the skill on.</p>
</li>
<li>
<p>Start a new cloud session, and ask Claude to list its skills.</p>
</li>
</ol>
<figure><img src="https://looselydecoupled.com/claude-code-plugin-not-loading-cloud-session/upload-skill_hu_769c3e4dfaeb44d3.png" alt="The Upload a skill page on claude.ai. It takes a .md, .zip or .skill file, and a security scan runs on upload." width="1030" height="480" loading="lazy" decoding="async"><figcaption>Customize, Skills, Add, Upload skill.</figcaption>
</figure>
<p>On upload, claude.ai previews the skill&rsquo;s name and description and runs a security scan. In a new cloud session with only the consuming repository attached, the skill showed up as <code>anthropic-skills:&lt;skill&gt;</code>. I checked that in one session. My test of Claude actually using the skill, in the section on confirming it loaded, ran on the copied version from the next section, which has the same files.</p>
<p>There are two costs. claude.ai numbers the skill&rsquo;s versions v1, v2 and so on, not by your git tags, and each release means another upload. It&rsquo;s an account skill, so it loads in every session on the account, not only the repositories that use it. That includes terminal sessions: on my Mac it appeared beside the same skill from the installed plugin, so the session offered it twice. Uninstall the local plugin, or turn the account skill off, to keep one.</p>
<h2 id="copy-the-skill-into-the-cloud-session">Copy the skill into the cloud session<a class="heading-anchor" href="https://looselydecoupled.com/claude-code-plugin-not-loading-cloud-session/#copy-the-skill-into-the-cloud-session"
     aria-label="Link to section: Copy the skill into the cloud session" data-pagefind-ignore></a>
</h2>
<p>If a session needs a specific release, copy the skill in from the skills repository instead. The <a href="https://code.claude.com/docs/en/skills" rel="external noopener" target="_blank">skills 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/claude-code-plugin-not-loading-cloud-session/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a> says Claude Code watches <code>~/.claude/skills/</code> and picks up a skill added there during the session, without a restart.</p>
<ol>
<li>
<p>Start the cloud session with two repositories attached: the one you&rsquo;re working in and the skills repository.</p>
</li>
<li>
<p>Ask Claude where the skills repository was cloned. It was <code>/home/user/&lt;skills-repo&gt;</code> in my sessions.</p>
</li>
<li>
<p>Ask Claude to copy the skill folder into your user skills directory, changing nothing else:</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">mkdir</span> <span class="console__token">-p</span> <span class="console__token">~/.claude/skills</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">cp</span> <span class="console__token">-R</span> <span class="console__token">/home/user/&lt;skills-repo&gt;/plugins/&lt;plugin&gt;/skills/&lt;skill&gt;</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">~/.claude/skills/&lt;skill&gt;</span></span></code></pre>
    </div>
  </div></div></li>
<li>
<p>Ask Claude to list its skills. If <code>~/.claude/skills/</code> didn&rsquo;t exist when the session started, run <code>/reload-skills</code> first, because Claude Code wasn&rsquo;t watching it.</p>
</li>
</ol>
<figure><img src="https://looselydecoupled.com/claude-code-plugin-not-loading-cloud-session/reload-skills_hu_873f416ef627492b.png" alt="The prompt box of a cloud session with /reload-skills typed and its description: pick up skills added or changed on disk during this session." width="845" height="140" loading="lazy" decoding="async">
</figure>
<p>The skill loads as plain <code>&lt;skill&gt;</code>, with no prefix. To copy a specific release, run <code>git -C /home/user/&lt;skills-repo&gt; fetch --tags</code> and <code>git -C /home/user/&lt;skills-repo&gt; checkout &lt;tag&gt;</code> before the copy. I haven&rsquo;t tested that step. The copy doesn&rsquo;t carry over to other sessions.</p>
<p>If every session in a repository needs the skill, commit a copy under that repository&rsquo;s <code>.claude/skills/</code> instead. The documentation lists the repository&rsquo;s own skills as available in cloud sessions. The cost is a copy in every repository, which is what the plugin was meant to avoid. I built it once, checked it against the release tag, and didn&rsquo;t merge it.</p>
<p>Both routes bring over the plugin&rsquo;s skills only. Hooks, agents and MCP servers in the plugin don&rsquo;t load this way, and I haven&rsquo;t found a way to load them in a cloud session without <code>/reload-plugins</code>.</p>
<h2 id="confirm-the-skill-loaded">Confirm the skill loaded<a class="heading-anchor" href="https://looselydecoupled.com/claude-code-plugin-not-loading-cloud-session/#confirm-the-skill-loaded"
     aria-label="Link to section: Confirm the skill loaded" data-pagefind-ignore></a>
</h2>
<p>There are two checks. The session&rsquo;s skill list shows the skill loaded. Asking a question the skill is built for, without naming the skill, shows that Claude actually uses it. My first test was a request to look over a project and say where it could be better. The session announced the skill&rsquo;s first step and worked through its process. If the answer is generic, ask which skill it used.</p>
<p>For the skill list, ask Claude to list its skills, and look for <code>&lt;skill&gt;</code> or <code>anthropic-skills:&lt;skill&gt;</code>.</p>
<p>A plugin that shows as enabled in <code>claude plugin list</code> isn&rsquo;t proof. In one of my sessions it was enabled and never loaded. A folder in <code>~/.claude/skills/</code> isn&rsquo;t proof either, until the session lists it.</p>
<h2 id="do-this-with-a-coding-agent">Do this with a coding agent<a class="heading-anchor" href="https://looselydecoupled.com/claude-code-plugin-not-loading-cloud-session/#do-this-with-a-coding-agent"
     aria-label="Link to section: Do this with a coding agent" data-pagefind-ignore></a>
</h2>
<p>What found my cause was an agent inspecting its own environment. Paste this into the coding agent in the cloud session you want to check. Run it once in a session where the skill works and once where it doesn&rsquo;t, then compare the two reports. Replace the placeholders first.</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">Inspect your own environment, read-only, and report whether a skill is available and where it came from. Change nothing: do not install, enable, disable, add, remove, copy, or edit anything, and do not print any token, key, or password you come across.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Skill name: &lt;skill-name&gt;
</span></span><span class="line"><span class="cl">Plugin name: &lt;plugin-name&gt;
</span></span><span class="line"><span class="cl">Skills repository: &lt;owner&gt;/&lt;skills-repo&gt;
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">1. Print the Claude Code version.
</span></span><span class="line"><span class="cl">2. List the installed plugins with their version, scope, and status. Note which ones are &lt;name&gt;@synced.
</span></span><span class="line"><span class="cl">3. List the plugin marketplaces that have been added.
</span></span><span class="line"><span class="cl">4. List ~/.claude/skills/ and ~/.claude/plugins/, including plugins/synced/.
</span></span><span class="line"><span class="cl">5. List the repositories attached to this session, and say whether the skills repository is one of them.
</span></span><span class="line"><span class="cl">6. Run git ls-remote https://github.com/&lt;owner&gt;/&lt;skills-repo&gt;.git and quote any error verbatim.
</span></span><span class="line"><span class="cl">7. Report whether this repository&#39;s .claude/settings.json has enabledPlugins or extraKnownMarketplaces entries.
</span></span><span class="line"><span class="cl">8. Report whether a setup script log exists for this session and whether it contains output from a plugin install.
</span></span><span class="line"><span class="cl">9. List the skills available to this session, and say whether &lt;skill-name&gt; is among them and under what name.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Then answer in this form:
</span></span><span class="line"><span class="cl">- Skill &lt;skill-name&gt; available: yes or no, and under what name
</span></span><span class="line"><span class="cl">- Plugin &lt;plugin-name&gt; installed: yes or no, and whether it is loaded or only enabled
</span></span><span class="line"><span class="cl">- Synced plugins: the names, or none
</span></span><span class="line"><span class="cl">- Skills repository attached: yes or no
</span></span><span class="line"><span class="cl">- Skills repository reachable with git: yes, or the error
</span></span><span class="line"><span class="cl">- Plugin settings in the repository: the entries, or none
</span></span><span class="line"><span class="cl">- Setup script install output: found, not found, or no log
</span></span><span class="line"><span class="cl">- Anything you could not check, and why</span></span></code></pre></div></div>
  </div>
</div><h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/claude-code-plugin-not-loading-cloud-session/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="git-fails-with-terminal-prompts-disabled">Git fails with <code>terminal prompts disabled</code><a class="heading-anchor" href="https://looselydecoupled.com/claude-code-plugin-not-loading-cloud-session/#git-fails-with-terminal-prompts-disabled"
     aria-label="Link to section: Git fails with terminal prompts disabled" 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">fatal:</span> <span class="console__token">could</span> <span class="console__token">not</span> <span class="console__token">read</span> <span class="console__token">Username</span> <span class="console__token">for</span> <span class="console__token">&#39;https://github.com&#39;:</span> <span class="console__token">terminal</span> <span class="console__token">prompts</span> <span class="console__token">disabled</span></span></code></pre>
    </div>
  </div></div><p>In a Claude Code cloud session, git prints this for a private repository that isn&rsquo;t attached to the session, because the GitHub proxy supplies no credentials for it. A mistyped repository name probably produces the same prompt. Check the name, then attach the repository or start a session with it attached. The documentation describes the proxy&rsquo;s answer to a GitHub API request for an unattached repository as a 403 whose message starts <code>GitHub access to</code> and contains <code>is not enabled for this session</code>.</p>
<h3 id="setup-script-install-leaves-no-trace">Setup-script install leaves no trace<a class="heading-anchor" href="https://looselydecoupled.com/claude-code-plugin-not-loading-cloud-session/#setup-script-install-leaves-no-trace"
     aria-label="Link to section: Setup-script install leaves no trace" data-pagefind-ignore></a>
</h3>
<p>First check that the saved setup script still contains the install commands. Mine was never checked. If it does, a private skills repository that isn&rsquo;t attached is a likely cause. The <a href="https://code.claude.com/docs/en/cloud-environments#environment-caching" rel="external noopener" target="_blank">documentation<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/claude-code-plugin-not-loading-cloud-session/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a> also says later sessions can start from a cached snapshot and skip the setup script, so a missing log line doesn&rsquo;t prove the script failed. Check the session itself with <code>claude plugin list</code>.</p>
<h3 id="enabledplugins-in-claudesettingsjson-does-nothing-in-a-cloud-session"><code>enabledPlugins</code> in <code>.claude/settings.json</code> does nothing in a cloud session<a class="heading-anchor" href="https://looselydecoupled.com/claude-code-plugin-not-loading-cloud-session/#enabledplugins-in-claudesettingsjson-does-nothing-in-a-cloud-session"
     aria-label="Link to section: enabledPlugins in .claude/settings.json does nothing in a cloud session" data-pagefind-ignore></a>
</h3>
<p>A cloud session doesn&rsquo;t install the plugins a repository turns on under <code>enabledPlugins</code>. The <a href="https://code.claude.com/docs/en/plugins/loading#plugins-shared-through-a-repository" rel="external noopener" target="_blank">plugin loading 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/claude-code-plugin-not-loading-cloud-session/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a> says it doesn&rsquo;t add the marketplaces under <code>extraKnownMarketplaces</code> either, because that needs the workspace trust dialog, which a cloud session never shows. Those keys still work for local sessions. The open issue <a href="https://github.com/anthropics/claude-code/issues/78119" rel="external noopener" target="_blank">anthropics/claude-code#78119<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/claude-code-plugin-not-loading-cloud-session/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a> tracks this, and a comment there reports that plugins declared in organization-managed settings don&rsquo;t install in cloud sessions either. I can&rsquo;t test that on an individual account.</p>
<h3 id="plugin-is-enabled-but-the-skill-isnt-listed">Plugin is enabled but the skill isn&rsquo;t listed<a class="heading-anchor" href="https://looselydecoupled.com/claude-code-plugin-not-loading-cloud-session/#plugin-is-enabled-but-the-skill-isnt-listed"
     aria-label="Link to section: Plugin is enabled but the skill isn’t listed" data-pagefind-ignore></a>
</h3>
<p>A plugin installed after the session starts loads only when Claude Code restarts or runs <code>/reload-plugins</code>, and the cloud UI has no <code>/reload-plugins</code>. Upload the skill to your claude.ai account, or copy it into <code>~/.claude/skills/</code>.</p>
<h3 id="install-worked-in-a-test-session-and-fails-in-the-real-one">Install worked in a test session and fails in the real one<a class="heading-anchor" href="https://looselydecoupled.com/claude-code-plugin-not-loading-cloud-session/#install-worked-in-a-test-session-and-fails-in-the-real-one"
     aria-label="Link to section: Install worked in a test session and fails in the real one" data-pagefind-ignore></a>
</h3>
<p>What&rsquo;s attached to a session decides what it can reach. Test in a session set up like the one that will use the skill, with the same repositories attached and nothing more.</p>
<p><strong>Still open:</strong> a way to load a whole private plugin, with its hooks, agents and MCP servers, into a cloud session without attaching its repository. Its skills have a route. I haven&rsquo;t found one for the rest. If you&rsquo;ve found a way that works, I&rsquo;d like to hear about it. Reply to any newsletter issue.</p>
<figure><img src="https://looselydecoupled.com/claude-code-plugin-not-loading-cloud-session/cover_hu_22d45f9a47861538.png" alt="A four-panel comic. Panel one: an engineer at a laptop says “Run the skill.” to a robot. Panel two: the robot instantly holds out a neat stack of paper. It says “Done!” Panel three: the engineer, reading the pages with a worried frown, says “That’s not the skill.” while the robot beams. Panel four: the robot, now wearing a small fake moustache, hands on hips, says “No. It’s my impression of it.”" width="1276" height="530" loading="lazy" decoding="async"><figcaption>I got no error, not even a small one.</figcaption>
</figure>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>Claude Code</dt> <dd>2.1.302</dd></div>
    <div class="tested__row"><dt>Account</dt> <dd>individual claude.ai plan</dd></div>
    <div class="tested__row"><dt>Cloud session</dt> <dd>claude.ai/code</dd></div>
    <div class="tested__row"><dt>Terminal</dt> <dd>macOS, signed in with a claude.ai account</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
    <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>Finding a Bridge in 2 TB of Dashcam Video by the Color of Tunnel Lights</title>
      <link>https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/</guid>
      <pubDate>Fri, 02 Oct 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Automation</category>
      <category>Python</category>
      
      <description>A red/blue ratio found the sodium-lit tunnel on 71 of 79 bridge drives after auto-exposure hid it from every brightness check.</description>
      <content:encoded><![CDATA[<figure><img src="https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/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">
</figure>
<p>I wanted every crossing of the I-90 floating bridge in 240 drive folders of
dashcam footage, 1,958 GB, for a montage. I wasn&rsquo;t going to watch the footage
to find them, and my Rivian R1T&rsquo;s GPS was too coarse to. What found them was the
color of the lights in the Mount Baker tunnel, which sits at the Seattle end
of the bridge. Brightness couldn&rsquo;t see that
tunnel. A red/blue ratio found it on 71 of the 79 bridge drives
scanned, and a person looking at one frame per candidate confirmed 68 crossings.
<a href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/">How to sort months of dashcam footage with Claude
Code</a> has the rest of the job this was
one stage of.</p>
<h2 id="signals-instead-of-pixels">Signals instead of pixels<a class="heading-anchor" href="https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/#signals-instead-of-pixels"
     aria-label="Link to section: Signals instead of pixels" data-pagefind-ignore></a>
</h2>
<p>The footage already describes itself if you read the right parts of it. Each
15-minute segment carries <a href="https://looselydecoupled.com/rivian-dashcam-gps-location-ffprobe/">one GPS fix, at its start, in the container&rsquo;s
<code>location</code> tag</a>. That sorts drives by area, but one fix per
15 minutes can&rsquo;t place a bridge crossing to the minute, let alone the second.</p>
<p>The finer signal is in the keyframes. The truck writes one keyframe per second.
Decoding only those, downscaled to 64 × 48, gives a per-second record of a
drive and skips the 29 frames between them. Each
second becomes four numbers: the mean change in green from the previous
keyframe, which I called motion, and the mean red, green and blue. That record
was computed once, cached as CSV, and every detector afterward read the cache
instead of the video.</p>
<p>The first calibration read a motion of zero everywhere, parked or moving.
The cause was ffmpeg duplicating the keyframes back up to 30 fps, so every
frame was compared with a copy of itself. <code>-fps_mode passthrough</code> fixed it. The bug
showed up only because the check ran on a known parked segment and a known
moving one before it ran on anything else. A detector that reads zero
on both cases is measuring itself.</p>
<h2 id="why-brightness-failed">Why brightness failed<a class="heading-anchor" href="https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/#why-brightness-failed"
     aria-label="Link to section: Why brightness failed" data-pagefind-ignore></a>
</h2>
<p>A tunnel should be the easiest thing in a drive to find: the outside goes away
and the picture gets darker. In this footage it doesn&rsquo;t get darker. The camera&rsquo;s
auto-exposure raises the gain as the light drops and holds the frame&rsquo;s
brightness close to what it was in daylight. That&rsquo;s what the exposure control
is for, and it erases the one property a brightness threshold depends on.</p>
<h2 id="why-color-worked">Why color worked<a class="heading-anchor" href="https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/#why-color-worked"
     aria-label="Link to section: Why color worked" data-pagefind-ignore></a>
</h2>
<p>Exposure scales brightness, and the hue of the lamps comes through. The Mount
Baker tunnel is lit by sodium lamps, and sodium light is orange. Inside the
tunnel, the front camera&rsquo;s mean red rises above its mean blue. That ratio is
the whole detector.</p>
<p>It runs with three guards:</p>
<ul>
<li>The red/blue ratio has to stay above 1.0 for 25–150 seconds, so a single
orange frame or a long orange stretch doesn&rsquo;t count.</li>
<li>A rolling median smooths the ratio first, so one bright frame inside a tunnel
doesn&rsquo;t break the run in two.</li>
<li>The first and last 3 minutes of every drive are ignored, because parking
garages look the same to the ratio.</li>
</ul>
<figure>
  <img src="https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/red-blue-ratio-tunnel.svg" alt="Two stacked charts over a 1,600-second window of one drive. The top chart is the smoothed red/blue ratio: it sits near 0.9 and crosses the dashed 1.0 threshold in five shaded runs, and the run at 381 seconds, labeled as the Mount Baker tunnel, peaks near 1.3. The bottom chart is mean green over the same seconds: it wanders between about 50 and 75 and stays in that range through the tunnel." loading="lazy" decoding="async"><figcaption>Drive 07_16_26_120000, front camera, from the cached per-second signals. The ratio is red / (blue + 1), smoothed by the detector&rsquo;s rolling median.</figcaption>
</figure>
<p>On the 79 bridge drives scanned, it found a tunnel on 71. Seven of
the eight misses were after dark. My reading is that the ratio describes
light, and once the light outside is artificial too, a tunnel stops standing
out from the road around it.</p>
<h2 id="detector-proposes-person-confirms">Detector proposes, person confirms<a class="heading-anchor" href="https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/#detector-proposes-person-confirms"
     aria-label="Link to section: Detector proposes, person confirms" data-pagefind-ignore></a>
</h2>
<p>Finding a tunnel isn&rsquo;t the same as finding the right one. A drive on I-90
passes more than one stretch that reads as a tunnel, including the Mercer
Island lid, and most drives had several candidates. The obvious rule, pick one
tunnel by its position in the drive, was right about two times in three.</p>
<p>I could have spent the effort on a better rule. Instead the tool writes a guess
for every drive and a contact sheet to check it against, then stops. Each row
of the sheet is one drive, and each tile is a frame from the bridge side of one
candidate. The bridge is unmistakable in a frame, so the right
column is obvious, and the edit is one number in a picks file, which <a href="https://looselydecoupled.com/ffmpeg-cut-multi-camera-dashcam-clips-in-sync/">the cut</a>
turns into synced clips.
Reviewing that way confirmed 68 crossings. Two night drives couldn&rsquo;t be
confirmed, because a dark frame of a dark bridge proves nothing, and one drive
had no crossing among its candidates.</p>
<figure><img src="https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/review-sheet-row_hu_42d4d411e91d9c33.jpg" alt="One row of the review sheet: the drive from the chart above, with one tile per candidate. The first tile is a freeway lined with trees, the second is the floating bridge, and the last three are replaced by gray blocks." width="1600" height="256" loading="lazy" decoding="async"><figcaption>The second tile is the bridge, so the pick is candidate 2. Three frames removed.</figcaption>
</figure>
<p>The layout of the sheet mattered as much as having one. The first version was
a flat grid of every candidate frame, and it was easy to match a tile to the
wrong drive. One row per drive makes that mistake visible rather than merely
possible.</p>
<p>The sheet is also an instrument, and an instrument can be wrong. At one point I
announced that the picks for some drives were the Mercer Island lid, not the Mount
Baker tunnel. The sheets had been built with a seek that ffmpeg applied after
tiling, so they showed a different window than the one they claimed. Extracting
the same frame two ways exposed it, and the picks had been right all along.
When a result contradicts work that was already verified, check the instrument
before the conclusion.</p>
<p>The review step reads like an admission that the detector wasn&rsquo;t good enough.
I think it&rsquo;s the reason the detector could stay simple. Take a rule that&rsquo;s
right two times in three, and add a sheet a person reads at a glance. That
beats a rule that&rsquo;s right more often and still needs someone to check it.</p>
<h2 id="signals-that-could-not-separate-the-cases">Signals that could not separate the cases<a class="heading-anchor" href="https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/#signals-that-could-not-separate-the-cases"
     aria-label="Link to section: Signals that could not separate the cases" data-pagefind-ignore></a>
</h2>
<p>The same per-second record answered four other questions, and each answer was
no for the same reason: the signal moved for the thing I wanted and for
something I didn&rsquo;t.</p>
<ul>
<li><strong>Parked footage.</strong> Near-zero motion found parked time reliably, but there
was little to find: 23 GB of the 1,760 GB scanned, because with Gear Guard turned off,
the truck stops recording seconds after it parks.</li>
<li><strong>Hard stops.</strong> A sudden change in the picture marks braking, but turning
changes the picture as much. Of 875 candidates, all 12 at the top were turns
into parking lots or ordinary stops.</li>
<li><strong>Traffic jams.</strong> Sustained low motion should mark crawling traffic, but the
neighboring cars keep moving, so the picture keeps changing and a jam looks
like driving.</li>
<li><strong>Garage boundaries.</strong> A gray band at the top of the frame marks a concrete
ceiling on a clear day, but an overcast sky is the same gray. The boundary
was set by timing instead.</li>
</ul>
<p>None of these is fixed by tuning a threshold. When a signal can&rsquo;t tell two
cases apart, a better number for the threshold still can&rsquo;t.</p>
<p>Close passes did work once the frame&rsquo;s overall brightness change was
subtracted from the side cameras: 37 of the top 60 were real passes. A person
still had to sort them.</p>
<h2 id="signals-the-camera-leaves-alone">Signals the camera leaves alone<a class="heading-anchor" href="https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/#signals-the-camera-leaves-alone"
     aria-label="Link to section: Signals the camera leaves alone" data-pagefind-ignore></a>
</h2>
<p>A camera processes its own picture before anything reads it, and that
processing exists to make the picture look normal. Auto-exposure made a tunnel
look like daylight and left the lamp color intact. Before measuring anything in
recorded video, ask which properties the camera flattens and which it passes
through, and build on the second kind.</p>
]]></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>Rivian Dashcam Camera Missing from a Drive: It Restarted in Another Folder</title>
      <link>https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/</guid>
      <pubDate>Tue, 29 Sep 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Automation</category>
      <category>Python</category>
      
      <description>A Rivian R1T camera that stops partway through a drive often restarted into a sibling folder; motion correlation, not folder names, gives its offset.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>The camera didn&rsquo;t stop recording. It restarted seconds later into a new drive
folder that holds only the restarted cameras. Look for a folder named a few
seconds after the broken one, with fewer than five cameras in it.</p>
<p>Don&rsquo;t take the offset from the two folder names. Measure it by correlating the
motion of two cameras that face the same way, such as the Gear Guard camera
and the rear camera. Then re-encode the restarted cameras from the sibling folder at that
offset, so that they start on the exact frame.</p>

</div>

<p>I cut a five-camera clip from a drive on August 7 and dropped it into a
multicam timeline. Three angles played to the end. The rear camera and the
side-left camera went black after 33 seconds. Every file was readable and nothing
reported an error. The footage I thought was lost was in the next folder.</p>
<h2 id="rear-or-side-camera-stopped-recording-after-a-few-seconds">Rear or side camera stopped recording after a few seconds<a class="heading-anchor" href="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/#rear-or-side-camera-stopped-recording-after-a-few-seconds"
     aria-label="Link to section: Rear or side camera stopped recording after a few seconds" data-pagefind-ignore></a>
</h2>
<p>The symptom has a few forms, depending on what you build from the drive:</p>
<ul>
<li>A camera&rsquo;s only file in the drive folder is a few seconds long, while the
other cameras have a full set of 15-minute segments.</li>
<li>A multicam clip or a grid of the five cameras shows black tiles for one or two
angles for most of the drive.</li>
<li>A hyperlapse of one angle is much shorter than the others, or ends in black.</li>
</ul>
<p>On the August 7 drive, the folder <code>08_07_26_140000</code> holds full segments for the
front, Gear Guard and side-right cameras, and one 33-second file each for the
rear and side-left cameras:</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=duration</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="console__token">08_07_26_140000/08_07_26_140000_rearCenter_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">33.000000</span></span></code></pre>
    </div>
  </div></div><figure><img src="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/five-cameras-before-stitch_hu_1be5a11e4db848dc.jpg" alt="A five-camera grid on the I-90 floating bridge built from the original folder alone. The front, side-right and Gear Guard tiles show the bridge; the rear and side-left tiles are solid black." width="1600" height="853" loading="lazy" decoding="async"><figcaption>The August 7 drive on the floating bridge, from 08_07_26_140000 only. Plates blurred.</figcaption>
</figure>
<h2 id="cause-the-camera-restarted-into-a-sibling-folder">Cause: the camera restarted into a sibling folder<a class="heading-anchor" href="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/#cause-the-camera-restarted-into-a-sibling-folder"
     aria-label="Link to section: Cause: the camera restarted into a sibling folder" data-pagefind-ignore></a>
</h2>
<p>The truck writes one folder per drive, named <code>MM_DD_YY_HHMMSS</code> in local time, and
one file per camera per 15-minute segment inside it. When a camera stops and
restarts partway through a drive, its recording doesn&rsquo;t continue in the
drive&rsquo;s folder. A new folder is created a few seconds later, and it holds only
the cameras that restarted. The rest of the drive stays in the original folder.</p>
<p>The two folders together hold the whole drive. Nothing was lost, but the drive is
now split across two clocks.</p>
<h2 id="find-the-sibling-folder">Find the sibling folder<a class="heading-anchor" href="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/#find-the-sibling-folder"
     aria-label="Link to section: Find the sibling folder" data-pagefind-ignore></a>
</h2>
<p>Run this in the <code>DriveStream</code> folder of the truck&rsquo;s drive, or of your copy of it.
It prints each drive folder with the cameras it holds:</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="k">for</span> d in */<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="nb">printf</span> <span class="s1">&#39;%s &#39;</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">d</span><span class="p">%/</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  ls <span class="s2">&#34;</span><span class="nv">$d</span><span class="s2">&#34;</span> <span class="p">|</span> grep -v <span class="s1">&#39;^\._&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="p">|</span> sed -E <span class="s1">&#39;s/^[0-9_]+_([A-Za-z]+)_[0-9]{3}\.mp4$/\1/&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="p">|</span> sort -u <span class="p">|</span> tr <span class="s1">&#39;\n&#39;</span> <span class="s1">&#39; &#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span></span></span></code></pre></div></div>
  </div>
</div><p>The <code>grep -v '^\._'</code> drops the AppleDouble files that macOS leaves beside every
clip on the truck&rsquo;s exFAT drive. The August 7 folders look like this:</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">08_07_26_100000 frontCenter gearGuard rearCenter sideLeft sideRight
</span></span><span class="line"><span class="cl">08_07_26_140000 frontCenter gearGuard rearCenter sideLeft sideRight
</span></span><span class="line"><span class="cl">08_07_26_140034 rearCenter sideLeft
</span></span><span class="line"><span class="cl">08_07_26_160000 frontCenter gearGuard rearCenter sideLeft sideRight</span></span></code></pre></div></div>
  </div>
</div><p>A sibling folder has all three of these properties:</p>
<ul>
<li>Its name is seconds, not minutes, after the previous folder&rsquo;s name.</li>
<li>It holds fewer than five cameras.</li>
<li>Its cameras are the ones that stop early in the previous folder.</li>
</ul>
<p>A short folder with one camera and no partner is a different case: a stray
fragment, not half of a drive. Across 233 drive folders, one was a 21-second
fragment from a single camera.</p>
<h2 id="folder-name-offset-is-wrong">Folder name offset is wrong<a class="heading-anchor" href="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/#folder-name-offset-is-wrong"
     aria-label="Link to section: Folder name offset is wrong" data-pagefind-ignore></a>
</h2>
<p>The folder names differ by 34 seconds, so the obvious fix is to place the
restarted cameras 34 seconds into the drive. Measured from the footage, the
restarted cameras begin 32.7 seconds into the drive. The names are wrong by more
than a second. The files&rsquo; <code>creation_time</code> tags are no better: the two folders'
differ by 35 seconds, a third answer.</p>
<p>A folder name is a label, not a clock: nothing ties it to the first frame of
the video inside. An error of more than a second is visible in a multicam edit.
A car that passes the truck crosses the rear view and the side view at moments
that no longer match. The truck&rsquo;s files carry no timecode track to correct it, so
the offset has to come from the pictures.</p>
<h2 id="measure-the-offset-by-motion-correlation">Measure the offset by motion correlation<a class="heading-anchor" href="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/#measure-the-offset-by-motion-correlation"
     aria-label="Link to section: Measure the offset by motion correlation" data-pagefind-ignore></a>
</h2>
<p>Two cameras that see the same scene change at the same moments. When the truck
hits a bump or a car passes behind it, both backward-facing pictures move at
once. The script reduces each camera to a motion signal and slides one signal
along the other until they match best:</p>
<ol>
<li>Decode a window of each clip at a fixed sample rate, scaled to a tiny
grayscale frame.</li>
<li>Take the mean absolute change between consecutive frames. That&rsquo;s the motion
signal: high when the picture changes, low when it holds still.</li>
<li>Standardize the reference, and each stretch of the other signal it&rsquo;s
compared with, so that a brighter or busier camera or stretch doesn&rsquo;t
outweigh the rest.</li>
<li>Try every lag within a search range around your guess, and keep the lag with
the highest correlation.</li>
</ol>
<p>It needs Python 3 and <code>ffmpeg</code> on your path, and nothing else:</p>
<div class="code-block" data-copyable data-copy-label="measure_offset.py">
  <div class="code-block__head"><span class="code-block__lang code-block__file">measure_offset.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="s2">&#34;&#34;&#34;Measure the time offset between two dashcam clips by correlating their motion.
</span></span></span><span class="line"><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="s2">Usage:
</span></span></span><span class="line"><span class="cl"><span class="s2">    python3 measure_offset.py REFERENCE OTHER --at SECONDS [--guess SECONDS]
</span></span></span><span class="line"><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="s2">Reads a window of REFERENCE starting at --at, and a slightly wider window of
</span></span></span><span class="line"><span class="cl"><span class="s2">OTHER around --at minus --guess. Prints the offset that lines the two up:
</span></span></span><span class="line"><span class="cl"><span class="s2">a moment at time t in REFERENCE is at time t - offset in OTHER.
</span></span></span><span class="line"><span class="cl"><span class="s2">&#34;&#34;&#34;</span>
</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">subprocess</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">WIDTH</span><span class="p">,</span> <span class="n">HEIGHT</span> <span class="o">=</span> <span class="mi">32</span><span class="p">,</span> <span class="mi">24</span>
</span></span><span class="line"><span class="cl"><span class="n">SAMPLES_PER_SECOND</span> <span class="o">=</span> <span class="mi">10</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">motion</span><span class="p">(</span><span class="n">clip</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">start</span><span class="p">:</span> <span class="nb">float</span><span class="p">,</span> <span class="n">seconds</span><span class="p">:</span> <span class="nb">float</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="nb">float</span><span class="p">]:</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Mean absolute change between consecutive tiny grayscale frames.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">raw</span> <span class="o">=</span> <span class="n">subprocess</span><span class="o">.</span><span class="n">run</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="p">[</span><span class="s2">&#34;ffmpeg&#34;</span><span class="p">,</span> <span class="s2">&#34;-nostdin&#34;</span><span class="p">,</span> <span class="s2">&#34;-v&#34;</span><span class="p">,</span> <span class="s2">&#34;error&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">         <span class="s2">&#34;-ss&#34;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">start</span><span class="si">:</span><span class="s2">.3f</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="s2">&#34;-t&#34;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">seconds</span><span class="si">:</span><span class="s2">.3f</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="s2">&#34;-i&#34;</span><span class="p">,</span> <span class="n">clip</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">         <span class="s2">&#34;-vf&#34;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;fps=</span><span class="si">{</span><span class="n">SAMPLES_PER_SECOND</span><span class="si">}</span><span class="s2">,scale=</span><span class="si">{</span><span class="n">WIDTH</span><span class="si">}</span><span class="s2">:</span><span class="si">{</span><span class="n">HEIGHT</span><span class="si">}</span><span class="s2">,format=gray&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">         <span class="s2">&#34;-f&#34;</span><span class="p">,</span> <span class="s2">&#34;rawvideo&#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="n">capture_output</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">check</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span><span class="o">.</span><span class="n">stdout</span>
</span></span><span class="line"><span class="cl">    <span class="n">size</span> <span class="o">=</span> <span class="n">WIDTH</span> <span class="o">*</span> <span class="n">HEIGHT</span>
</span></span><span class="line"><span class="cl">    <span class="n">frames</span> <span class="o">=</span> <span class="p">[</span><span class="n">raw</span><span class="p">[</span><span class="n">i</span><span class="p">:</span><span class="n">i</span> <span class="o">+</span> <span class="n">size</span><span class="p">]</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">raw</span><span class="p">)</span> <span class="o">-</span> <span class="n">size</span> <span class="o">+</span> <span class="mi">1</span><span class="p">,</span> <span class="n">size</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="p">[</span><span class="nb">sum</span><span class="p">(</span><span class="nb">abs</span><span class="p">(</span><span class="n">a</span> <span class="o">-</span> <span class="n">b</span><span class="p">)</span> <span class="k">for</span> <span class="n">a</span><span class="p">,</span> <span class="n">b</span> <span class="ow">in</span> <span class="nb">zip</span><span class="p">(</span><span class="n">before</span><span class="p">,</span> <span class="n">after</span><span class="p">))</span> <span class="o">/</span> <span class="n">size</span>
</span></span><span class="line"><span class="cl">            <span class="k">for</span> <span class="n">before</span><span class="p">,</span> <span class="n">after</span> <span class="ow">in</span> <span class="nb">zip</span><span class="p">(</span><span class="n">frames</span><span class="p">,</span> <span class="n">frames</span><span class="p">[</span><span class="mi">1</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">standardize</span><span class="p">(</span><span class="n">values</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">float</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="nb">float</span><span class="p">]:</span>
</span></span><span class="line"><span class="cl">    <span class="n">mean</span> <span class="o">=</span> <span class="nb">sum</span><span class="p">(</span><span class="n">values</span><span class="p">)</span> <span class="o">/</span> <span class="nb">len</span><span class="p">(</span><span class="n">values</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">variance</span> <span class="o">=</span> <span class="nb">sum</span><span class="p">((</span><span class="n">v</span> <span class="o">-</span> <span class="n">mean</span><span class="p">)</span> <span class="o">**</span> <span class="mi">2</span> <span class="k">for</span> <span class="n">v</span> <span class="ow">in</span> <span class="n">values</span><span class="p">)</span> <span class="o">/</span> <span class="nb">len</span><span class="p">(</span><span class="n">values</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">spread</span> <span class="o">=</span> <span class="n">variance</span> <span class="o">**</span> <span class="mf">0.5</span> <span class="ow">or</span> <span class="mf">1.0</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="p">[(</span><span class="n">v</span> <span class="o">-</span> <span class="n">mean</span><span class="p">)</span> <span class="o">/</span> <span class="n">spread</span> <span class="k">for</span> <span class="n">v</span> <span class="ow">in</span> <span class="n">values</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">best_lag</span><span class="p">(</span><span class="n">reference</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">float</span><span class="p">],</span> <span class="n">other</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">float</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">float</span><span class="p">,</span> <span class="nb">int</span><span class="p">]:</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Slide reference along other; return the best correlation and its lag.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">scores</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">lag</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">other</span><span class="p">)</span> <span class="o">-</span> <span class="nb">len</span><span class="p">(</span><span class="n">reference</span><span class="p">)</span> <span class="o">+</span> <span class="mi">1</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">window</span> <span class="o">=</span> <span class="n">standardize</span><span class="p">(</span><span class="n">other</span><span class="p">[</span><span class="n">lag</span><span class="p">:</span><span class="n">lag</span> <span class="o">+</span> <span class="nb">len</span><span class="p">(</span><span class="n">reference</span><span class="p">)])</span>
</span></span><span class="line"><span class="cl">        <span class="n">score</span> <span class="o">=</span> <span class="nb">sum</span><span class="p">(</span><span class="n">a</span> <span class="o">*</span> <span class="n">b</span> <span class="k">for</span> <span class="n">a</span><span class="p">,</span> <span class="n">b</span> <span class="ow">in</span> <span class="nb">zip</span><span class="p">(</span><span class="n">reference</span><span class="p">,</span> <span class="n">window</span><span class="p">))</span> <span class="o">/</span> <span class="nb">len</span><span class="p">(</span><span class="n">reference</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">scores</span><span class="o">.</span><span class="n">append</span><span class="p">((</span><span class="n">score</span><span class="p">,</span> <span class="n">lag</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">max</span><span class="p">(</span><span class="n">scores</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">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">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></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;reference&#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;other&#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;--at&#34;</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">float</span><span class="p">,</span> <span class="n">required</span><span class="o">=</span><span class="kc">True</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;window start in REFERENCE, seconds&#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;--guess&#34;</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">float</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="mf">0.0</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;expected offset, seconds&#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;--length&#34;</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">float</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="mf">120.0</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;window length, seconds&#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;--search&#34;</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">float</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="mf">5.0</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;how far either side of the guess to look&#34;</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">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 class="n">reference</span> <span class="o">=</span> <span class="n">standardize</span><span class="p">(</span><span class="n">motion</span><span class="p">(</span><span class="n">args</span><span class="o">.</span><span class="n">reference</span><span class="p">,</span> <span class="n">args</span><span class="o">.</span><span class="n">at</span><span class="p">,</span> <span class="n">args</span><span class="o">.</span><span class="n">length</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># The other window starts --search seconds early, so lag 0 means guess + search.</span>
</span></span><span class="line"><span class="cl">    <span class="n">other_start</span> <span class="o">=</span> <span class="n">args</span><span class="o">.</span><span class="n">at</span> <span class="o">-</span> <span class="n">args</span><span class="o">.</span><span class="n">guess</span> <span class="o">-</span> <span class="n">args</span><span class="o">.</span><span class="n">search</span>
</span></span><span class="line"><span class="cl">    <span class="n">other_length</span> <span class="o">=</span> <span class="n">args</span><span class="o">.</span><span class="n">length</span> <span class="o">+</span> <span class="mi">2</span> <span class="o">*</span> <span class="n">args</span><span class="o">.</span><span class="n">search</span>
</span></span><span class="line"><span class="cl">    <span class="n">other</span> <span class="o">=</span> <span class="n">motion</span><span class="p">(</span><span class="n">args</span><span class="o">.</span><span class="n">other</span><span class="p">,</span> <span class="n">other_start</span><span class="p">,</span> <span class="n">other_length</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">score</span><span class="p">,</span> <span class="n">lag</span> <span class="o">=</span> <span class="n">best_lag</span><span class="p">(</span><span class="n">reference</span><span class="p">,</span> <span class="n">other</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">offset</span> <span class="o">=</span> <span class="n">args</span><span class="o">.</span><span class="n">guess</span> <span class="o">+</span> <span class="n">args</span><span class="o">.</span><span class="n">search</span> <span class="o">-</span> <span class="n">lag</span> <span class="o">/</span> <span class="n">SAMPLES_PER_SECOND</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;window at </span><span class="si">{</span><span class="n">args</span><span class="o">.</span><span class="n">at</span><span class="si">:</span><span class="s2">g</span><span class="si">}</span><span class="s2"> s: correlation </span><span class="si">{</span><span class="n">score</span><span class="si">:</span><span class="s2">.2f</span><span class="si">}</span><span class="s2">, offset </span><span class="si">{</span><span class="n">offset</span><span class="si">:</span><span class="s2">+.1f</span><span class="si">}</span><span class="s2"> s&#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>Give it a camera from the original folder as the reference, a camera from the
sibling folder as the other, and the folder-name difference as the guess. Both
folders number their segments from <code>001</code> and every segment but the last runs
900 seconds, so segment <code>002</code> of the sibling starts the same offset after
segment <code>002</code> of the original. Compare files with the same segment number, and
keep the window inside one segment.</p>
<p>In the <code>DriveStream</code> folder, 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">python3</span> <span class="console__token">measure_offset.py</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">08_07_26_140000/08_07_26_140000_gearGuard_001.mp4</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">08_07_26_140034/08_07_26_140034_rearCenter_001.mp4</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">--at</span> <span class="console__token">300</span> <span class="console__token">--guess</span> <span class="console__token">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">window</span> <span class="console__token">at</span> <span class="console__token">300</span> <span class="console__token">s:</span> <span class="console__token">correlation</span> <span class="console__token">0.84,</span> <span class="console__token">offset</span> <span class="console__token">+32.6</span> <span class="console__token">s</span></span></code></pre>
    </div>
  </div></div><p>One window is one measurement. Repeat it in windows that don&rsquo;t overlap, from
different segments, and trust the offset only when they agree. Four windows on
the August 7 drive gave this:</p>
<div class="table-wrap" data-scrollable data-label="Table: Segment">
  <table>
    <thead>
      <tr><th scope="col">Segment</th><th scope="col"><code>--at</code></th><th scope="col">Correlation</th><th scope="col">Offset</th></tr>
    </thead>
    <tbody>
      <tr><td><code>001</code></td><td>300</td><td>0.84</td><td>+32.6 s</td></tr>
      <tr><td><code>001</code></td><td>700</td><td>0.73</td><td>+32.7 s</td></tr>
      <tr><td><code>002</code></td><td>600</td><td>0.85</td><td>+32.8 s</td></tr>
      <tr><td><code>003</code></td><td>200</td><td>0.69</td><td>+32.7 s</td></tr>
    </tbody>
  </table>
</div>
<p>That&rsquo;s 32.7 seconds, plus or minus 0.1, from four independent windows. A moment
at time <em>t</em> in the original folder is at <em>t</em> − 32.7 in the sibling folder.</p>
<figure>
  <img src="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/motion-correlation-offset.svg" alt="Two plots of motion against time, from 300 to 330 seconds into the Gear Guard file, with the Gear Guard signal in blue and the restarted rear camera in amber. Shifted by the folder-name offset of 34 seconds, each amber peak lands about a second after the blue one. Shifted by the measured 32.7 seconds, the peaks fall together." loading="lazy" decoding="async"><figcaption>Motion from measure_offset.py&rsquo;s motion() function, standardized, for the first window in the table.</figcaption>
</figure>
<h3 id="compare-cameras-that-face-the-same-way">Compare cameras that face the same way<a class="heading-anchor" href="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/#compare-cameras-that-face-the-same-way"
     aria-label="Link to section: Compare cameras that face the same way" data-pagefind-ignore></a>
</h3>
<p>The front camera was my first choice for the reference, because it recorded the
whole drive. Against the restarted rear camera it gave two answers:</p>
<div class="table-wrap" data-scrollable data-label="Table: Segment">
  <table>
    <thead>
      <tr><th scope="col">Segment</th><th scope="col"><code>--at</code></th><th scope="col">Correlation</th><th scope="col">Offset</th></tr>
    </thead>
    <tbody>
      <tr><td><code>001</code></td><td>300</td><td>0.74</td><td>+31.9 s</td></tr>
      <tr><td><code>002</code></td><td>600</td><td>0.52</td><td>+32.8 s</td></tr>
    </tbody>
  </table>
</div>
<p>The two windows disagree by 0.9 seconds, and each one looks plausible on its
own. The front camera sees the road ahead and the rear camera sees the road
behind. Their pictures change for different reasons most of the time, so the
best match moves from one window to the next. The Gear Guard camera looks backward over
the bed, the same direction as the rear camera, and the two agree.</p>
<p>Pick a reference camera that kept recording in the original folder and faces
the same direction as one of the restarted cameras. The files in a sibling
folder share one start time and the same segment lengths, so the offset you
measure for one of its cameras applies to all of them.</p>
<h2 id="stitch-the-restarted-cameras">Stitch the restarted cameras<a class="heading-anchor" href="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/#stitch-the-restarted-cameras"
     aria-label="Link to section: Stitch the restarted cameras" data-pagefind-ignore></a>
</h2>
<p>A stream copy can only start on a keyframe, and the truck writes one keyframe per
second. The stream-copied angles from the original folder start on their own
keyframes. An offset of 32.7 seconds isn&rsquo;t a whole number of seconds, so no
keyframe in the sibling folder falls on the same instant. Re-encode only the restarted
cameras, cut at the exact frame, and leave the rest as stream copies.</p>
<p>Suppose your clip covers <code>START</code> to <code>END</code> seconds of the original folder, and
<code>OFFSET</code> is the measured offset. The window has to begin after the restart, so
<code>START</code> is greater than <code>OFFSET</code>. In the sibling folder, the clip begins at
<code>START</code> minus <code>OFFSET</code> and lasts <code>END</code> minus <code>START</code> seconds.</p>
<p>In the <code>DriveStream</code> folder, the following commands write a concat list of the
restarted camera&rsquo;s segments in the sibling folder, and then cut the clip from
the joined segments, re-encoding to HEVC. They create two files and change
nothing in the drive folders:</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="nb">printf</span> <span class="s2">&#34;file &#39;%s&#39;\n&#34;</span> <span class="s2">&#34;</span><span class="nv">$PWD</span><span class="s2">&#34;</span>/08_07_26_140034/08_07_26_140034_rearCenter_*.mp4 <span class="se">\
</span></span></span><span class="line"><span class="cl">  &gt; rearCenter.txt
</span></span><span class="line"><span class="cl">ffmpeg -f concat -safe <span class="m">0</span> -i rearCenter.txt -ss &lt;sibling-start&gt; -t &lt;duration&gt; <span class="se">\
</span></span></span><span class="line"><span class="cl">  -c:v hevc_videotoolbox -q:v <span class="m">80</span> -tag:v hvc1 -color_range pc -an <span class="se">\
</span></span></span><span class="line"><span class="cl">  &lt;clip-folder&gt;/rearCenter.mp4</span></span></code></pre></div></div>
  </div>
</div><p>Run the same two commands for every other camera in the sibling folder.</p>
<p><code>-ss</code> after <code>-i</code> decodes from the start of the list and discards frames up to
the exact time, so the output starts on that instant rather than on the nearest
keyframe. It&rsquo;s slow for a window late in the drive, because ffmpeg decodes
everything before the window first. Joining the segments in one concat pass,
rather than cutting each file and joining the cuts, keeps the frames whose
group of pictures runs across a segment boundary. <a href="https://looselydecoupled.com/ffmpeg-cut-multi-camera-dashcam-clips-in-sync/">Cut multi-camera dashcam
clips with ffmpeg</a> covers the
stream-copied cameras and that join in full.</p>
<p>The encoder <code>hevc_videotoolbox</code> is the hardware HEVC encoder on macOS. On
another system, use the encoder you&rsquo;d use for the rest of the project. The
option <code>-color_range pc</code> labels the re-encoded angle full range like the
truck&rsquo;s footage, so it matches the stream-copied ones; check it with
<code>ffprobe -show_entries stream=color_range</code>. The truck
records no audio, so <code>-an</code> drops nothing.</p>
<p>Every file now starts at time 0. The re-encoded angles start on the exact
frame, and a stream-copied one starts early by the lead <a href="https://looselydecoupled.com/ffmpeg-cut-multi-camera-dashcam-clips-in-sync/#build-the-multicam-clip">the cut
script</a>
printed for it. In DaVinci Resolve, mark each lead as an In point, then create
the multicam clip with angle sync set to the In point.</p>
<figure><img src="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/five-cameras-after-stitch_hu_dedc50ea5b067455.jpg" alt="The same moment on the floating bridge after the stitch. All five tiles are filled: the rear tile shows the car following behind, which the Gear Guard tile also shows over the truck bed, and the side-left tile shows the bridge deck and railing." width="1600" height="853" loading="lazy" decoding="async"><figcaption>Rear and side-left from 08_07_26_140034, cut at the measured 32.7 s offset. Plates and the following driver blurred.</figcaption>
</figure>
<h2 id="confirm-the-stitch">Confirm the stitch<a class="heading-anchor" href="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/#confirm-the-stitch"
     aria-label="Link to section: Confirm the stitch" data-pagefind-ignore></a>
</h2>
<p>Run the same script on the finished clip, with the guess left at zero. In the
clip&rsquo;s folder, compare the Gear Guard camera, which was stream-copied from the original
folder, with the re-encoded rear 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">python3</span> <span class="console__token">measure_offset.py</span> <span class="console__token">08_07_26_140000_gearGuard.mp4</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">08_07_26_140000_rearCenter.mp4</span> <span class="console__token">--at</span> <span class="console__token">300</span> <span class="console__token">--length</span> <span class="console__token">90</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">window</span> <span class="console__token">at</span> <span class="console__token">300</span> <span class="console__token">s:</span> <span class="console__token">correlation</span> <span class="console__token">0.79,</span> <span class="console__token">offset</span> <span class="console__token">+0.1</span> <span class="console__token">s</span></span></code></pre>
    </div>
  </div></div><p>An offset of +0.1 seconds is one sample step of <code>SAMPLES_PER_SECOND</code>, the
resolution of the method. The angles are in sync. The same script reported
32.7 seconds on the unstitched folders, so it does notice an offset when one
exists. A zero from a check that has never reported anything else proves
nothing.</p>
]]></content:encoded>
    </item>
    
    <item>
      <title>Read the GPS Location from Rivian Dashcam Video with ffprobe</title>
      <link>https://looselydecoupled.com/rivian-dashcam-gps-location-ffprobe/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/rivian-dashcam-gps-location-ffprobe/</guid>
      <pubDate>Mon, 28 Sep 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Automation</category>
      <category>Python</category>
      
      <description>A Rivian R1T dashcam clip carries one GPS fix, taken where its segment began, in the MP4 location tag, and ffprobe reads it in one command.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>The GPS position is in the MP4 container&rsquo;s <code>location</code> tag. Read it with
ffprobe:</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">\</span>
</span><span class="line">    <span class="console__token">&lt;clip&gt;.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">47.589462-122.2655</span></span></code></pre>
    </div>
  </div></div><p>The value is a signed latitude immediately followed by a signed longitude, in
decimal degrees: <code>47.589462</code> north, <code>-122.2655</code> west. That clip starts on the
I-90 floating bridge. <code>0,0</code> means the truck had no fix.</p>
<p>There is one fix per file, taken where the 15-minute segment began. There is
no per-second GPS track.</p>

</div>

<figure><img src="https://looselydecoupled.com/rivian-dashcam-gps-location-ffprobe/floating-bridge-segment-start_hu_2b5d759a1bdfb1b3.jpg" alt="The front camera half a second into a segment that begins on the I-90 floating bridge under an overcast sky, with two cars ahead and a distant skyline low on the horizon." width="1600" height="1280" loading="lazy" decoding="async"><figcaption>Half a second into the clip. Its tag reads 47.589462-122.2655: 47.589462, -122.2655. Plates blurred.</figcaption>
</figure>
<h2 id="spellings-of-the-tag">Spellings of the tag<a class="heading-anchor" href="https://looselydecoupled.com/rivian-dashcam-gps-location-ffprobe/#spellings-of-the-tag"
     aria-label="Link to section: Spellings of the tag" data-pagefind-ignore></a>
</h2>
<p>The value has no separator between the two numbers. The sign of the longitude
is the separator, so a parser has to split on the second <code>+</code> or <code>-</code>, not on a
comma or a space. The truck never writes a leading plus sign. Across 475
front-camera segments, all from encoder tag Rivian Firmware ver. 2022-47, three
spellings turned up, and <code>0,0</code> stands in when there is no fix:</p>
<dl class="facts">
  <dt>Usual</dt>
  <dd><code>47.589462-122.2655</code>, on 439 of the 475 segments.</dd>
  <dt>Trailing slash</dt>
  <dd>the same form ending in <code>/</code>, on the other 36.</dd>
  <dt>After ffmpeg copies the file</dt>
  <dd><code>+47.5894-122.2655/</code>, the ISO 6709 form, with a leading plus sign and four decimal places. This is ffmpeg&rsquo;s rewrite, not a firmware variant.</dd>
  <dt>No fix</dt>
  <dd><code>0,0</code>, which is a position in the Gulf of Guinea, not a reading.</dd>
</dl>

<p>ffprobe also prints a <code>location-eng</code> tag with the same value. Either one
works; the commands here read <code>location</code>.</p>
<h2 id="what-one-fix-per-segment-rules-out">What one fix per segment rules out<a class="heading-anchor" href="https://looselydecoupled.com/rivian-dashcam-gps-location-ffprobe/#what-one-fix-per-segment-rules-out"
     aria-label="Link to section: What one fix per segment rules out" data-pagefind-ignore></a>
</h2>
<p>The truck records one file per camera per 15-minute segment, and each file
carries a single fix from the moment that segment started. Nothing inside the
file records where the truck was a minute later. That rules out:</p>
<ul>
<li>A route drawn from the video. A segment gives you a point every 15 minutes,
which is too coarse to follow a road.</li>
<li>Speed. Two fixes 15 minutes apart give an average over the whole segment, not
the speed at any moment.</li>
<li>Finding a place by GPS alone. A drive across the floating bridge takes less
than a segment, so the bridge usually falls between two fixes.</li>
</ul>
<p>The five cameras of a segment each carry their own fix. In the clip earlier,
the front and rear files read <code>47.589462-122.2655</code> and the two side cameras and
the Gear Guard camera read <code>47.589474-122.2659</code>. Treat any one of them as the
segment&rsquo;s position.</p>
<p>To place a moment inside a segment, read the picture instead of the
container. <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> locates the I-90
floating bridge crossing from the picture, where the GPS can&rsquo;t.</p>
<h2 id="parse-the-tag-in-python">Parse the tag in Python<a class="heading-anchor" href="https://looselydecoupled.com/rivian-dashcam-gps-location-ffprobe/#parse-the-tag-in-python"
     aria-label="Link to section: Parse the tag in Python" data-pagefind-ignore></a>
</h2>
<p><code>parse_location</code> returns a <code>(latitude, longitude)</code> tuple, or <code>None</code> when the
tag is missing, unreadable, or <code>0,0</code>. <code>read_location</code> runs ffprobe on one
file. The standard library is enough; Python 3.10 or later, for the <code>|</code> in the
type hints.</p>
<div class="code-block" data-copyable data-copy-label="rivian_location.py">
  <div class="code-block__head"><span class="code-block__lang code-block__file">rivian_location.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="kn">import</span> <span class="nn">re</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">subprocess</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></span><span class="line"><span class="cl"><span class="c1"># A signed latitude immediately followed by a signed longitude. The leading</span>
</span></span><span class="line"><span class="cl"><span class="c1"># plus sign and the trailing slash are optional because not every writer</span>
</span></span><span class="line"><span class="cl"><span class="c1"># emits them.</span>
</span></span><span class="line"><span class="cl"><span class="n">LOCATION</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;([+-]?\d+(?:\.\d+)?)([+-]\d+(?:\.\d+)?)&#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_location</span><span class="p">(</span><span class="n">tag</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">float</span><span class="p">,</span> <span class="nb">float</span><span class="p">]</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">match</span> <span class="o">=</span> <span class="n">LOCATION</span><span class="o">.</span><span class="n">search</span><span class="p">(</span><span class="n">tag</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="k">match</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="cl">    <span class="n">latitude</span><span class="p">,</span> <span class="n">longitude</span> <span class="o">=</span> <span class="nb">float</span><span class="p">(</span><span class="k">match</span><span class="p">[</span><span class="mi">1</span><span class="p">]),</span> <span class="nb">float</span><span class="p">(</span><span class="k">match</span><span class="p">[</span><span class="mi">2</span><span class="p">])</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># The truck writes 0,0 when it has no GPS fix.</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">latitude</span> <span class="o">==</span> <span class="mi">0</span> <span class="ow">and</span> <span class="n">longitude</span> <span class="o">==</span> <span class="mi">0</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">latitude</span><span class="p">,</span> <span class="n">longitude</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">read_location</span><span class="p">(</span><span class="n">clip</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">float</span><span class="p">,</span> <span class="nb">float</span><span class="p">]</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">tag</span> <span class="o">=</span> <span class="n">subprocess</span><span class="o">.</span><span class="n">run</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="p">[</span><span class="s2">&#34;ffprobe&#34;</span><span class="p">,</span> <span class="s2">&#34;-v&#34;</span><span class="p">,</span> <span class="s2">&#34;error&#34;</span><span class="p">,</span> <span class="s2">&#34;-show_entries&#34;</span><span class="p">,</span> <span class="s2">&#34;format_tags=location&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">         <span class="s2">&#34;-of&#34;</span><span class="p">,</span> <span class="s2">&#34;default=nw=1:nk=1&#34;</span><span class="p">,</span> <span class="n">clip</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">        <span class="n">capture_output</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">text</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">check</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span><span class="o">.</span><span class="n">stdout</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">parse_location</span><span class="p">(</span><span class="n">tag</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="k">for</span> <span class="n">clip</span> <span class="ow">in</span> <span class="n">sys</span><span class="o">.</span><span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">:]:</span>
</span></span><span class="line"><span class="cl">        <span class="n">point</span> <span class="o">=</span> <span class="n">read_location</span><span class="p">(</span><span class="n">clip</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="n">clip</span><span class="p">,</span> <span class="s2">&#34;no fix&#34;</span> <span class="k">if</span> <span class="n">point</span> <span class="ow">is</span> <span class="kc">None</span> <span class="k">else</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">point</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="si">}</span><span class="s2">, </span><span class="si">{</span><span class="n">point</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
  </div>
</div><p>Pass it one or more clips:</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">python3</span> <span class="console__token">rivian_location.py</span> <span class="console__token">&lt;clip&gt;.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">&lt;clip&gt;.mp4</span> <span class="console__token">47.589462,</span> <span class="console__token">-122.2655</span></span></code></pre>
    </div>
  </div></div><p>The tests cover every spelling in the list earlier, a southern and eastern
position so both signs are exercised, and each way the tag can mean &ldquo;no fix&rdquo;:</p>
<div class="code-block" data-copyable data-copy-label="test_rivian_location.py">
  <div class="code-block__head"><span class="code-block__lang code-block__file">test_rivian_location.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="kn">import</span> <span class="nn">pytest</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">rivian_location</span> <span class="kn">import</span> <span class="n">parse_location</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="nd">@pytest.mark.parametrize</span><span class="p">((</span><span class="s2">&#34;tag&#34;</span><span class="p">,</span> <span class="s2">&#34;expected&#34;</span><span class="p">),</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="s2">&#34;47.589462-122.2655&#34;</span><span class="p">,</span> <span class="p">(</span><span class="mf">47.589462</span><span class="p">,</span> <span class="o">-</span><span class="mf">122.2655</span><span class="p">)),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="s2">&#34;47.589462-122.2655/&#34;</span><span class="p">,</span> <span class="p">(</span><span class="mf">47.589462</span><span class="p">,</span> <span class="o">-</span><span class="mf">122.2655</span><span class="p">)),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="s2">&#34;+47.5894-122.2655/&#34;</span><span class="p">,</span> <span class="p">(</span><span class="mf">47.5894</span><span class="p">,</span> <span class="o">-</span><span class="mf">122.2655</span><span class="p">)),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="s2">&#34;-33.8688+151.2093/&#34;</span><span class="p">,</span> <span class="p">(</span><span class="o">-</span><span class="mf">33.8688</span><span class="p">,</span> <span class="mf">151.2093</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">def</span> <span class="nf">test_reads_each_spelling_of_the_tag</span><span class="p">(</span><span class="n">tag</span><span class="p">,</span> <span class="n">expected</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">assert</span> <span class="n">parse_location</span><span class="p">(</span><span class="n">tag</span><span class="p">)</span> <span class="o">==</span> <span class="n">expected</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="nd">@pytest.mark.parametrize</span><span class="p">(</span><span class="s2">&#34;tag&#34;</span><span class="p">,</span> <span class="p">[</span><span class="s2">&#34;&#34;</span><span class="p">,</span> <span class="s2">&#34;</span><span class="se">\n</span><span class="s2">&#34;</span><span class="p">,</span> <span class="s2">&#34;garbage&#34;</span><span class="p">,</span> <span class="s2">&#34;+0.0000+0.0000/&#34;</span><span class="p">,</span> <span class="s2">&#34;0-0&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">test_returns_none_for_no_fix</span><span class="p">(</span><span class="n">tag</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">assert</span> <span class="n">parse_location</span><span class="p">(</span><span class="n">tag</span><span class="p">)</span> <span class="ow">is</span> <span class="kc">None</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">python3</span> <span class="console__token">-m</span> <span class="console__token">pytest</span> <span class="console__token">-q</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">.........</span>                                                                <span class="console__token">[100%]</span>
</span><span class="line"><span class="console__token">9</span> <span class="console__token">passed</span> <span class="console__token">in</span> <span class="console__token">0.01s</span></span></code></pre>
    </div>
  </div></div><p>Dropping the optional sign from the latitude&rsquo;s half of the pattern fails the
southern-hemisphere case, so the tests do notice a broken pattern.</p>
<h2 id="remove-the-gps-location-from-a-clip-before-sharing-it">Remove the GPS location from a clip before sharing it<a class="heading-anchor" href="https://looselydecoupled.com/rivian-dashcam-gps-location-ffprobe/#remove-the-gps-location-from-a-clip-before-sharing-it"
     aria-label="Link to section: Remove the GPS location from a clip before sharing it" data-pagefind-ignore></a>
</h2>
<p>The tag travels with the file. A raw clip posted online tells anyone who
downloads it where it was recorded, and a folder of them maps where the truck
goes every day. Trimming the clip doesn&rsquo;t necessarily remove it: a stream copy
with ffmpeg kept the tag, rewritten in the <code>+…/</code> form, and exiftool reports it
as a GPS position:</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">-i</span> <span class="console__token">&lt;clip&gt;.mp4</span> <span class="console__token">-t</span> <span class="console__token">2</span> <span class="console__token">-c</span> <span class="console__token">copy</span> <span class="console__token">copy.mp4</span>
</span><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">copy.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">[Composite]</span>     <span class="console__token">GPS</span> <span class="console__token">Altitude</span>                    <span class="console__token">:</span> <span class="console__token">0</span> <span class="console__token">m</span>
</span><span class="line"><span class="console__token">[Composite]</span>     <span class="console__token">GPS</span> <span class="console__token">Altitude</span> <span class="console__token">Ref</span>                <span class="console__token">:</span> <span class="console__token">Above</span> <span class="console__token">Sea</span> <span class="console__token">Level</span>
</span><span class="line"><span class="console__token">[Composite]</span>     <span class="console__token">GPS</span> <span class="console__token">Latitude</span>                    <span class="console__token">:</span> <span class="console__token">47</span> <span class="console__token">deg</span> <span class="console__token">35&#39;</span> <span class="console__token">22.02&#34;</span> <span class="console__token">N</span>
</span><span class="line"><span class="console__token">[Composite]</span>     <span class="console__token">GPS</span> <span class="console__token">Longitude</span>                   <span class="console__token">:</span> <span class="console__token">122</span> <span class="console__token">deg</span> <span class="console__token">15&#39;</span> <span class="console__token">55.76&#34;</span> <span class="console__token">W</span>
</span><span class="line"><span class="console__token">[Composite]</span>     <span class="console__token">GPS</span> <span class="console__token">Position</span>                    <span class="console__token">:</span> <span class="console__token">47</span> <span class="console__token">deg</span> <span class="console__token">35&#39;</span> <span class="console__token">22.02&#34;</span> <span class="console__token">N,</span> <span class="console__token">122</span> <span class="console__token">deg</span> <span class="console__token">15&#39;</span> <span class="console__token">55.76&#34;</span> <span class="console__token">W</span>
</span><span class="line"><span class="console__token">[UserData]</span>      <span class="console__token">Location</span> <span class="console__token">Information</span>            <span class="console__token">:</span> <span class="console__token">(none)</span> <span class="console__token">Role=shooting</span> <span class="console__token">Lat=47.58945</span> <span class="console__token">Lon=-122.26549</span> <span class="console__token">Alt=0.00</span> <span class="console__token">Body=earth</span> <span class="console__token">Notes=</span></span></code></pre>
    </div>
  </div></div><p>Other ways of writing a file behave differently. The <a href="https://looselydecoupled.com/ffmpeg-cut-multi-camera-dashcam-clips-in-sync/">multi-camera clips cut
with ffmpeg&rsquo;s concat demuxer</a>
came out with no <code>location</code> tag at all, because the demuxer drops file-level
metadata. The hyperlapse tool I used kept it, because it copies the
first source file&rsquo;s metadata on purpose. Check
each file rather than trusting the tool. To drop the container&rsquo;s metadata while copying,
add <code>-map_metadata -1</code>; the same exiftool command then prints nothing:</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">-i</span> <span class="console__token">&lt;clip&gt;.mp4</span> <span class="console__token">-t</span> <span class="console__token">2</span> <span class="console__token">-c</span> <span class="console__token">copy</span> <span class="console__token">-map_metadata</span> <span class="console__token">-1</span> <span class="console__token">strip.mp4</span>
</span><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">strip.mp4</span></span></code></pre>
    </div>
  </div></div><p>Empty output is the result you want. Run the check on the file you&rsquo;re about
to upload, not on its source: each tool between the two can keep the tag or
drop it.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>ffprobe</dt> <dd>9.0.1</dd></div>
    <div class="tested__row"><dt>exiftool</dt> <dd>13.55</dd></div>
    <div class="tested__row"><dt>Python</dt> <dd>3.12.11</dd></div>
    <div class="tested__row"><dt>pytest</dt> <dd>9.1.1</dd></div>
    <div class="tested__row"><dt>OS</dt> <dd>macOS 27.0</dd></div>
    <div class="tested__row"><dt>Footage</dt> <dd>Rivian, encoder tag Rivian Firmware ver. 2022-47</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>Why Moving $null to the Left Side Broke My PowerShell Script</title>
      <link>https://looselydecoupled.com/powershell-null-left-side-comparison-array/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/powershell-null-left-side-comparison-array/</guid>
      <pubDate>Sat, 26 Sep 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>PowerShell</category>
      <category>Code quality</category>
      
      <description>The analyzer&#39;s fix is safe only where code meant a null check; this function relied on the array filter in ways only empty and nearly empty inputs reveal.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>PSScriptAnalyzer&rsquo;s <code>PSPossibleIncorrectComparisonWithNull</code> rule is right for
most code. When the left side of <code>-ne</code> is an array, PowerShell filters the array instead
of returning true or false. These two tests ask different questions:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="c"># Does $items hold at least one item that is not null?</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nv">$items</span> <span class="o">-ne</span> <span class="vm">$null</span><span class="p">)</span> <span class="p">{</span> <span class="s1">&#39;has items&#39;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c"># Is the variable itself something other than null?</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="vm">$null</span> <span class="o">-ne</span> <span class="nv">$items</span><span class="p">)</span> <span class="p">{</span> <span class="s1">&#39;not null&#39;</span> <span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p>An empty array fails the first test and passes the second. If the code you are
cleaning up relies on the first, state that meaning in a form the analyzer
accepts. It differs in one case: when the filter leaves exactly one item and
that item is falsy, such as <code>0</code>, <code>''</code>, <code>$false</code> or an empty array. That case
is covered below:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="kd">function</span><span class="w"> </span><span class="nb">Test-HasItem</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">param</span><span class="p">(</span><span class="nv">$Value</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="vm">@</span><span class="p">(</span><span class="nv">$Value</span> <span class="p">|</span> <span class="nb">Where-Object</span> <span class="p">{</span> <span class="vm">$null</span> <span class="o">-ne</span> <span class="nv">$_</span> <span class="p">}).</span><span class="py">Count</span> <span class="o">-gt</span> <span class="mf">0</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nb">Test-HasItem</span> <span class="nv">$items</span><span class="p">)</span> <span class="p">{</span> <span class="s1">&#39;has items&#39;</span> <span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div>
</div>

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

</div>

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

</div>

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

</div>

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

</div>

<p>The scripts that run my 7 Days to Die server pass SteamCMD one number, <code>294420</code>. The game has a number too, <code>251570</code>, in the address of its store page, and an anonymous install of it fails with <code>No subscription</code>. I wrote this part first because every later part in this series hands the server&rsquo;s ID to SteamCMD, and a reader running another game has to find theirs.</p>
<h2 id="app-id-for-each-of-25-dedicated-servers">App ID for each of 25 dedicated servers<a class="heading-anchor" href="https://looselydecoupled.com/find-steam-dedicated-server-app-id/#app-id-for-each-of-25-dedicated-servers"
     aria-label="Link to section: App ID for each of 25 dedicated servers" data-pagefind-ignore></a>
</h2>
<p>I checked each row on 2026-10-03, on Windows Server 2025, with the commands in this article.</p>
<div class="table-wrap" data-scrollable data-label="Table: Game">
  <table>
    <thead>
      <tr><th scope="col">Game</th><th scope="col">App ID</th><th scope="col">Anonymous install</th><th scope="col">Name Steam returns</th><th scope="col">Game&rsquo;s ID</th></tr>
    </thead>
    <tbody>
      <tr><td>7 Days to Die</td><td>294420</td><td>Install begins</td><td>7 Days to Die Dedicated Server</td><td>251570</td></tr>
      <tr><td>ARK: Survival Ascended</td><td>2430930</td><td>Install begins</td><td>ARK: Survival Ascended Dedicated Server</td><td>2399830</td></tr>
      <tr><td>ARK: Survival Evolved</td><td>376030</td><td>Install begins</td><td>ARK: Survival Evolved Dedicated Server</td><td>none</td></tr>
      <tr><td>Arma 3</td><td>233780</td><td>Refused: No subscription</td><td>Arma 3 Server</td><td>107410</td></tr>
      <tr><td>Arma Reforger</td><td>1874900</td><td>Install begins</td><td>Arma Reforger Server</td><td>1874880</td></tr>
      <tr><td>Conan Exiles</td><td>443030</td><td>Install begins</td><td>Conan Exiles Dedicated Server</td><td>none</td></tr>
      <tr><td>Core Keeper</td><td>1963720</td><td>Install begins</td><td>Core Keeper Dedicated Server</td><td>1621690</td></tr>
      <tr><td>Counter-Strike 2</td><td>730</td><td>Install begins</td><td>Counter-Strike 2</td><td>none</td></tr>
      <tr><td>DayZ</td><td>223350</td><td>Refused: No subscription</td><td>DayZ Server</td><td>221100</td></tr>
      <tr><td>Don&rsquo;t Starve Together</td><td>343050</td><td>Install begins</td><td>Don&rsquo;t Starve Together Dedicated Server</td><td>none</td></tr>
      <tr><td>Enshrouded</td><td>2278520</td><td>Install begins</td><td>Enshrouded Dedicated Server</td><td>1203620</td></tr>
      <tr><td>Garry&rsquo;s Mod</td><td>4020</td><td>Install begins</td><td>Garry&rsquo;s Mod Dedicated Server</td><td>4000</td></tr>
      <tr><td>Insurgency: Sandstorm</td><td>581330</td><td>Install begins</td><td>Insurgency: Sandstorm Dedicated Server</td><td>581320</td></tr>
      <tr><td>Left 4 Dead 2</td><td>222860</td><td>Install begins</td><td>Left 4 Dead 2 Dedicated Server</td><td>550</td></tr>
      <tr><td>Palworld</td><td>2394010</td><td>Install begins</td><td>Palworld Dedicated Server</td><td>1623730</td></tr>
      <tr><td>Project Zomboid</td><td>380870</td><td>Install begins</td><td>Project Zomboid Dedicated Server</td><td>none</td></tr>
      <tr><td>Rust</td><td>258550</td><td>Install begins</td><td>Rust Dedicated Server</td><td>none</td></tr>
      <tr><td>Satisfactory</td><td>1690800</td><td>Install begins</td><td>Satisfactory Dedicated Server</td><td>526870</td></tr>
      <tr><td>Sons of the Forest</td><td>2465200</td><td>Install begins</td><td>Sons Of The Forest Dedicated Server</td><td>1326470</td></tr>
      <tr><td>Space Engineers</td><td>298740</td><td>Install begins</td><td>Space Engineers Dedicated Server</td><td>244850</td></tr>
      <tr><td>Squad</td><td>403240</td><td>Install begins</td><td>Squad Dedicated Server</td><td>none</td></tr>
      <tr><td>Team Fortress 2</td><td>232250</td><td>Install begins</td><td>Team Fortress 2 Dedicated Server</td><td>440</td></tr>
      <tr><td>Unturned</td><td>1110390</td><td>Install begins</td><td>Unturned Dedicated Server</td><td>304930</td></tr>
      <tr><td>V Rising</td><td>1829350</td><td>Install begins</td><td>V Rising Dedicated Server</td><td>1604030</td></tr>
      <tr><td>Valheim</td><td>896660</td><td>Install begins</td><td>Valheim Dedicated Server</td><td>892970</td></tr>
    </tbody>
  </table>
</div>
<p>The columns:</p>
<ul>
<li><strong>Anonymous install</strong>: <code>Install begins</code> means SteamCMD printed its first <code>Update state</code> line and I stopped it. <code>Refused</code> means the install was refused with <code>No subscription</code>. No install ran to the end, and I didn&rsquo;t try a login.</li>
<li><strong>Name Steam returns</strong>: what <code>app_info_print</code> printed as <code>name</code> for the ID.</li>
<li><strong>Game&rsquo;s ID</strong>: the record&rsquo;s <code>parent</code>. I looked up each one, and each returned that game&rsquo;s name. Seven servers have none.</li>
</ul>
<p>Two rows need a second look. Counter-Strike 2&rsquo;s ID is the game&rsquo;s own app: its <code>type</code> is <code>Game</code> and its name doesn&rsquo;t contain Server. Rust&rsquo;s record calls it a dedicated server but gives it the <code>type</code> <code>Game</code>, so don&rsquo;t filter on <code>type</code>.</p>
<!-- style-check: procedure -->
<h2 id="1-find-a-candidate-id">1. Find a candidate ID<a class="heading-anchor" href="https://looselydecoupled.com/find-steam-dedicated-server-app-id/#1-find-a-candidate-id"
     aria-label="Link to section: 1. Find a candidate ID" data-pagefind-ignore></a>
</h2>
<p>Look for the ID in the game&rsquo;s own server documentation, or search SteamDB in a browser. Treat what you find as a candidate until the next step confirms it.</p>
<p>The store won&rsquo;t lead you to it. The game&rsquo;s ID is in the address of the game&rsquo;s store page: <code>store.steampowered.com/app/251570/</code> is 7 Days to Die. The server has no page of its own. When I requested <code>store.steampowered.com/app/294420/</code> from a script, the store answered with its front page, and the store&rsquo;s <code>api/storesearch</code> endpoint returned no result for <code>7 days to die dedicated server</code>. SteamDB answered a script with 403, and Valve&rsquo;s Developer Community wiki answered with a &ldquo;Making sure you&rsquo;re not a bot!&rdquo; page instead of an article, so I haven&rsquo;t checked what either lists.</p>
<h2 id="2-confirm-the-id-from-its-record">2. Confirm the ID from its record<a class="heading-anchor" href="https://looselydecoupled.com/find-steam-dedicated-server-app-id/#2-confirm-the-id-from-its-record"
     aria-label="Link to section: 2. Confirm the ID from its record" data-pagefind-ignore></a>
</h2>
<p>This step needs <code>steamcmd.exe</code>. <a href="https://looselydecoupled.com/install-steamcmd-steam-dedicated-server-windows/">Part 2</a> installs it, and the commands here assume <code>C:\steamcmd</code>. For the lookup alone, <code>https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip</code> unpacks to <code>steamcmd.exe</code>, and the command works on its first run, though that run exits with code 7. The command is the one in the answer block, with your candidate in place of <code>294420</code>. It reads the record and changes nothing.</p>
<p>Read the three lines:</p>
<ul>
<li><code>name</code> is the one to trust. 24 of the 25 names in the table contain Server. The exception is Counter-Strike 2, which is the game.</li>
<li><code>parent</code> is the game&rsquo;s ID, when the server has one.</li>
<li><code>type</code> is <code>Tool</code> for 23 of the 25 and <code>Game</code> for two, so a <code>Game</code> isn&rsquo;t proof you have the wrong app. Rust&rsquo;s record reads:</li>
</ul>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">PS&gt; </span><span class="console__token">&amp;</span> <span class="console__token">C:\steamcmd\steamcmd.exe</span> <span class="console__token">+login</span> <span class="console__token">anonymous</span> <span class="console__token">+app_info_print</span> <span class="console__token">258550</span> <span class="console__token">+quit</span> <span class="console__token">|</span>
</span><span class="line"><span class="prompt">&gt;     </span><span class="console__token">Select-String</span> <span class="console__token">-Pattern</span> <span class="console__token">&#39;^\t\t&#34;(name|type|parent)&#34;\t&#39;</span> <span class="console__token">|</span>
</span><span class="line"><span class="prompt">&gt;     </span><span class="console__token">ForEach-Object</span> <span class="console__token">{</span> <span class="console__token">$_.Line.Trim()</span> <span class="console__token">-replace</span> <span class="console__token">&#39;\t+&#39;,</span> <span class="console__token">&#39;</span>  <span class="console__token">&#39;</span> <span class="console__token">}</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">&#34;name&#34;</span>  <span class="console__token">&#34;Rust</span> <span class="console__token">Dedicated</span> <span class="console__token">Server&#34;</span>
</span><span class="line"><span class="console__token">&#34;type&#34;</span>  <span class="console__token">&#34;Game&#34;</span></span></code></pre>
    </div>
  </div></div><h2 id="3-check-that-anonymous-login-can-install-it">3. Check that anonymous login can install it<a class="heading-anchor" href="https://looselydecoupled.com/find-steam-dedicated-server-app-id/#3-check-that-anonymous-login-can-install-it"
     aria-label="Link to section: 3. Check that anonymous login can install it" data-pagefind-ignore></a>
</h2>
<p>A real install downloads the whole server, which for some games is tens of gigabytes, and you only need to know whether it starts. Run the install once into an empty folder you can delete, here <code>C:\steam-check</code>, and stop it when SteamCMD answers:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="p">&amp;</span> <span class="n">C:</span><span class="p">\</span><span class="n">steamcmd</span><span class="p">\</span><span class="n">steamcmd</span><span class="p">.</span><span class="py">exe</span> <span class="p">+</span><span class="n">force_install_dir</span> <span class="s2">&#34;C:\steam-check&#34;</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="p">+</span><span class="n">login</span> <span class="n">anonymous</span> <span class="p">+</span><span class="n">app_update</span> <span class="mf">294420</span></span></span></code></pre></div></div>
  </div>
</div><p>Keep the quotes around the folder, because a path with a space splits at the space. Watch the console. One of two things comes first:</p>
<ul>
<li>A line that starts <code>Update state</code>, such as <code>Update state (0x61) downloading, progress: 0.00 (0 / 6103829589)</code>. The install has begun, so anonymous login can install the server. Press Ctrl+C.</li>
<li><code>ERROR! Failed to install app '233780' (No subscription)</code>. Anonymous login can&rsquo;t install it. SteamCMD waits at its <code>Steam&gt;</code> prompt, so type <code>quit</code>.</li>
</ul>
<p>Then delete the folder:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Remove-Item</span> <span class="n">-LiteralPath</span> <span class="s1">&#39;\\?\C:\steam-check&#39;</span> <span class="n">-Recurse</span> <span class="n">-Force</span></span></span></code></pre></div></div>
  </div>
</div><p>The <code>\\?\</code> prefix costs nothing and is there for long paths. <code>Remove-Item</code> in Windows PowerShell 5.1 stops at 260 characters, and some games nest folders deeper than that. An install stopped in its first seconds rarely gets that far, but if you let one run longer, the prefix is what deletes it.</p>
<p>Stopping SteamCMD this way can make its next start print <code>Looks like steam didn't shutdown cleanly, scheduling immediate update check</code>. Nothing is wrong.</p>
<!-- style-check: end procedure -->
<h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/find-steam-dedicated-server-app-id/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="no-subscription-when-you-install"><code>No subscription</code> when you install<a class="heading-anchor" href="https://looselydecoupled.com/find-steam-dedicated-server-app-id/#no-subscription-when-you-install"
     aria-label="Link to section: No subscription when you install" data-pagefind-ignore></a>
</h3>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">ERROR!</span> <span class="console__token">Failed</span> <span class="console__token">to</span> <span class="console__token">install</span> <span class="console__token">app</span> <span class="console__token">&#39;233780&#39;</span> <span class="console__token">(No</span> <span class="console__token">subscription)</span></span></code></pre>
    </div>
  </div></div><p>An anonymous login isn&rsquo;t allowed to install that app. Three cases give the same message:</p>
<ul>
<li>The ID is the game&rsquo;s. Step 3 gives it for <code>251570</code>, 7 Days to Die.</li>
<li>The server isn&rsquo;t available to an anonymous login. The DayZ and Arma 3 servers answered this way. I didn&rsquo;t test whether a login works.</li>
<li>The ID doesn&rsquo;t exist. Step 3 gives it for <code>999999999</code>.</li>
</ul>
<p>Read the ID&rsquo;s record to tell them apart:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="p">&amp;</span> <span class="n">C:</span><span class="p">\</span><span class="n">steamcmd</span><span class="p">\</span><span class="n">steamcmd</span><span class="p">.</span><span class="py">exe</span> <span class="p">+</span><span class="n">login</span> <span class="n">anonymous</span> <span class="p">+</span><span class="n">app_info_print</span> <span class="p">&lt;</span><span class="nb">app-id</span><span class="p">&gt;</span> <span class="p">+</span><span class="n">quit</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Select-String</span> <span class="n">-Pattern</span> <span class="s1">&#39;^\t\t&#34;(name|type|parent)&#34;\t&#39;</span></span></span></code></pre></div></div>
  </div>
</div><p>A <code>name</code> without Server in it, with <code>type</code> <code>Game</code>, is the game. A name that says server means anonymous login can&rsquo;t install it. No lines at all means the lookup returned no record. Either no app has that ID, or SteamCMD couldn&rsquo;t reach Steam. To tell them apart, run the lookup again without the <code>Select-String</code> line, so SteamCMD&rsquo;s own output shows. A missing app still prints <code>Waiting for user info...OK</code>. A failed connection prints <code>FAILED (No Connection)</code> after retrying for a couple of minutes.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>OS</dt> <dd>Windows Server 2025 Datacenter (10.0.26100), virtual machine</dd></div>
    <div class="tested__row"><dt>PowerShell</dt> <dd>Windows PowerShell 5.1</dd></div>
    <div class="tested__row"><dt>SteamCMD</dt> <dd>client version 1788292693, anonymous login</dd></div>
    <div class="tested__row"><dt>Servers</dt> <dd>25 checked, each install stopped as soon as it began or was refused</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
    <item>
      <title>Configure a 7 Days to Die Dedicated Server and Open Its Ports</title>
      <link>https://looselydecoupled.com/configure-7-days-to-die-server-and-open-ports/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/configure-7-days-to-die-server-and-open-ports/</guid>
      <pubDate>Thu, 24 Sep 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Windows Server</category>
      <category>PowerShell</category>
      <category>Automation</category>
      
      <description>A 7 Days to Die server that starts on a clean Windows Server, with its saves where you choose and TCP 26900 and UDP 26900 to 26902 open in Windows Firewall.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Install the Visual C++ runtime, set the server&rsquo;s name, password and port in a copy of <code>serverconfig.xml</code> that you start the server from, and start the server once. The copy matters: a later <code>validate</code> restores the shipped file. Read the ports it listens on, then open exactly those in Windows Firewall. With the default <code>ServerPort</code> of <code>26900</code>, the server listens on TCP <code>26900</code>, and on UDP <code>26900</code> and <code>26902</code>, which is <code>ServerPort</code> and <code>ServerPort + 2</code>. The server&rsquo;s own message about a bad port also names <code>ServerPort + 1</code>, so the rules open UDP <code>26900</code> to <code>26902</code>. The port numbers and the config file are the game-specific parts.</p>
<p>In an elevated PowerShell on the server, once the server runs:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">New-NetFirewallRule</span> <span class="n">-DisplayName</span> <span class="s1">&#39;7DaysToDie-26900-TCP&#39;</span> <span class="n">-Direction</span> <span class="n">Inbound</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-Action</span> <span class="n">Allow</span> <span class="n">-Protocol</span> <span class="n">TCP</span> <span class="n">-LocalPort</span> <span class="mf">26900</span>
</span></span><span class="line"><span class="cl"><span class="nb">New-NetFirewallRule</span> <span class="n">-DisplayName</span> <span class="s1">&#39;7DaysToDie-26900-UDP&#39;</span> <span class="n">-Direction</span> <span class="n">Inbound</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-Action</span> <span class="n">Allow</span> <span class="n">-Protocol</span> <span class="n">UDP</span> <span class="n">-LocalPort</span> <span class="mf">26900</span><span class="p">-</span><span class="mf">26902</span></span></span></code></pre></div></div>
  </div>
</div><p>Run those once. Step 5 has a version that replaces a rule of the same name, so a rule you customized under that name is overwritten. Windows Firewall blocks inbound traffic until a rule allows it, and a network security group or router in front of the server is a separate firewall that these rules don&rsquo;t open. If the server runs but players can&rsquo;t join, these rules are the first check, and the group or router&rsquo;s port forwarding for the same ports is the second.</p>
<p>The thing most likely to stop you comes before any of that. On a clean Windows Server the server exits within seconds of starting, with <code>[EOS] Native library or one of its dependencies not found</code> in its log, because the Visual C++ runtime isn&rsquo;t installed. Step 1 installs it.</p>

</div>

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

</div>

<p>While working on a backup script for my
<a href="https://looselydecoupled.com/backup-7-days-to-die-server-s3/">7 Days to Die game server</a>,
I thought it would be good to share this.</p>
<p>You replace an AWS CLI command with a PowerShell cmdlet and keep the success
check. The cmdlet prints an error. The script announces success anyway.</p>
<p>The check still runs. It&rsquo;s reading the result of a different operation.</p>
<!-- style-check: procedure -->
<h2 id="reproduce-a-failed-command-with-exit-code-zero">Reproduce a failed command with exit code zero<a class="heading-anchor" href="https://looselydecoupled.com/powershell-lastexitcode-cmdlet-errors/#reproduce-a-failed-command-with-exit-code-zero"
     aria-label="Link to section: Reproduce a failed command with exit code zero" data-pagefind-ignore></a>
</h2>
<p>Run this in PowerShell on Windows:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="n">cmd</span><span class="p">.</span><span class="py">exe</span> <span class="p">/</span><span class="n">d</span> <span class="p">/</span><span class="n">c</span> <span class="n">exit</span> <span class="mf">0</span>
</span></span><span class="line"><span class="cl"><span class="nb">Write-Error</span> <span class="s1">&#39;Demonstration: the upload failed&#39;</span> <span class="n">-ErrorAction</span> <span class="k">Continue</span>
</span></span><span class="line"><span class="cl"><span class="s2">&#34;Native exit code: </span><span class="nv">$LASTEXITCODE</span><span class="s2">&#34;</span></span></span></code></pre></div></div>
  </div>
</div><p>The error appears, followed by:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Native exit code: 0</span></span></code></pre></div></div>
  </div>
</div><p>The zero came from <code>cmd.exe</code>. <code>Write-Error</code> did not update it. The explicit
<code>Continue</code> makes this demonstration non-terminating even if your session normally
uses a stricter error preference.</p>
<p>The reverse is just as misleading:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="n">cmd</span><span class="p">.</span><span class="py">exe</span> <span class="p">/</span><span class="n">d</span> <span class="p">/</span><span class="n">c</span> <span class="n">exit</span> <span class="mf">7</span>
</span></span><span class="line"><span class="cl"><span class="nb">Get-Date</span> <span class="p">|</span> <span class="nb">Out-Null</span>
</span></span><span class="line"><span class="cl"><span class="s2">&#34;Native exit code: </span><span class="nv">$LASTEXITCODE</span><span class="s2">&#34;</span></span></span></code></pre></div></div>
  </div>
</div><p>This prints <code>7</code> even though <code>Get-Date</code> succeeded. These examples illustrate why
a value left in the variable is not evidence about a later cmdlet.</p>
<p>Microsoft defines the variable for native programs and PowerShell scripts;
script behavior also depends on invocation and the <code>exit</code> keyword. Ordinary
cmdlet success or failure does not make it a universal status register. See
<a href="https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_automatic_variables#lastexitcode" rel="external noopener" target="_blank">about_Automatic_Variables<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/powershell-lastexitcode-cmdlet-errors/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>.</p>
<h2 id="for-native-commands-check-each-result-at-the-boundary">For native commands, check each result at the boundary<a class="heading-anchor" href="https://looselydecoupled.com/powershell-lastexitcode-cmdlet-errors/#for-native-commands-check-each-result-at-the-boundary"
     aria-label="Link to section: For native commands, check each result at the boundary" data-pagefind-ignore></a>
</h2>
<p>This example assumes AWS CLI is installed, the source exists, and the active
AWS identity can access your chosen bucket:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$Source</span> <span class="p">=</span> <span class="s1">&#39;C:\Data\exports&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$Destination</span> <span class="p">=</span> <span class="s1">&#39;s3://YOUR-BUCKET/exports/&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="vm">$null</span> <span class="p">=</span> <span class="nb">Get-Command</span> <span class="n">aws</span> <span class="n">-CommandType</span> <span class="n">Application</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl"><span class="n">aws</span> <span class="n">s3</span> <span class="n">sync</span> <span class="nv">$Source</span> <span class="nv">$Destination</span>
</span></span><span class="line"><span class="cl"><span class="nv">$Code</span> <span class="p">=</span> <span class="nv">$LASTEXITCODE</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nv">$Code</span> <span class="o">-ne</span> <span class="mf">0</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">throw</span> <span class="s2">&#34;S3 sync did not complete cleanly; AWS CLI exit code </span><span class="nv">$Code</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="s1">&#39;Export transfer completed&#39;</span></span></span></code></pre></div></div>
  </div>
</div><p>Replace <code>YOUR-BUCKET</code> before running it. AWS documents nonzero outcomes including
failed S3 transfers and skipped files. A skipped file is enough to reject a
backup run as incomplete; inspect the command output for the cause. See the
<a href="https://docs.aws.amazon.com/cli/latest/userguide/cli-usage-returncodes.html" rel="external noopener" target="_blank">AWS CLI return-code reference<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/powershell-lastexitcode-cmdlet-errors/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>.</p>
<div class="admonition admonition--warning" role="note">
  <p class="admonition__label">
    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/powershell-lastexitcode-cmdlet-errors/#i-triangle-alert"/></svg>
    <span>Warning</span>
  </p>
  <p>Do not run three transfers and check only after the third. A later successful
native command replaces an earlier failure code with zero, so the script
reports success for a backup that partly failed. That is worse than no
success message at all: you now have positive evidence for something untrue,
and you will not look again until you need the data.</p>
</div><p>Capture and handle each result before moving on.</p>
<p>Also distinguish a process failure from a launch failure. If <code>aws</code> is not
found, no AWS CLI process returned an exit code, and the check reads whatever
an earlier native command left. The <code>Get-Command</code> line stops the script before
that can happen.</p>
<!-- style-check: end procedure -->
<h2 id="why-trycatch-sometimes-misses-a-cmdlet-error">Why try/catch sometimes misses a cmdlet error<a class="heading-anchor" href="https://looselydecoupled.com/powershell-lastexitcode-cmdlet-errors/#why-trycatch-sometimes-misses-a-cmdlet-error"
     aria-label="Link to section: Why try/catch sometimes misses a cmdlet error" data-pagefind-ignore></a>
</h2>
<p>PowerShell can report an error while continuing execution. For example:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Write-Error</span> <span class="s1">&#39;Demonstration failure&#39;</span> <span class="n">-ErrorAction</span> <span class="k">Continue</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;Still inside try&#39;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;Caught the error&#39;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p>The error is displayed and <code>Still inside try</code> prints. The catch block isn&rsquo;t
entered. Change that one invocation to <code>-ErrorAction Stop</code> and the catch block
runs instead.</p>
<p>Some errors already terminate execution; they don&rsquo;t need this promotion.
<code>-ErrorAction Stop</code> makes the intended behavior explicit for cmdlet errors that
would otherwise continue. Microsoft&rsquo;s <a href="https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_try_catch_finally" rel="external noopener" target="_blank">try/catch documentation<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/powershell-lastexitcode-cmdlet-errors/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
explains which errors those blocks handle.</p>
<h2 id="catch-a-write-s3object-failure-with--erroraction-stop">Catch a <code>Write-S3Object</code> failure with <code>-ErrorAction Stop</code><a class="heading-anchor" href="https://looselydecoupled.com/powershell-lastexitcode-cmdlet-errors/#catch-a-write-s3object-failure-with--erroraction-stop"
     aria-label="Link to section: Catch a Write-S3Object failure with -ErrorAction Stop" data-pagefind-ignore></a>
</h2>
<p>With <code>AWS.Tools.S3</code> installed and AWS access configured, use this for an
intentional upload of one existing file:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Import-Module</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="py">S3</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Write-S3Object</span> <span class="n">-BucketName</span> <span class="s1">&#39;YOUR-BUCKET&#39;</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">        <span class="o">-File</span> <span class="s1">&#39;C:\Data\exports\report.json&#39;</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">        <span class="n">-Key</span> <span class="s1">&#39;exports/report.json&#39;</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">        <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;Upload completed&#39;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Write-Warning</span> <span class="p">(</span><span class="s2">&#34;Upload failed: {0}&#34;</span> <span class="o">-f</span> <span class="nv">$_</span><span class="p">.</span><span class="py">Exception</span><span class="p">.</span><span class="n">Message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">throw</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p>The success message is reachable only after the upload returns normally. The
bare <code>throw</code> propagates the caught error to the caller. Logging and then
continuing would hide the failure from whatever invoked the script.</p>
<p>If you have no context to add and no recovery to attempt, remove the <code>try/catch</code>
and keep <code>-ErrorAction Stop</code>. A catch block that only rethrows isn&rsquo;t required.</p>
<h2 id="psnativecommanduseerroractionpreference-in-powershell-7"><code>$PSNativeCommandUseErrorActionPreference</code> in PowerShell 7<a class="heading-anchor" href="https://looselydecoupled.com/powershell-lastexitcode-cmdlet-errors/#psnativecommanduseerroractionpreference-in-powershell-7"
     aria-label="Link to section: $PSNativeCommandUseErrorActionPreference in PowerShell 7" data-pagefind-ignore></a>
</h2>
<p>PowerShell 7.4 added <code>$PSNativeCommandUseErrorActionPreference</code>. When it&rsquo;s
<code>$true</code>, nonzero native exit codes produce PowerShell errors governed by
<code>$ErrorActionPreference</code>; with <code>Stop</code>, those can reach <code>catch</code>. The default is
<code>$false</code>. Microsoft documents it in
<a href="https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_preference_variables#psnativecommanduseerroractionpreference" rel="external noopener" target="_blank">about_Preference_Variables<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/powershell-lastexitcode-cmdlet-errors/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>.</p>
<p>That doesn&rsquo;t make <code>-ErrorAction Stop</code> a parameter for <code>aws.exe</code>. Passed to a
native program, it&rsquo;s an argument for that program to interpret. Explicit
exit-code checks remain useful for scripts spanning Windows PowerShell 5.1 and
PowerShell 7, and for tools whose nonzero codes have special meanings.</p>
<p>If native error promotion is enabled with <code>Stop</code>, execution can leave the native
invocation before the following explicit check. Either path must preserve the
failure; don&rsquo;t catch it and then print success.</p>
<h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/powershell-lastexitcode-cmdlet-errors/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<p><strong>LASTEXITCODE is empty or stale.</strong> There may be no relevant native result in the
session. Choose the error mechanism for the operation you actually ran.</p>
<p><strong>Can I replace it with <code>$?</code> everywhere?</strong> <code>$?</code> describes the last command&rsquo;s
execution status and is overwritten by the next command. It isn&rsquo;t a durable
record of a multi-step job. Structured error handling is clearer for cmdlets.</p>
<p><strong>The task still reports success.</strong> Check whether a wrapper catches and suppresses
the error, then exits normally. At the outer script boundary, preserve a failure
through an unhandled terminating error or an intentional nonzero <code>exit</code> code.</p>
<p>A script needs to remember whether the work succeeded. That starts by listening
to the failure signal the command actually provides.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>OS</dt> <dd>Windows Server 2019 (10.0.17763)</dd></div>
    <div class="tested__row"><dt>PowerShell</dt> <dd>Windows PowerShell 5.1</dd></div>
    <div class="tested__row"><dt>PowerShell 7 section</dt> <dd>7.6.6 on macOS 27.0</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
    <item>
      <title>Why Does My Batch File Exit When I Call a Function?</title>
      <link>https://looselydecoupled.com/batch-file-exits-when-calling-function/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/batch-file-exits-when-calling-function/</guid>
      <pubDate>Mon, 07 Sep 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Windows Server</category>
      <category>Batch</category>
      <category>Automation</category>
      
      <description>The subroutine runs before anything calls it, and its goto :eof ends the whole script, because cmd.exe reads straight down through a label.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>A batch label does not create a function body that <code>cmd.exe</code> skips. Execution
runs straight through the label unless you redirect it.</p>
<p>Put the main work first, end it with <code>exit /b</code>, and place subroutines below it:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="BATCH">
  <div class="code-block__head"><span class="code-block__lang">BATCH</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-batch" data-lang="batch"><span class="line"><span class="cl"><span class="p">@</span><span class="k">echo</span> off
</span></span><span class="line"><span class="cl"><span class="k">setlocal</span> EnableExtensions
</span></span><span class="line"><span class="cl"><span class="k">call</span> <span class="p">:</span><span class="nl">timestamp</span>
</span></span><span class="line"><span class="cl"><span class="k">echo</span> [<span class="nv">%stamp%</span>] Work starts here
</span></span><span class="line"><span class="cl"><span class="k">exit</span> /b 0
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">:</span><span class="nl">timestamp</span>
</span></span><span class="line"><span class="cl"><span class="k">set</span> <span class="s2">&#34;stamp=</span><span class="nv">%DATE%</span><span class="s2"> </span><span class="nv">%TIME%</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">goto</span> <span class="p">:</span><span class="nl">eof</span></span></span></code></pre></div></div>
  </div>
</div><p><code>call :timestamp</code> creates a return point. <code>goto :eof</code> returns from that call.
Reached in the main batch context, it ends the batch instead.</p>

</div>

<p>You add a timestamp helper to a working batch file. The helper looks reasonable.
The next run prints almost nothing, and the real work never starts.</p>
<p>It&rsquo;s tempting to debug the timestamp expression. First check whether the
script ever reaches the line that calls it.</p>
<h2 id="minimal-reproduction-the-call-never-happens">Minimal reproduction: the call never happens<a class="heading-anchor" href="https://looselydecoupled.com/batch-file-exits-when-calling-function/#minimal-reproduction-the-call-never-happens"
     aria-label="Link to section: Minimal reproduction: the call never happens" data-pagefind-ignore></a>
</h2>
<p>Save this as <code>fall-through.cmd</code>:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="fall-through.cmd">
  <div class="code-block__head"><span class="code-block__lang code-block__file">fall-through.cmd</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-batch" data-lang="batch"><span class="line"><span class="cl"><span class="p">@</span><span class="k">echo</span> off
</span></span><span class="line"><span class="cl"><span class="k">echo</span> Starting script
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">:</span><span class="nl">timestamp</span>
</span></span><span class="line"><span class="cl"><span class="k">echo</span> Inside timestamp
</span></span><span class="line"><span class="cl"><span class="k">set</span> <span class="s2">&#34;stamp=</span><span class="nv">%DATE%</span><span class="s2"> </span><span class="nv">%TIME%</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">goto</span> <span class="p">:</span><span class="nl">eof</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">call</span> <span class="p">:</span><span class="nl">timestamp</span>
</span></span><span class="line"><span class="cl"><span class="k">echo</span> [<span class="nv">%stamp%</span>] Doing the work</span></span></code></pre></div></div>
  </div>
</div><p>Run it from an existing Command Prompt so the output stays visible:</p>
<div class="console"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Expected output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">C:\scripts&gt;</span> <span class="console__token">cmd</span> <span class="console__token">/d</span> <span class="console__token">/e:on</span> <span class="console__token">/c</span> <span class="console__token">fall-through.cmd</span>
</span><span class="line"><span class="console__token">Starting</span> <span class="console__token">script</span>
</span><span class="line"><span class="console__token">Inside</span> <span class="console__token">timestamp</span>
</span><span class="line">
</span><span class="line"><span class="console__token">C:\scripts&gt;</span></span></code></pre>
    </div>
  </div></div><p>That output is the clue: the helper ran even though the <code>call</code> below it was
never reached. Execution arrived there by reading downward.</p>
<p>A label marks a location. It doesn&rsquo;t surround or hide the commands beneath it.
The <code>goto :eof</code> then ends the current batch context, so both remaining lines
are unreachable on this path. Microsoft&rsquo;s <a href="https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/goto" rel="external noopener" target="_blank">goto reference<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/batch-file-exits-when-calling-function/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
documents the special end-of-file target; you don&rsquo;t need to define an <code>:eof</code> label.</p>
<h2 id="end-the-main-path-with-exit-b-before-the-subroutines">End the main path with <code>exit /b</code> before the subroutines<a class="heading-anchor" href="https://looselydecoupled.com/batch-file-exits-when-calling-function/#end-the-main-path-with-exit-b-before-the-subroutines"
     aria-label="Link to section: End the main path with exit /b before the subroutines" data-pagefind-ignore></a>
</h2>
<p>Here is the complete repaired example:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="no-fall-through.cmd">
  <div class="code-block__head"><span class="code-block__lang code-block__file">no-fall-through.cmd</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-batch" data-lang="batch"><span class="line"><span class="cl"><span class="p">@</span><span class="k">echo</span> off
</span></span><span class="line"><span class="cl"><span class="k">setlocal</span> EnableExtensions
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">echo</span> Starting script
</span></span><span class="line"><span class="cl"><span class="k">call</span> <span class="p">:</span><span class="nl">timestamp</span>
</span></span><span class="line"><span class="cl"><span class="k">echo</span> [<span class="nv">%stamp%</span>] Doing the work
</span></span><span class="line"><span class="cl"><span class="k">exit</span> /b 0
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">:</span><span class="nl">timestamp</span>
</span></span><span class="line"><span class="cl"><span class="k">echo</span> Inside timestamp
</span></span><span class="line"><span class="cl"><span class="k">set</span> <span class="s2">&#34;stamp=</span><span class="nv">%DATE%</span><span class="s2"> </span><span class="nv">%TIME%</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">goto</span> <span class="p">:</span><span class="nl">eof</span></span></span></code></pre></div></div>
  </div>
</div><p>Expected output, with the timestamp varying by locale and clock:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Starting script
</span></span><span class="line"><span class="cl">Inside timestamp
</span></span><span class="line"><span class="cl">[&lt;local date&gt; &lt;local time&gt;] Doing the work</span></span></code></pre></div></div>
  </div>
</div><p>The main path calls the helper, resumes after the call, does its work, and exits
before reaching the helper a second time. The helper is now reachable only
through an intentional call.</p>
<p>Moving a subroutine to the bottom <strong>without</strong> the main-path exit is incomplete.
The call returns correctly, but after the main work finishes, execution falls
into the helper again.</p>
<h2 id="what-call-goto-and-exit-b-each-do">What <code>call</code>, <code>goto</code> and <code>exit /b</code> each do<a class="heading-anchor" href="https://looselydecoupled.com/batch-file-exits-when-calling-function/#what-call-goto-and-exit-b-each-do"
     aria-label="Link to section: What call, goto and exit /b each do" data-pagefind-ignore></a>
</h2>
<p><code>call :label</code> starts a new batch context within the same file. When that context
ends, execution resumes at the statement after the call. <code>goto label</code> only jumps;
it doesn&rsquo;t arrange a return. See Microsoft&rsquo;s <a href="https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/call" rel="external noopener" target="_blank">call reference<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/batch-file-exits-when-calling-function/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>.</p>
<div class="table-wrap" data-scrollable data-label="Table: Command">
  <table>
    <thead>
      <tr><th scope="col">Command</th><th scope="col">Effect</th></tr>
    </thead>
    <tbody>
      <tr><td><code>call :timestamp</code></td><td>Enter the subroutine with a return point</td></tr>
      <tr><td><code>goto timestamp</code></td><td>Jump to the label without a return point</td></tr>
      <tr><td><code>goto :eof</code></td><td>End the current batch context</td></tr>
      <tr><td><code>exit /b 0</code></td><td>End the current batch context and set a success status</td></tr>
      <tr><td><code>exit</code></td><td>Exit the command interpreter</td></tr>
    </tbody>
  </table>
</div>
<div class="admonition admonition--warning" role="note">
  <p class="admonition__label">
    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/batch-file-exits-when-calling-function/#i-triangle-alert"/></svg>
    <span>Warning</span>
  </p>
  <p>Plain <code>exit</code> inside a batch file exits the <em>command interpreter</em>, not only the
script. Run it from a Command Prompt you were using and that window closes,
taking any unsaved session state with it. <code>exit /b</code> is almost always what you
want.</p>
</div><p>The <code>/b</code> matters. <code>exit /b</code> ends the current batch context instead of cmd.exe,
as Microsoft&rsquo;s <a href="https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/exit" rel="external noopener" target="_blank">exit reference<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/batch-file-exits-when-calling-function/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
says. A <code>call :label</code> runs in its own context, so inside a subroutine <code>exit /b</code>
returns to the caller, and at the top level it ends the file. Use an explicit
code when the caller needs a result.</p>
<p>These label-call and <code>:eof</code> examples assume command extensions are enabled, as
they normally are. The repaired file makes that requirement explicit with
<code>setlocal EnableExtensions</code>.</p>
<h2 id="helpers-can-also-go-above-main">Helpers can also go above main<a class="heading-anchor" href="https://looselydecoupled.com/batch-file-exits-when-calling-function/#helpers-can-also-go-above-main"
     aria-label="Link to section: Helpers can also go above main" data-pagefind-ignore></a>
</h2>
<p>There&rsquo;s no rule that subroutines must live at the bottom. If you prefer helpers
first, jump over them on entry:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="BATCH">
  <div class="code-block__head"><span class="code-block__lang">BATCH</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-batch" data-lang="batch"><span class="line"><span class="cl"><span class="p">@</span><span class="k">echo</span> off
</span></span><span class="line"><span class="cl"><span class="k">setlocal</span> EnableExtensions
</span></span><span class="line"><span class="cl"><span class="k">goto</span> <span class="nl">main</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">:</span><span class="nl">timestamp</span>
</span></span><span class="line"><span class="cl"><span class="k">set</span> <span class="s2">&#34;stamp=</span><span class="nv">%DATE%</span><span class="s2"> </span><span class="nv">%TIME%</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">goto</span> <span class="p">:</span><span class="nl">eof</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">:</span><span class="nl">main</span>
</span></span><span class="line"><span class="cl"><span class="k">call</span> <span class="p">:</span><span class="nl">timestamp</span>
</span></span><span class="line"><span class="cl"><span class="k">echo</span> [<span class="nv">%stamp%</span>] Doing the work
</span></span><span class="line"><span class="cl"><span class="k">exit</span> /b 0</span></span></code></pre></div></div>
  </div>
</div><p>Placement is a readability choice. An unambiguous execution path is the fix.</p>
<h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/batch-file-exits-when-calling-function/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<p><strong>The window disappears immediately.</strong> Run the file from an existing Command
Prompt. A window opened only to run a batch file can close when that file
ends; the disappearance alone doesn&rsquo;t tell you which command caused it.</p>
<p><strong>The helper runs twice.</strong> Look for a missing <code>exit /b</code> or <code>goto :eof</code> between
the end of the main work and the first helper label.</p>
<p><strong>The main work never resumes.</strong> Check for <code>goto timestamp</code> where you intended
<code>call :timestamp</code>, or plain <code>exit</code> inside the helper.</p>
<p><strong>I only needed a timestamp in a log.</strong> A separate subroutine may be unnecessary:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="BATCH">
  <div class="code-block__head"><span class="code-block__lang">BATCH</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-batch" data-lang="batch"><span class="line"><span class="cl"><span class="k">echo</span> [<span class="nv">%DATE%</span> <span class="nv">%TIME%</span>] Backup started<span class="p">&gt;&gt;</span><span class="s2">&#34;backup.log&#34;</span></span></span></code></pre></div></div>
  </div>
</div><p>This is a display timestamp, not a locale-independent filename format. Keep
this simple example outside a parenthesized block, where percent-variable
expansion introduces a separate timing issue.</p>
<p>When a batch file stops early, follow the lines it actually executes. A label
that looks like a function declaration is still on that path.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>OS</dt> <dd>Windows Server 2019 (10.0.17763)</dd></div>
    <div class="tested__row"><dt>Shell</dt> <dd>cmd.exe</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
    <item>
      <title>How to Install AWS Tools for PowerShell for All Users on Windows</title>
      <link>https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/</guid>
      <pubDate>Mon, 07 Sep 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>AWS</category>
      <category>S3</category>
      <category>PowerShell</category>
      <category>Windows Server</category>
      <category>Automation</category>
      
      <description>You end up with AWS.Tools in Program Files, where a task running as SYSTEM finds it; SYSTEM&#39;s module path never includes your Documents folder.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Open the PowerShell edition your automation uses <strong>as Administrator</strong>, then
install the modular S3 package for all users:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Install-Module</span> <span class="n">-Name</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="py">Installer</span> <span class="n">-MinimumVersion</span> <span class="mf">2.0</span><span class="p">.</span><span class="py">0</span> <span class="n">-MaximumVersion</span> <span class="mf">2.9</span><span class="p">.</span><span class="py">999</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-Repository</span> <span class="n">PSGallery</span> <span class="n">-Scope</span> <span class="n">AllUsers</span> <span class="n">-Force</span>
</span></span><span class="line"><span class="cl"><span class="nb">Import-Module</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="py">Installer</span> <span class="n">-MinimumVersion</span> <span class="mf">2.0</span><span class="p">.</span><span class="py">0</span> <span class="n">-MaximumVersion</span> <span class="mf">2.9</span><span class="p">.</span><span class="py">999</span> <span class="p">`</span>
</span></span><span class="line"><span class="cl">    <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl"><span class="nb">Install-AWSToolsModule</span> <span class="n">-Name</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="py">S3</span> <span class="n">-Version</span> <span class="s1">&#39;5.*&#39;</span> <span class="n">-Scope</span> <span class="n">AllUsers</span></span></span></code></pre></div></div>
  </div>
</div><p>Swap <code>AWS.Tools.S3</code> for the service module your script calls. An older 1.x
installer may already be there, and 1.x has no <code>-Version</code> parameter, so the
last line would fail with it. Without <code>-Force</code>, <code>Install-Module</code> only warns
that 1.x is already installed and leaves it. With <code>-Force</code> it installs 2.x
beside it, and the import line loads 2.x rather than 1.x. <code>-MaximumVersion 2.9.999</code> on both lines, as in AWS&rsquo;s own install command, keeps a future 3.x
out of a workflow written for 2.x.
Keep <code>-Version '5.*'</code>. Without it the installer takes the
latest major version, which can carry breaking changes, as AWS&rsquo;s
<a href="https://aws.amazon.com/blogs/developer/aws-tools-installer-v2-is-now-generally-available/" rel="external noopener" target="_blank">Installer v2 announcement<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
warns. With <code>-Name</code> it installs only the modules you name plus
<code>AWS.Tools.Common</code>, and leaves other AWS.Tools modules at their versions, so
name every module your tasks use together if they should move in step.</p>
<p>In Windows PowerShell 5.1, run this first:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$protocol</span> <span class="p">=</span> <span class="p">[</span><span class="no">Net.ServicePointManager</span><span class="p">]::</span><span class="n">SecurityProtocol</span>
</span></span><span class="line"><span class="cl"><span class="nv">$tls12</span> <span class="p">=</span> <span class="p">[</span><span class="no">Net.SecurityProtocolType</span><span class="p">]::</span><span class="n">Tls12</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nv">$protocol</span> <span class="o">-ne</span> <span class="s1">&#39;SystemDefault&#39;</span> <span class="o">-and</span> <span class="o">-not</span> <span class="p">(</span><span class="nv">$protocol</span> <span class="o">-band</span> <span class="nv">$tls12</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="no">Net.ServicePointManager</span><span class="p">]::</span><span class="n">SecurityProtocol</span> <span class="p">=</span> <span class="nv">$protocol</span> <span class="o">-bor</span> <span class="nv">$tls12</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="nb">Install-PackageProvider</span> <span class="n">-Name</span> <span class="n">NuGet</span> <span class="n">-MinimumVersion</span> <span class="mf">2.8</span><span class="p">.</span><span class="py">5</span><span class="p">.</span><span class="py">201</span> <span class="n">-Force</span> <span class="p">|</span> <span class="nb">Out-Null</span></span></span></code></pre></div></div>
  </div>
</div><p>The <code>if</code> adds TLS 1.2 only when the session has neither it nor the operating
system&rsquo;s default. It never removes a protocol, and it leaves the OS default
alone, as Microsoft&rsquo;s
<a href="https://learn.microsoft.com/en-us/dotnet/framework/network-programming/tls#recommendations" rel="external noopener" target="_blank">TLS guidance<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
recommends, since that default can negotiate newer versions. The gallery needs
TLS 1.2, as Microsoft&rsquo;s
<a href="https://devblogs.microsoft.com/powershell/powershell-gallery-tls-support/" rel="external noopener" target="_blank">PowerShell Gallery TLS post<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
explains, and so does the NuGet provider download, which is why the check
comes first. On the test server it changed nothing. The last line installs the
NuGet provider, and does nothing where it&rsquo;s already in place. If the gallery still can&rsquo;t be reached after both, TLS
isn&rsquo;t the cause; look at a proxy, DNS or a firewall. An install that reaches
the gallery and then fails is a different problem, such as the .NET or
PowerShellGet version the package requires.</p>
<p>Verify from a fresh session under the account that runs the task:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Import-Module</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="py">S3</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span>
</span></span><span class="line"><span class="cl"><span class="p">(</span><span class="nb">Get-Module</span> <span class="n">AWS</span><span class="p">.</span><span class="py">Tools</span><span class="p">.</span><span class="n">S3</span><span class="p">).</span><span class="n">ModuleBase</span></span></span></code></pre></div></div>
  </div>
</div><p>The path should start <code>$env:ProgramFiles\WindowsPowerShell\Modules\</code> for
Windows PowerShell, or <code>$env:ProgramFiles\PowerShell\Modules\</code> for PowerShell 7.
That&rsquo;s <code>C:\Program Files</code> for a 64-bit host and <code>C:\Program Files (x86)</code> for a
32-bit one.</p>
<div class="admonition admonition--note" role="note">
  <p class="admonition__label">
    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/install-aws-tools-powershell-all-users-windows/#i-info"/></svg>
    <span>Note</span>
  </p>
  <p>Module availability and AWS authentication are separate problems that fail at
different points. A task that imports the module and then reports access
denied has finished with this article.</p>
</div>
</div>

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

</div>

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

</div>

<p>I wanted to repurpose my old Mac, a 2011 iMac, by putting Linux Mint on it.
I wrote the installer USB drive on my Apple Silicon MacBook Pro with
balenaEtcher. Etcher stopped at &ldquo;Starting&hellip;&rdquo; and gave no reason, so I wrote
the drive with <code>dd</code>, and the iMac booted from it. Later I reproduced the hang
on a second drive to find the cause.</p>
<h2 id="symptoms">Symptoms<a class="heading-anchor" href="https://looselydecoupled.com/balena-etcher-stuck-starting-macos/#symptoms"
     aria-label="Link to section: Symptoms" data-pagefind-ignore></a>
</h2>
<p>balenaEtcher (also searched as Balena Etcher) detects the drive and loads the
ISO. After you click Flash, the window stays at:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Starting...</span></span></code></pre></div></div>
  </div>
</div><p>No progress bar appears and no error is raised. Launching the app from a
terminal with elevated permissions makes no difference:</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">sudo /Applications/balenaEtcher.app/Contents/MacOS/balenaEtcher</span></span></code></pre></div></div>
  </div>
</div><p>Etcher&rsquo;s DevTools console shows the flasher starting and then stopping short
of the write. This is Etcher 2.1.6 with an APFS-formatted drive:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Starting priviledged flasher sidecar on port 3435
</span></span><span class="line"><span class="cl">/Applications/balenaEtcher.app/Contents/Resources/etcher-util
</span></span><span class="line"><span class="cl">... with privileges ...
</span></span><span class="line"><span class="cl">elevated pending
</span></span><span class="line"><span class="cl">...
</span></span><span class="line"><span class="cl">elevated granted
</span></span><span class="line"><span class="cl">etcher-&lt;id&gt;
</span></span><span class="line"><span class="cl">Connection to sidecar flasher process attempt 1 / 10 failed; retrying in 1000ms...
</span></span><span class="line"><span class="cl">etcher-&lt;id&gt;
</span></span><span class="line"><span class="cl">CHILD READY
</span></span><span class="line"><span class="cl">start heartbeat
</span></span><span class="line"><span class="cl">params Object
</span></span><span class="line"><span class="cl">CHILD LOG: Ready ...
</span></span><span class="line"><span class="cl">CHILD LOG: write requested
</span></span><span class="line"><span class="cl">CHILD LOG: Image: /Users/&lt;user&gt;/Downloads/&lt;image&gt;.iso
</span></span><span class="line"><span class="cl">CHILD LOG: Devices: /dev/diskN
</span></span><span class="line"><span class="cl">CHILD LOG: Auto blockmapping: true
</span></span><span class="line"><span class="cl">CHILD LOG: Decompress first: true</span></span></code></pre></div></div>
  </div>
</div><p>The log reaches <code>write requested</code>, echoes the job&rsquo;s settings, and no bytes
follow. In the log, <code>&lt;user&gt;</code> is your account name and <code>&lt;image&gt;</code> is the ISO
you selected. <code>N</code> is your drive&rsquo;s disk number, and <code>&lt;id&gt;</code> is a random name
Etcher gives each helper run.</p>
<h2 id="apfs-drive-that-stops-the-helper-before-the-write">APFS drive that stops the helper before the write<a class="heading-anchor" href="https://looselydecoupled.com/balena-etcher-stuck-starting-macos/#apfs-drive-that-stops-the-helper-before-the-write"
     aria-label="Link to section: APFS drive that stops the helper before the write" data-pagefind-ignore></a>
</h2>
<p>Etcher doesn&rsquo;t write the disk itself. The Electron app launches a separate
privileged helper, the &ldquo;sidecar flasher&rdquo;, and hands it the image and the
device. The <code>CHILD LOG</code> lines after <code>write requested</code> are the helper echoing
that request back. Etcher asks macOS for administrator rights for the helper
itself, which is what the <code>elevated pending</code> and <code>elevated granted</code> lines
record, so <code>sudo</code> adds nothing. Run as <code>sudo</code>, the log is identical.</p>
<p>The log doesn&rsquo;t show why the helper stops. The drive&rsquo;s format does. I
reproduced the hang with a second USB drive, a different ISO and Etcher 2.1.6,
and changed only the format between two runs:</p>
<div class="table-wrap" data-scrollable data-label="Table: Drive format">
  <table>
    <thead>
      <tr><th scope="col">Drive format</th><th scope="col">What Etcher did</th></tr>
    </thead>
    <tbody>
      <tr><td>APFS, GUID partition map</td><td>Stopped at &ldquo;Starting&hellip;&rdquo;; the log ended at <code>Decompress first: true</code></td></tr>
      <tr><td>exFAT, GUID partition map</td><td>Started writing; the next log line was the first progress line</td></tr>
    </tbody>
  </table>
</div>
<p>Erased as exFAT, the same flash continued straight into progress:</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">CHILD LOG: Decompress first: true
</span></span><span class="line"><span class="cl">Flashing 1 device, 0% at 1.60 MB/s (total 1.60 MB/s) eta in 235s with 0 failed devices</span></span></code></pre></div></div>
  </div>
</div><p>That matches the reports on balena&rsquo;s forums.
<a href="https://forums.balena.io/t/etcher-stalls-on-0-flashing-never-starts-fix/375032" rel="external noopener" target="_blank">One thread<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/balena-etcher-stuck-starting-macos/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
states that Etcher doesn&rsquo;t work on an APFS drive and surfaces no error.
<a href="https://forums.balena.io/t/mac-14-8-23j21-and-balena-etcher/374842" rel="external noopener" target="_blank">Another<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/balena-etcher-stuck-starting-macos/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
with this hang was resolved by erasing the drive as exFAT.</p>
<p>A USB drive isn&rsquo;t APFS unless someone made it so. Disk Utility offers APFS
only when you erase the whole device with a GUID partition map, not when you
erase a volume on a drive with the older MBR layout. I don&rsquo;t know what the
first drive was formatted as when it hung. <code>dd</code> wrote the Mint image to it
without an error, so the ISO and the drive weren&rsquo;t at fault.</p>
<!-- style-check: procedure -->
<h2 id="erase-the-usb-drive-as-exfat">Erase the USB drive as exFAT<a class="heading-anchor" href="https://looselydecoupled.com/balena-etcher-stuck-starting-macos/#erase-the-usb-drive-as-exfat"
     aria-label="Link to section: Erase the USB drive as exFAT" data-pagefind-ignore></a>
</h2>
<p>The erase replaces the drive&rsquo;s partition map and every volume on it. Copy off
anything you need from the drive first.</p>
<h3 id="1-find-the-usb-drive">1. Find the USB drive<a class="heading-anchor" href="https://looselydecoupled.com/balena-etcher-stuck-starting-macos/#1-find-the-usb-drive"
     aria-label="Link to section: 1. Find the USB drive" data-pagefind-ignore></a>
</h3>
<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 header that reads <code>external, physical</code> and matches your drive&rsquo;s size.
Its number replaces <code>N</code> in the next step.
An APFS drive also appears a second time, as a <code>synthesized</code> container. Use
the <code>external, physical</code> identifier, not the container&rsquo;s.</p>
<h3 id="2-erase-it">2. Erase it<a class="heading-anchor" href="https://looselydecoupled.com/balena-etcher-stuck-starting-macos/#2-erase-it"
     aria-label="Link to section: 2. Erase it" data-pagefind-ignore></a>
</h3>
<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">force</span> <span class="console__token">/dev/diskN</span>
</span><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><p><code>force</code> matters when Spotlight is indexing the drive. Without it, the erase
can fail with <code>Couldn't unmount disk. : (-69888)</code> and name the process <code>mds</code>.
<code>USB</code> is the new volume name; any name works, because the flash overwrites it.</p>
<h3 id="3-flash-again">3. Flash again<a class="heading-anchor" href="https://looselydecoupled.com/balena-etcher-stuck-starting-macos/#3-flash-again"
     aria-label="Link to section: 3. Flash again" data-pagefind-ignore></a>
</h3>
<p>Select the ISO and the drive in Etcher, and click Flash. Within a few seconds,
the window shows <code>Flashing...</code> with a percentage, and the DevTools log shows
<code>Flashing 1 device</code> lines.</p>
<!-- style-check: end procedure -->
<h2 id="write-the-iso-with-dd-instead">Write the ISO with dd instead<a class="heading-anchor" href="https://looselydecoupled.com/balena-etcher-stuck-starting-macos/#write-the-iso-with-dd-instead"
     aria-label="Link to section: Write the ISO with dd instead" data-pagefind-ignore></a>
</h2>
<p>If Etcher still stops after the erase, <code>dd</code> writes the image without Etcher&rsquo;s
helper. That&rsquo;s how the Mint drive in this article was written. The steps, the
checks that keep <code>dd</code> off the wrong disk, and how to confirm the write are in
<a href="https://looselydecoupled.com/bootable-linux-usb-macos-dd/">Create a bootable Linux USB drive on macOS with dd</a>.</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>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>Reproduction</dt> <dd>balenaEtcher 2.1.6, CHIPSBNK 1 GB USB 2.0, Alpine 3.24.2 x86_64</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>March 2026</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
    <item>
      <title>Back Up a 7 Days to Die Dedicated Server to Amazon S3 with PowerShell</title>
      <link>https://looselydecoupled.com/backup-7-days-to-die-server-s3/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/backup-7-days-to-die-server-s3/</guid>
      <pubDate>Fri, 06 Feb 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>AWS</category>
      <category>S3</category>
      <category>Windows Server</category>
      <category>PowerShell</category>
      <category>Automation</category>
      
      <description>The world lands in S3 every morning as an archive that restores onto a fresh server, and the task&#39;s result says whether that morning&#39;s copy arrived.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>A daily scheduled task runs a PowerShell script as SYSTEM. The script zips
<code>Saves</code> and <code>serverconfig.xml</code>, writes the archive to S3 with
<code>Write-S3Object</code> as <code>STANDARD_IA</code>, and keeps three archives locally and
thirty days in the bucket. Every part is stock: <code>Compress-Archive</code>, AWS
Tools for PowerShell, Task Scheduler.</p>
<p>SYSTEM has its own profile, so install the modules and write the credentials
where it can reach them, not where your own session puts them.</p>

</div>

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

</div>

<p>Windows hides the extension of known file types by default, so <code>invoice.pdf</code>
and <code>invoice.pdf.exe</code> look the same in Explorer. Turning that off is the first
thing to do on any machine you&rsquo;ll administer from the desktop, and the
setting is one DWORD.</p>
<h2 id="where-the-setting-lives">Where the setting lives<a class="heading-anchor" href="https://looselydecoupled.com/show-file-extensions-windows-powershell/#where-the-setting-lives"
     aria-label="Link to section: Where the setting lives" data-pagefind-ignore></a>
</h2>
<div class="code-block" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced
</span></span><span class="line"><span class="cl">    HideFileExt    REG_DWORD    0 = show extensions, 1 = hide</span></span></code></pre></div></div>
  </div>
</div><p>Explorer reads the value when it starts, which is why the answer restarts it:
a write from PowerShell notifies nothing, and open windows stay as they were.
Stopping it with <code>Stop-Process -Name explorer</code> ends the shell process;
Winlogon relaunches it (<code>AutoRestartShell</code>, on by default), so the taskbar is
back within a second or two.</p>
<div class="admonition admonition--warning" role="note">
  <p class="admonition__label">
    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/show-file-extensions-windows-powershell/#i-triangle-alert"/></svg>
    <span>Warning</span>
  </p>
  <p>Elevated, <code>Stop-Process -Name explorer -Force</code> stops every user&rsquo;s Explorer
on the machine, not only yours — on a server with other people signed in
over RDP, that is their desktop vanishing. From an elevated session, stop
only the one in your session:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Get-Process</span> <span class="n">-Name</span> <span class="n">explorer</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Where-Object</span> <span class="n">SessionId</span> <span class="o">-eq</span> <span class="p">(</span><span class="nb">Get-Process</span> <span class="n">-Id</span> <span class="nv">$PID</span><span class="p">).</span><span class="py">SessionId</span> <span class="p">|</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Stop-Process</span> <span class="n">-Force</span></span></span></code></pre></div></div>
  </div>
</div>
</div><!-- style-check: procedure -->
<h2 id="for-every-account-including-ones-that-do-not-exist-yet">For every account, including ones that do not exist yet<a class="heading-anchor" href="https://looselydecoupled.com/show-file-extensions-windows-powershell/#for-every-account-including-ones-that-do-not-exist-yet"
     aria-label="Link to section: For every account, including ones that do not exist yet" data-pagefind-ignore></a>
</h2>
<p><code>HKCU</code> is the current user&rsquo;s hive, so the command above changes one account.
On a server where several administrators sign in, put the same command where
Windows runs it once for each user at their next sign-in, before Explorer
starts. That is Active Setup, and it is a key under <code>HKLM</code>, so it is set with
the registry provider from an elevated session:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$stub</span> <span class="p">=</span> <span class="s1">&#39;HKLM:\SOFTWARE\Microsoft\Active Setup\Installed Components\ShowFileExtensions&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">New-Item</span> <span class="n">-Path</span> <span class="nv">$stub</span> <span class="n">-Force</span> <span class="p">|</span> <span class="nb">Out-Null</span>
</span></span><span class="line"><span class="cl"><span class="nb">Set-ItemProperty</span> <span class="n">-Path</span> <span class="nv">$stub</span> <span class="n">-Name</span> <span class="s1">&#39;(Default)&#39;</span>   <span class="n">-Value</span> <span class="s1">&#39;Show file extensions&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Set-ItemProperty</span> <span class="n">-Path</span> <span class="nv">$stub</span> <span class="n">-Name</span> <span class="s1">&#39;IsInstalled&#39;</span> <span class="n">-Value</span> <span class="mf">1</span> <span class="n">-Type</span> <span class="n">DWord</span>
</span></span><span class="line"><span class="cl"><span class="nb">Set-ItemProperty</span> <span class="n">-Path</span> <span class="nv">$stub</span> <span class="n">-Name</span> <span class="s1">&#39;Version&#39;</span>     <span class="n">-Value</span> <span class="s1">&#39;1&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$advanced</span> <span class="p">=</span> <span class="s1">&#39;HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">$command</span> <span class="p">=</span> <span class="s2">&#34;Set-ItemProperty -Path &#39;</span><span class="nv">$advanced</span><span class="s2">&#39; -Name HideFileExt -Value 0&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">Set-ItemProperty</span> <span class="n">-Path</span> <span class="nv">$stub</span> <span class="n">-Name</span> <span class="s1">&#39;StubPath&#39;</span> <span class="n">-Value</span> <span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;powershell.exe -NoProfile -WindowStyle Hidden -Command &#34;&#39;</span> <span class="p">+</span> <span class="nv">$command</span> <span class="p">+</span> <span class="s1">&#39;&#34;&#39;</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span></span></span></code></pre></div></div>
  </div>
</div><p>At each sign-in Windows compares <code>Version</code> here against the copy under that
user&rsquo;s <code>HKCU\Software\Microsoft\Active Setup\Installed Components</code>. When the
user has none, or an older one, it runs <code>StubPath</code> as that user and records
the version. So the command runs once per account: existing accounts at their
next sign-in, new accounts at their first. It runs while the shell is still
starting, before the desktop is shown, so the first Explorer window a user
opens already shows extensions. To run it again for everyone, raise <code>Version</code>;
to stop it running for anyone new, delete the key under <code>HKLM</code>. Users who
already ran it keep the setting either way.</p>
<p>A console window can flash for a moment at sign-in while the stub runs;
<code>-WindowStyle Hidden</code> hides it as soon as PowerShell has started, not before.</p>
<p>This is the whole mechanism Windows uses to seed per-user settings from a
machine-wide install. It is the one route to &ldquo;every account&rdquo; that needs no
hive loading and no tool outside PowerShell. Group Policy Preferences does the
same job on a domain-joined machine.</p>
<!-- style-check: end procedure -->
<h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/show-file-extensions-windows-powershell/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="value-is-0-and-extensions-are-still-hidden">Value is <code>0</code> and extensions are still hidden<a class="heading-anchor" href="https://looselydecoupled.com/show-file-extensions-windows-powershell/#value-is-0-and-extensions-are-still-hidden"
     aria-label="Link to section: Value is 0 and extensions are still hidden" data-pagefind-ignore></a>
</h3>
<p>You ran the command elevated as a different account. An elevated PowerShell
opened with &ldquo;Run as administrator&rdquo; and a second set of credentials writes to
that account&rsquo;s <code>HKCU</code>, not yours. Run it unelevated in your own session.</p>
<h3 id="taskbar-didnt-come-back-after-stop-process">Taskbar didn&rsquo;t come back after <code>Stop-Process</code><a class="heading-anchor" href="https://looselydecoupled.com/show-file-extensions-windows-powershell/#taskbar-didnt-come-back-after-stop-process"
     aria-label="Link to section: Taskbar didn’t come back after Stop-Process" data-pagefind-ignore></a>
</h3>
<p><code>AutoRestartShell</code> is off on that machine, or the process was ended from
outside the session it belonged to. Start it by hand:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nb">Start-Process</span> <span class="n">explorer</span></span></span></code></pre></div></div>
  </div>
</div><p>On Server Core there&rsquo;s no Explorer to stop and no setting to change;
<code>Stop-Process</code> reports that no such process exists.</p>
<h3 id="extensions-still-hidden-for-another-user">Extensions still hidden for another user<a class="heading-anchor" href="https://looselydecoupled.com/show-file-extensions-windows-powershell/#extensions-still-hidden-for-another-user"
     aria-label="Link to section: Extensions still hidden for another user" data-pagefind-ignore></a>
</h3>
<p>The user hasn&rsquo;t signed out and in since it was written; Active Setup runs at
sign-in and nowhere else. Or the stub ran under a policy that blocks
<code>powershell.exe</code> for that user. Check
<code>HKCU\Software\Microsoft\Active Setup\Installed Components\ShowFileExtensions</code>
in their hive: present means the stub was launched, absent means it wasn&rsquo;t.</p>
]]></content:encoded>
    </item>
    
  </channel>
</rss>
