<?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>AWS · Loosely Decoupled</title>
    <link>https://looselydecoupled.com/topics/aws/</link>
    <description>Using AWS from PowerShell: the AWS.Tools modules where they differ from the CLI, EC2 AMIs, and the S3 cmdlets whose defaults surprise.</description>
    <language>en-us</language>
    
    <copyright>© 2026 Emmanuel Tsouris</copyright>
    <lastBuildDate>Sun, 04 Oct 2026 00:00:00 +0000</lastBuildDate>
    <atom:link href="https://looselydecoupled.com/topics/aws/index.xml" rel="self" type="application/rss+xml" />
    
    <item>
      <title>Test AccessDenied in AWS PowerShell with a Role That Has No Permissions</title>
      <link>https://looselydecoupled.com/aws-access-denied-test-empty-iam-role/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/aws-access-denied-test-empty-iam-role/</guid>
      <pubDate>Sun, 27 Sep 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>AWS</category>
      <category>PowerShell</category>
      
      <description>Watch your script handle a real AccessDenied, raised with a throwaway role&#39;s temporary keys, without changing your own permissions or writing any data.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Create a role with no permissions that your account can assume, switch the
session to its temporary keys, and run the script. Every call it makes that needs a
permission is denied for real:</p>
<div class="code-block" data-copyable data-copy-label="PowerShell">
  <div class="code-block__head"><span class="code-block__lang">PowerShell</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="nv">$identity</span>  <span class="p">=</span> <span class="nb">Get-STSCallerIdentity</span>
</span></span><span class="line"><span class="cl"><span class="nv">$partition</span> <span class="p">=</span> <span class="nv">$identity</span><span class="p">.</span><span class="py">Arn</span><span class="p">.</span><span class="py">Split</span><span class="p">(</span><span class="s1">&#39;:&#39;</span><span class="p">)[</span><span class="mf">1</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nv">$account</span>   <span class="p">=</span> <span class="nv">$identity</span><span class="p">.</span><span class="py">Account</span>
</span></span><span class="line"><span class="cl"><span class="nv">$trust</span> <span class="p">=</span> <span class="s1">&#39;{&#34;Version&#34;:&#34;2012-10-17&#34;,&#34;Statement&#34;:[{&#34;Effect&#34;:&#34;Allow&#34;,&#34;Principal&#34;:{&#34;AWS&#34;:&#34;arn:&#39;</span> <span class="p">+</span> <span class="nv">$partition</span> <span class="p">+</span> <span class="s1">&#39;:iam::&#39;</span> <span class="p">+</span> <span class="nv">$account</span> <span class="p">+</span> <span class="s1">&#39;:root&#34;},&#34;Action&#34;:&#34;sts:AssumeRole&#34;}]}&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nb">New-IAMRole</span> <span class="n">-RoleName</span> <span class="n">denytest</span> <span class="n">-AssumeRolePolicyDocument</span> <span class="nv">$trust</span>
</span></span><span class="line"><span class="cl"><span class="nv">$previous</span> <span class="p">=</span> <span class="nb">Get-AWSCredential</span>
</span></span><span class="line"><span class="cl"><span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="c"># A new role took about eight seconds to become assumable in testing.</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Start-Sleep</span> <span class="n">-Seconds</span> <span class="mf">8</span>
</span></span><span class="line"><span class="cl">    <span class="nv">$delay</span> <span class="p">=</span> <span class="mf">1</span>
</span></span><span class="line"><span class="cl">    <span class="k">foreach</span> <span class="p">(</span><span class="nv">$attempt</span> <span class="k">in</span> <span class="mf">1</span><span class="p">.</span><span class="mf">.6</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nv">$keys</span> <span class="p">=</span> <span class="p">(</span><span class="nb">Use-STSRole</span> <span class="n">-RoleArn</span> <span class="s2">&#34;arn:</span><span class="nv">${partition}</span><span class="s2">:iam::</span><span class="nv">${account}</span><span class="s2">:role/denytest&#34;</span> <span class="n">-RoleSessionName</span> <span class="n">denytest</span> <span class="n">-ErrorAction</span> <span class="n">Stop</span><span class="p">).</span><span class="py">Credentials</span>
</span></span><span class="line"><span class="cl">            <span class="k">break</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="c"># Retry only the denial a new role gives; rethrow anything else, and the last denial.</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="nv">$_</span><span class="p">.</span><span class="py">Exception</span><span class="p">.</span><span class="py">InnerException</span><span class="p">.</span><span class="py">ErrorCode</span> <span class="o">-ne</span> <span class="s1">&#39;AccessDenied&#39;</span> <span class="o">-or</span> <span class="nv">$attempt</span> <span class="o">-eq</span> <span class="mf">6</span><span class="p">)</span> <span class="p">{</span> <span class="k">throw</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="nb">Start-Sleep</span> <span class="n">-Seconds</span> <span class="nv">$delay</span>
</span></span><span class="line"><span class="cl">            <span class="nv">$delay</span> <span class="p">*=</span> <span class="mf">2</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Set-AWSCredential</span> <span class="n">-Credential</span> <span class="nv">$keys</span>
</span></span><span class="line"><span class="cl">    <span class="p">./</span><span class="nb">Your-Script</span><span class="p">.</span><span class="py">ps1</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">finally</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="c"># Restore your own credentials first; deleting the role needs them.</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nv">$previous</span><span class="p">)</span> <span class="p">{</span> <span class="nb">Set-AWSCredential</span> <span class="n">-Credential</span> <span class="nv">$previous</span> <span class="p">}</span> <span class="k">else</span> <span class="p">{</span> <span class="nb">Clear-AWSCredential</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="nb">Remove-IAMRole</span> <span class="n">-RoleName</span> <span class="n">denytest</span> <span class="n">-Force</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p>AWS.Tools wraps the AWS error in an <code>InvalidOperationException</code>; the
<code>AccessDenied</code> code is on <code>$_.Exception.InnerException</code>, not on
<code>$_.Exception</code>.</p>

</div>

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

</div>

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

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

</div>

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

</div>

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