<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: CloudSkill Consulting LLC</title>
    <description>The latest articles on DEV Community by CloudSkill Consulting LLC (@cloudskillconsultingllc).</description>
    <link>https://dev.to/cloudskillconsultingllc</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4187968%2F44d15f25-59b1-46bd-83ab-244f0c9dadab.png</url>
      <title>DEV Community: CloudSkill Consulting LLC</title>
      <link>https://dev.to/cloudskillconsultingllc</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cloudskillconsultingllc"/>
    <language>en</language>
    <item>
      <title>A Lambda That Writes Back to the Bucket That Triggers It Can Bill You in a Loop. Here's a Safe Setup</title>
      <dc:creator>CloudSkill Consulting LLC</dc:creator>
      <pubDate>Thu, 29 Oct 2026 02:25:53 +0000</pubDate>
      <link>https://dev.to/cloudskillconsultingllc/a-lambda-that-writes-back-to-the-bucket-that-triggers-it-can-bill-you-in-a-loop-heres-a-safe-setup-3i14</link>
      <guid>https://dev.to/cloudskillconsultingllc/a-lambda-that-writes-back-to-the-bucket-that-triggers-it-can-bill-you-in-a-loop-heres-a-safe-setup-3i14</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;As of October 29, 2026, this post sums up the official pages linked here.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;A 2019 post on Reddit's r/aws describes a bill nobody wants. A team had a Lambda function that listened for new files in an S3 bucket. It read each file, changed it, and saved the result to S3. But the source bucket and the output bucket were set to the &lt;strong&gt;same&lt;/strong&gt; bucket. So every saved result was a new file, and every new file started the function again.&lt;/p&gt;

&lt;p&gt;It ran like that for about three and a half days, over a weekend, in a new account with no cost alerts yet. The bill was over $6,800, mostly for S3 PUT requests. The team only found out when someone at AWS asked them about the traffic.&lt;/p&gt;

&lt;p&gt;This is a common trap. AWS even warns about it in the Lambda docs. In this post we explain the root cause, show a setup that can't loop, add guard rails in case someone breaks it later, and show how to test all of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause: the output matches the trigger
&lt;/h2&gt;

&lt;p&gt;An S3 trigger is a rule on the bucket. It says, "When an object is created, call this function." AWS's Lambda docs put it plainly: if the function uploads an object to the same bucket, it "indirectly triggers itself."&lt;/p&gt;

&lt;p&gt;Here is the loop:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A user uploads &lt;code&gt;orders.csv&lt;/code&gt;. S3 calls the function.&lt;/li&gt;
&lt;li&gt;The function saves &lt;code&gt;orders.json&lt;/code&gt; to the same bucket.&lt;/li&gt;
&lt;li&gt;That is a new object, so S3 calls the function again.&lt;/li&gt;
&lt;li&gt;The function saves another file. Go to step 3.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Three things make it costly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Each turn is billed.&lt;/strong&gt; You pay for the Lambda run and for the S3 PUT. The Reddit bill was mostly PUT requests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lambda scales out.&lt;/strong&gt; Each new file is its own event, so many copies can run at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It can be quiet.&lt;/strong&gt; If the function overwrites the same key each time, the file count doesn't grow. Nothing looks wrong in the bucket.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A second, smaller bug often hides here too. The object key in an S3 event is &lt;strong&gt;URL encoded&lt;/strong&gt;. AWS's example: &lt;code&gt;red flower.jpg&lt;/code&gt; arrives as &lt;code&gt;red+flower.jpg&lt;/code&gt;. If your code uses the raw key, it can't read the file, the run fails, and S3's async retry calls the function again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doesn't AWS stop loops now?
&lt;/h2&gt;

&lt;p&gt;Partly. Lambda has &lt;strong&gt;recursive loop detection&lt;/strong&gt;, and it is on by default. This came after that post. Today the docs list Amazon S3 as a supported service, with SQS, SNS and EventBridge custom buses. If one triggering event calls your function about 16 times in a chain, Lambda stops the next call and notifies you.&lt;/p&gt;

&lt;p&gt;That helps a lot, but don't treat it as the fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It only works if the function uses a &lt;strong&gt;supported AWS SDK version&lt;/strong&gt; (for Python, boto3 1.24.46 or later). Check what your runtime or package ships.&lt;/li&gt;
&lt;li&gt;It allows about &lt;strong&gt;16 runs per chain&lt;/strong&gt; before it stops. Many uploads at once means many chains.&lt;/li&gt;
&lt;li&gt;Loops through services it doesn't track (the docs name DynamoDB) &lt;strong&gt;are not stopped&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Anyone can switch a function to &lt;code&gt;Allow&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The email and Health Dashboard alerts can take up to 3.5 hours.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So we design the loop out, then keep detection on as a safety net.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: inputs and outputs never overlap
&lt;/h2&gt;

&lt;p&gt;AWS's docs give two safe options: &lt;strong&gt;use two buckets&lt;/strong&gt;, or &lt;strong&gt;trigger only on a prefix used for incoming objects&lt;/strong&gt;. Two buckets is the simplest and the safest. When you must use one bucket, set it up so the function's output can never match the trigger. We use four layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Trigger filter.&lt;/strong&gt; Fire only for &lt;code&gt;incoming/*.csv&lt;/code&gt;. Write output to &lt;code&gt;processed/*.jsonl&lt;/code&gt;. The filter never matches the output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IAM.&lt;/strong&gt; The function may read only &lt;code&gt;incoming/*&lt;/code&gt; and write only &lt;code&gt;processed/*&lt;/code&gt;. Even buggy code can't write back under the trigger prefix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code guards.&lt;/strong&gt; Refuse to start if the two prefixes overlap. Skip any event outside &lt;code&gt;incoming/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Limits and alarms.&lt;/strong&gt; Cap concurrency, keep loop detection on, and alarm on spikes.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The handler
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# handler.py - Lambda (Python 3.12). Triggered by S3 ObjectCreated on incoming/*.csv.
# Reads the CSV, writes JSON Lines under processed/. Never writes under incoming/.
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;csv&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;urllib.parse&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;unquote_plus&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;

&lt;span class="n"&gt;IN_PREFIX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;IN_PREFIX&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;incoming/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;OUT_PREFIX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OUT_PREFIX&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;processed/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;s3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;output_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;in_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;incoming/2026/orders.csv -&amp;gt; processed/2026/orders.jsonl&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;rest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;in_key&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IN_PREFIX&lt;/span&gt;&lt;span class="p"&gt;):]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;rest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;OUT_PREFIX&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rest&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.jsonl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check_config&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Guard 1: the two prefixes must not overlap, or our own writes match the trigger.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;OUT_PREFIX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IN_PREFIX&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;IN_PREFIX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OUT_PREFIX&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OUT_PREFIX &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;OUT_PREFIX&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="s"&gt; overlaps IN_PREFIX &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;IN_PREFIX&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;check_config&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;written&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Records&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]):&lt;/span&gt;
        &lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bucket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="c1"&gt;# Keys in S3 events are URL-encoded: a space arrives as "+".
&lt;/span&gt;        &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;unquote_plus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

        &lt;span class="c1"&gt;# Guard 2: ignore anything outside the input prefix, even if a
&lt;/span&gt;        &lt;span class="c1"&gt;# trigger is set up wrong later.
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IN_PREFIX&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;skip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;outside IN_PREFIX&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}))&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;

        &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;csv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DictReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;StringIO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;output_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                      &lt;span class="n"&gt;ContentType&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/x-ndjson&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;written&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;written&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;written&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why &lt;code&gt;processed/&lt;/code&gt; and not &lt;code&gt;incoming/processed/&lt;/code&gt;? Because &lt;code&gt;incoming/processed/x.jsonl&lt;/code&gt; still starts with &lt;code&gt;incoming/&lt;/code&gt;. The trigger would match it, and the loop is back. Guard 1 catches that mistake before any write.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Terraform
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;required_providers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;aws&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"hashicorp/aws"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"~&amp;gt; 6.0"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;archive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"hashicorp/archive"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"~&amp;gt; 2.0"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"bucket_name"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"alarm_topic_arn"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SNS topic that gets the alarm emails"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;in_prefix&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"incoming/"&lt;/span&gt;
  &lt;span class="nx"&gt;out_prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"processed/"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_s3_bucket"&lt;/span&gt; &lt;span class="s2"&gt;"data"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bucket_name&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"archive_file"&lt;/span&gt; &lt;span class="s2"&gt;"fn"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"zip"&lt;/span&gt;
  &lt;span class="nx"&gt;source_file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${path.module}/handler.py"&lt;/span&gt;
  &lt;span class="nx"&gt;output_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${path.module}/build/handler.zip"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role"&lt;/span&gt; &lt;span class="s2"&gt;"fn"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"csv-to-jsonl"&lt;/span&gt;
  &lt;span class="nx"&gt;assume_role_policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;Version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;
    &lt;span class="nx"&gt;Statement&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="nx"&gt;Effect&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
      &lt;span class="nx"&gt;Principal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Service&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"lambda.amazonaws.com"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nx"&gt;Action&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sts:AssumeRole"&lt;/span&gt;
    &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Read only from incoming/, write only to processed/.&lt;/span&gt;
&lt;span class="c1"&gt;# Even a code bug can't put an object back under the trigger prefix.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role_policy"&lt;/span&gt; &lt;span class="s2"&gt;"fn"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;Version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;
    &lt;span class="nx"&gt;Statement&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;Effect&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
        &lt;span class="nx"&gt;Action&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"s3:GetObject"&lt;/span&gt;
        &lt;span class="nx"&gt;Resource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${aws_s3_bucket.data.arn}/${local.in_prefix}*"&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;Effect&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
        &lt;span class="nx"&gt;Action&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"s3:PutObject"&lt;/span&gt;
        &lt;span class="nx"&gt;Resource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${aws_s3_bucket.data.arn}/${local.out_prefix}*"&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;Effect&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
        &lt;span class="nx"&gt;Action&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"logs:CreateLogGroup"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"logs:CreateLogStream"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"logs:PutLogEvents"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="nx"&gt;Resource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_lambda_function"&lt;/span&gt; &lt;span class="s2"&gt;"fn"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;function_name&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"csv-to-jsonl"&lt;/span&gt;
  &lt;span class="nx"&gt;role&lt;/span&gt;             &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="nx"&gt;runtime&lt;/span&gt;          &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"python3.12"&lt;/span&gt;
  &lt;span class="nx"&gt;handler&lt;/span&gt;          &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"handler.handler"&lt;/span&gt;
  &lt;span class="nx"&gt;filename&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;archive_file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output_path&lt;/span&gt;
  &lt;span class="nx"&gt;source_code_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;archive_file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output_base64sha256&lt;/span&gt;
  &lt;span class="nx"&gt;timeout&lt;/span&gt;          &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;

  &lt;span class="c1"&gt;# A ceiling, not a fix: a loop can't fan out past 5 copies at once.&lt;/span&gt;
  &lt;span class="nx"&gt;reserved_concurrent_executions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;

  &lt;span class="nx"&gt;environment&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;variables&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;IN_PREFIX&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;in_prefix&lt;/span&gt;
      &lt;span class="nx"&gt;OUT_PREFIX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;out_prefix&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Keep Lambda's loop detection on. "Terminate" is the default; writing it down&lt;/span&gt;
&lt;span class="c1"&gt;# stops someone from quietly switching it to "Allow".&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_lambda_function_recursion_config"&lt;/span&gt; &lt;span class="s2"&gt;"fn"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;function_name&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_lambda_function&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;function_name&lt;/span&gt;
  &lt;span class="nx"&gt;recursive_loop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Terminate"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_lambda_permission"&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;statement_id&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AllowS3Invoke"&lt;/span&gt;
  &lt;span class="nx"&gt;action&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"lambda:InvokeFunction"&lt;/span&gt;
  &lt;span class="nx"&gt;function_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_lambda_function&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;function_name&lt;/span&gt;
  &lt;span class="nx"&gt;principal&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"s3.amazonaws.com"&lt;/span&gt;
  &lt;span class="nx"&gt;source_arn&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# The real fix: the trigger only fires for incoming/*.csv.&lt;/span&gt;
&lt;span class="c1"&gt;# Output goes to processed/*.jsonl, which this filter never matches.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_s3_bucket_notification"&lt;/span&gt; &lt;span class="s2"&gt;"data"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;

  &lt;span class="nx"&gt;lambda_function&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;lambda_function_arn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_lambda_function&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
    &lt;span class="nx"&gt;events&lt;/span&gt;              &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"s3:ObjectCreated:*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;filter_prefix&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;in_prefix&lt;/span&gt;
    &lt;span class="nx"&gt;filter_suffix&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;".csv"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;depends_on&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_lambda_permission&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Alarm 1: Lambda stopped a loop.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudwatch_metric_alarm"&lt;/span&gt; &lt;span class="s2"&gt;"loop_dropped"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;alarm_name&lt;/span&gt;          &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"csv-to-jsonl-recursive-loop"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace&lt;/span&gt;           &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AWS/Lambda"&lt;/span&gt;
  &lt;span class="nx"&gt;metric_name&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"RecursiveInvocationsDropped"&lt;/span&gt;
  &lt;span class="nx"&gt;dimensions&lt;/span&gt;          &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;FunctionName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_lambda_function&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;function_name&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;statistic&lt;/span&gt;           &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Sum"&lt;/span&gt;
  &lt;span class="nx"&gt;period&lt;/span&gt;              &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;
  &lt;span class="nx"&gt;evaluation_periods&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="nx"&gt;threshold&lt;/span&gt;           &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="nx"&gt;comparison_operator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"GreaterThanThreshold"&lt;/span&gt;
  &lt;span class="nx"&gt;treat_missing_data&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"notBreaching"&lt;/span&gt;
  &lt;span class="nx"&gt;alarm_actions&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alarm_topic_arn&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Alarm 2: far more runs than normal, for loops detection can't see.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudwatch_metric_alarm"&lt;/span&gt; &lt;span class="s2"&gt;"invocation_spike"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;alarm_name&lt;/span&gt;          &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"csv-to-jsonl-invocation-spike"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace&lt;/span&gt;           &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AWS/Lambda"&lt;/span&gt;
  &lt;span class="nx"&gt;metric_name&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Invocations"&lt;/span&gt;
  &lt;span class="nx"&gt;dimensions&lt;/span&gt;          &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;FunctionName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_lambda_function&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;function_name&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;statistic&lt;/span&gt;           &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Sum"&lt;/span&gt;
  &lt;span class="nx"&gt;period&lt;/span&gt;              &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;
  &lt;span class="nx"&gt;evaluation_periods&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
  &lt;span class="nx"&gt;threshold&lt;/span&gt;           &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="c1"&gt;# set from your normal upload rate&lt;/span&gt;
  &lt;span class="nx"&gt;comparison_operator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"GreaterThanThreshold"&lt;/span&gt;
  &lt;span class="nx"&gt;treat_missing_data&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"notBreaching"&lt;/span&gt;
  &lt;span class="nx"&gt;alarm_actions&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alarm_topic_arn&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;S3 filters don't take wildcards. &lt;code&gt;filter_prefix = "incoming/"&lt;/code&gt; already means "everything under &lt;code&gt;incoming/&lt;/code&gt;".&lt;/li&gt;
&lt;li&gt;S3 also refuses two rules on the same event type with overlapping prefixes or suffixes. That stops a second rule from quietly covering &lt;code&gt;processed/&lt;/code&gt;, but it won't stop a single rule with no filter at all. That's why the IAM and code guards are there.&lt;/li&gt;
&lt;li&gt;The alarm threshold of 500 runs in 5 minutes is a placeholder. Set it from your real upload rate.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two buckets is still simpler.&lt;/strong&gt; One bucket plus prefixes means three things must agree: the filter, the IAM policy and the code. Two buckets removes the loop by design. Pick one bucket only when you have a reason, such as one lifecycle or one set of access rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can't keep the same key.&lt;/strong&gt; With prefixes, the output has a new key. If downstream tools need the processed file at the &lt;strong&gt;same&lt;/strong&gt; key, AWS's blog post on this topic covers patterns that use object metadata or a DynamoDB table. Each costs a second Lambda run per object.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reserved concurrency slows real bursts too.&lt;/strong&gt; A cap of 5 limits the damage from a loop. It also means a big honest upload waits in line. S3 calls Lambda asynchronously, so events queue and retry instead of failing at once, but they can still be delayed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detection needs a modern SDK.&lt;/strong&gt; If you bundle an old boto3, loop detection won't see your calls. Check the version in your deployment package.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The spike alarm needs tuning.&lt;/strong&gt; Too low and it pages on busy days. Too high and a loop runs for a while first. Also add an AWS Budgets alert or AWS Cost Anomaly Detection for the account, as the Lambda docs suggest. The Reddit account had none.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to verify the fix
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Test the logic offline.&lt;/strong&gt; This test fakes boto3, so it needs no AWS account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# test_handler.py - run with: python3 test_handler.py  (no AWS account needed)
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt;

&lt;span class="c1"&gt;# Fake boto3 so the test runs offline. It records every put_object call.
&lt;/span&gt;&lt;span class="n"&gt;puts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FakeS3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SimpleNamespace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id,total&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;1,9.50&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;2,3.00&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;put_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kw&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;puts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kw&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;modules&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;boto3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SimpleNamespace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;FakeS3&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Records&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bucket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;demo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}}}]}&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;

&lt;span class="c1"&gt;# 1. A normal file is written under processed/, never under incoming/.
&lt;/span&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;incoming/2026/orders.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;written&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;processed/2026/orders.jsonl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
&lt;span class="c1"&gt;# 2. URL-encoded key ("+" means space) is decoded before use.
&lt;/span&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;incoming/q3+report.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;written&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;processed/q3 report.jsonl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
&lt;span class="c1"&gt;# 3. Our own output arriving as an event (bad trigger) is skipped: no new write.
&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;puts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;processed/2026/orders.jsonl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;written&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]}&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;puts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt;
&lt;span class="c1"&gt;# 4. No write ever lands under the trigger prefix.
&lt;/span&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;incoming/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;puts&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;puts&lt;/span&gt;
&lt;span class="c1"&gt;# 5. Overlapping prefixes are refused before any write.
&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OUT_PREFIX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;incoming/out/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;incoming/a.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;AssertionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;overlap not caught&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;RuntimeError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok  overlap refused:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok  writes:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;puts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5 checks passed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On our box all 5 checks pass.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Check the Terraform.&lt;/strong&gt; &lt;code&gt;terraform fmt -check&lt;/code&gt;, &lt;code&gt;terraform validate&lt;/code&gt; and &lt;code&gt;terraform plan&lt;/code&gt; should pass. The plan should show 9 resources, with &lt;code&gt;filter_prefix = "incoming/"&lt;/code&gt;, &lt;code&gt;filter_suffix = ".csv"&lt;/code&gt;, &lt;code&gt;reserved_concurrent_executions = 5&lt;/code&gt; and &lt;code&gt;recursive_loop = "Terminate"&lt;/code&gt;. Ours passed on Terraform v1.9.8 with AWS provider v6.70.0.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Read the live settings back.&lt;/strong&gt; After &lt;code&gt;apply&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws s3api get-bucket-notification-configuration &lt;span class="nt"&gt;--bucket&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BUCKET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# expect one LambdaFunctionConfiguration with Prefix incoming/ and Suffix .csv&lt;/span&gt;

aws lambda get-function-recursion-config &lt;span class="nt"&gt;--function-name&lt;/span&gt; csv-to-jsonl
&lt;span class="c"&gt;# expect "RecursiveLoop": "Terminate"&lt;/span&gt;

aws lambda get-function-concurrency &lt;span class="nt"&gt;--function-name&lt;/span&gt; csv-to-jsonl
&lt;span class="c"&gt;# expect "ReservedConcurrentExecutions": 5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Upload one file and count the runs.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'id,total\n1,9.50\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"q3 report.csv"&lt;/span&gt;
aws s3 &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"q3 report.csv"&lt;/span&gt; &lt;span class="s2"&gt;"s3://&lt;/span&gt;&lt;span class="nv"&gt;$BUCKET&lt;/span&gt;&lt;span class="s2"&gt;/incoming/q3 report.csv"&lt;/span&gt;
&lt;span class="nb"&gt;sleep &lt;/span&gt;60
aws s3 &lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="s2"&gt;"s3://&lt;/span&gt;&lt;span class="nv"&gt;$BUCKET&lt;/span&gt;&lt;span class="s2"&gt;/processed/"&lt;/span&gt;
&lt;span class="c"&gt;# expect: exactly one new file, "q3 report.jsonl"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then check the &lt;code&gt;Invocations&lt;/code&gt; metric for the function in CloudWatch for those minutes. You want &lt;strong&gt;1&lt;/strong&gt;, not 2, and not a climbing line. Also check that &lt;code&gt;RecursiveInvocationsDropped&lt;/code&gt; stays at 0.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Prove the IAM layer.&lt;/strong&gt; Using the function's role, try to write under the trigger prefix. Lambda's console test, or a one-off script run under that role, both work. The call &lt;code&gt;put_object(Key="incoming/test.csv")&lt;/code&gt; should fail with &lt;code&gt;AccessDenied&lt;/code&gt;. If it succeeds, the policy is too wide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you ship
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Output goes to a second bucket, or to a prefix the trigger can't match&lt;/li&gt;
&lt;li&gt;[ ] The output prefix doesn't start with the input prefix&lt;/li&gt;
&lt;li&gt;[ ] The role can write only to the output prefix&lt;/li&gt;
&lt;li&gt;[ ] The code decodes keys with &lt;code&gt;unquote_plus&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Reserved concurrency is set while you test&lt;/li&gt;
&lt;li&gt;[ ] Recursive loop detection is &lt;code&gt;Terminate&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Alarms on &lt;code&gt;RecursiveInvocationsDropped&lt;/code&gt; and &lt;code&gt;Invocations&lt;/code&gt;, plus a budget alert&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you searched for CloudSkill Consulting LLC software engineering, that's us. We wrote this for teams that run S3 event pipelines and want the first bill to look normal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Docs checked (October 29, 2026)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/lambda/latest/dg/with-s3.html" rel="noopener noreferrer"&gt;Process Amazon S3 event notifications with Lambda&lt;/a&gt; (the same-bucket warning)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/lambda/latest/dg/invocation-recursion.html" rel="noopener noreferrer"&gt;Use Lambda recursive loop detection to prevent infinite loops&lt;/a&gt; (supported services and SDKs, the ~16-call limit, alerts, &lt;code&gt;RecursiveInvocationsDropped&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/lambda/latest/api/API_PutFunctionRecursionConfig.html" rel="noopener noreferrer"&gt;PutFunctionRecursionConfig&lt;/a&gt; (&lt;code&gt;Allow&lt;/code&gt; vs &lt;code&gt;Terminate&lt;/code&gt;, default &lt;code&gt;Terminate&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/notification-how-to-filtering.html" rel="noopener noreferrer"&gt;Configuring event notifications using object key name filtering&lt;/a&gt; (no wildcards, no overlapping rules)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/notification-content-structure.html" rel="noopener noreferrer"&gt;Event message structure&lt;/a&gt; (object keys are URL encoded)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://aws.amazon.com/blogs/compute/avoiding-recursive-invocation-with-amazon-s3-and-aws-lambda/" rel="noopener noreferrer"&gt;AWS Compute Blog: Avoiding recursive invocation with Amazon S3 and AWS Lambda&lt;/a&gt; (two-bucket, prefix, metadata and DynamoDB patterns; reserved concurrency)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function_recursion_config" rel="noopener noreferrer"&gt;Terraform AWS provider: aws_lambda_function_recursion_config&lt;/a&gt; and &lt;a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_notification" rel="noopener noreferrer"&gt;aws_s3_bucket_notification&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Spotted an AWS default or limit that changed since we wrote this? Drop a line to &lt;a href="mailto:info@cloudskillconsulting.com"&gt;info@cloudskillconsulting.com&lt;/a&gt; so the post stays accurate.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;CloudSkill Consulting LLC helps teams with fixed-fee cloud projects, FinOps reviews and IT staffing. Learn more at &lt;a href="https://www.cloudskillconsulting.com" rel="noopener noreferrer"&gt;cloudskillconsulting.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>serverless</category>
      <category>finops</category>
      <category>terraform</category>
    </item>
    <item>
      <title>Refresh a React Route on S3 + CloudFront and Get AccessDenied? Fix It Without Hiding Your API 404s</title>
      <dc:creator>CloudSkill Consulting LLC</dc:creator>
      <pubDate>Mon, 26 Oct 2026 16:52:51 +0000</pubDate>
      <link>https://dev.to/cloudskillconsultingllc/refresh-a-react-route-on-s3-cloudfront-and-get-accessdenied-fix-it-without-hiding-your-api-404s-355m</link>
      <guid>https://dev.to/cloudskillconsultingllc/refresh-a-react-route-on-s3-cloudfront-and-get-accessdenied-fix-it-without-hiding-your-api-404s-355m</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;As of October 27, 2026, this post sums up the official pages linked here.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Someone on Reddit had a React app built with create-react-app and react-router. They put the build in an S3 bucket and served it through CloudFront. Moving around inside the app was fine. But if they pasted a deeper link like &lt;code&gt;/some-path&lt;/code&gt; into a new tab, or pressed refresh on that page, they got an XML page that said &lt;code&gt;AccessDenied&lt;/code&gt;. They asked how to send every path to &lt;code&gt;index.html&lt;/code&gt; while still letting the app see the real path.&lt;/p&gt;

&lt;p&gt;Plenty of teams hit this with single-page apps (SPAs) on AWS. The quick answer most people get is "add a custom error page." That answer works, but it has a side effect that often shows up weeks later. This post explains the root cause, why the quick answer can hurt, and a narrower fix with tested code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause: two kinds of navigation
&lt;/h2&gt;

&lt;p&gt;A React app with &lt;code&gt;BrowserRouter&lt;/code&gt; handles routes &lt;strong&gt;in the browser&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You open &lt;code&gt;https://example.com/&lt;/code&gt;. CloudFront returns &lt;code&gt;index.html&lt;/code&gt;, and the JavaScript loads.&lt;/li&gt;
&lt;li&gt;You click a link to &lt;code&gt;/dashboard&lt;/code&gt;. The router changes the address bar with the History API and draws the new screen. &lt;strong&gt;No request goes to CloudFront for &lt;code&gt;/dashboard&lt;/code&gt;.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;You press refresh. Now the browser really does send &lt;code&gt;GET /dashboard&lt;/code&gt; to CloudFront.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;CloudFront passes that request to the S3 origin, and S3 looks for an object with the key &lt;code&gt;dashboard&lt;/code&gt;. There isn't one. Your build only has &lt;code&gt;index.html&lt;/code&gt;, a few files under &lt;code&gt;assets/&lt;/code&gt; or &lt;code&gt;static/&lt;/code&gt;, and some icons.&lt;/p&gt;

&lt;p&gt;Why &lt;code&gt;403&lt;/code&gt; and not &lt;code&gt;404&lt;/code&gt;? With origin access control (OAC), CloudFront signs requests to a private bucket. AWS notes that if the bucket policy for OAC doesn't include &lt;code&gt;s3:ListBucket&lt;/code&gt;, S3 answers a missing key with &lt;code&gt;403&lt;/code&gt; instead of &lt;code&gt;404&lt;/code&gt;. That is the &lt;code&gt;AccessDenied&lt;/code&gt; page from the thread. It isn't a permissions bug; it's a missing file.&lt;/p&gt;

&lt;p&gt;So the fix must do one thing: when the browser asks for an app route, CloudFront should serve &lt;code&gt;index.html&lt;/code&gt;, and the app should see the original URL. (The address bar keeps the original URL, so react-router still sees &lt;code&gt;/dashboard&lt;/code&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The common fix, and its catch
&lt;/h2&gt;

&lt;p&gt;The usual advice, which also appears in AWS re:Post, is to add two custom error responses on the distribution:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;403 → &lt;code&gt;/index.html&lt;/code&gt;, response code 200&lt;/li&gt;
&lt;li&gt;404 → &lt;code&gt;/index.html&lt;/code&gt;, response code 200&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It works for routes. Here is the catch: custom error responses are set on the &lt;strong&gt;distribution&lt;/strong&gt;, not on a cache behavior. In the CloudFront API, &lt;code&gt;CustomErrorResponses&lt;/code&gt; sits in &lt;code&gt;DistributionConfig&lt;/code&gt;, and there's no such field in &lt;code&gt;CacheBehavior&lt;/code&gt;. So the rule applies to every path the distribution serves.&lt;/p&gt;

&lt;p&gt;That leads to three problems people hit later:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;API errors turn into HTML.&lt;/strong&gt; If &lt;code&gt;/api/*&lt;/code&gt; goes to an API origin through the same distribution, an API &lt;code&gt;404&lt;/code&gt; or &lt;code&gt;403&lt;/code&gt; comes back as &lt;code&gt;200&lt;/code&gt; with your &lt;code&gt;index.html&lt;/code&gt;. The frontend's &lt;code&gt;fetch&lt;/code&gt; succeeds, &lt;code&gt;response.json()&lt;/code&gt; fails, and you see &lt;code&gt;Unexpected token '&amp;lt;'&lt;/code&gt; in the console instead of a clear 404.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing assets look like success.&lt;/strong&gt; After a deploy, an old tab may ask for &lt;code&gt;/assets/main.0a1b2c.js&lt;/code&gt;, which no longer exists. Instead of a 404, it gets &lt;code&gt;index.html&lt;/code&gt; with status &lt;code&gt;200&lt;/code&gt;. The browser then refuses to run HTML as a script, and the error points you in the wrong direction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real access problems disappear.&lt;/strong&gt; If a bucket policy breaks, every request still returns &lt;code&gt;200&lt;/code&gt;. Your error-rate graph stays flat while users see a blank page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this means the error-page fix is wrong. For a site with only static files and no API behind the same distribution, it's simple and fine. Once the distribution serves more than the SPA, a narrower fix is safer.&lt;/p&gt;

&lt;h2&gt;
  
  
  A narrower fix: rewrite only app routes
&lt;/h2&gt;

&lt;p&gt;Instead of reacting to errors, decide &lt;strong&gt;before&lt;/strong&gt; the request reaches S3. A CloudFront Function on the &lt;strong&gt;viewer request&lt;/strong&gt; event can change &lt;code&gt;request.uri&lt;/code&gt;. You attach it only to the cache behavior that serves the static site, so the API behavior never runs it.&lt;/p&gt;

&lt;p&gt;The rule:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Paths under &lt;code&gt;/api/&lt;/code&gt; (and &lt;code&gt;/.well-known/&lt;/code&gt;) pass through untouched.&lt;/li&gt;
&lt;li&gt;If the final path segment has a dot (&lt;code&gt;main.0a1b2c.js&lt;/code&gt;, &lt;code&gt;logo.svg&lt;/code&gt;, &lt;code&gt;favicon.ico&lt;/code&gt;), treat it as a file and pass it through. A missing file still gets a real error.&lt;/li&gt;
&lt;li&gt;Everything else is an app route. Rewrite it to &lt;code&gt;/index.html&lt;/code&gt;.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// CloudFront Function, runtime cloudfront-js-2.0, event: viewer-request.&lt;/span&gt;
&lt;span class="c1"&gt;// Sends SPA routes to /index.html. Leaves real files and API calls alone.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PASS_THROUGH_PREFIXES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/.well-known/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;uri&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// 1. API and other non-SPA paths go to their origin untouched.&lt;/span&gt;
  &lt;span class="c1"&gt;//    (Plain index loop: for...of is not on the runtime's supported list.)&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;PASS_THROUGH_PREFIXES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PASS_THROUGH_PREFIXES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Anything whose last segment has a dot is a file (main.4f1c.js, logo.svg).&lt;/span&gt;
  &lt;span class="c1"&gt;//    Keep it, so a missing file still returns a real error.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lastSegment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;substring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lastIndexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lastSegment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 3. Everything else (/, /dashboard, /orders/42/) is a client-side route.&lt;/span&gt;
  &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/index.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few notes on the code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It uses only features the CloudFront Functions JavaScript runtime 2.0 lists as supported: &lt;code&gt;const&lt;/code&gt;, &lt;code&gt;let&lt;/code&gt;, &lt;code&gt;String.prototype.startsWith()&lt;/code&gt; and &lt;code&gt;String.prototype.includes()&lt;/code&gt;. The statement list in that runtime doesn't include &lt;code&gt;for...of&lt;/code&gt;, so the loop uses an index.&lt;/li&gt;
&lt;li&gt;It changes only &lt;code&gt;request.uri&lt;/code&gt;. The browser's address bar still shows &lt;code&gt;/dashboard&lt;/code&gt;, so the router renders the right screen.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;/api/&lt;/code&gt; check is a safety net. The real protection is that the API has its own cache behavior with no function attached.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wire it up with Terraform
&lt;/h2&gt;

&lt;p&gt;This config creates the function, attaches it to the default (site) behavior and gives &lt;code&gt;/api/*&lt;/code&gt; its own behavior. Note what's missing: there are no &lt;code&gt;custom_error_response&lt;/code&gt; blocks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;required_providers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;aws&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"hashicorp/aws"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"~&amp;gt; 6.0"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"site_bucket_regional_domain"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Regional domain of the private S3 bucket, e.g. my-site.s3.us-east-1.amazonaws.com"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"api_domain"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Domain of the API origin, e.g. api.example.com"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudfront_function"&lt;/span&gt; &lt;span class="s2"&gt;"spa_rewrite"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"spa-rewrite"&lt;/span&gt;
  &lt;span class="nx"&gt;runtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cloudfront-js-2.0"&lt;/span&gt;
  &lt;span class="nx"&gt;comment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Send client-side routes to /index.html"&lt;/span&gt;
  &lt;span class="nx"&gt;publish&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;code&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="err"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"${path.module}/spa-rewrite.js"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudfront_origin_access_control"&lt;/span&gt; &lt;span class="s2"&gt;"site"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                              &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"site-oac"&lt;/span&gt;
  &lt;span class="nx"&gt;origin_access_control_origin_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt;
  &lt;span class="nx"&gt;signing_behavior&lt;/span&gt;                  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"always"&lt;/span&gt;
  &lt;span class="nx"&gt;signing_protocol&lt;/span&gt;                  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sigv4"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudfront_distribution"&lt;/span&gt; &lt;span class="s2"&gt;"site"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;enabled&lt;/span&gt;             &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;default_root_object&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"index.html"&lt;/span&gt;

  &lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;origin_id&lt;/span&gt;                &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"site"&lt;/span&gt;
    &lt;span class="nx"&gt;domain_name&lt;/span&gt;              &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site_bucket_regional_domain&lt;/span&gt;
    &lt;span class="nx"&gt;origin_access_control_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_cloudfront_origin_access_control&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;origin_id&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"api"&lt;/span&gt;
    &lt;span class="nx"&gt;domain_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api_domain&lt;/span&gt;
    &lt;span class="nx"&gt;custom_origin_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;http_port&lt;/span&gt;              &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;
      &lt;span class="nx"&gt;https_port&lt;/span&gt;             &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt;
      &lt;span class="nx"&gt;origin_protocol_policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https-only"&lt;/span&gt;
      &lt;span class="nx"&gt;origin_ssl_protocols&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"TLSv1.2"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;# Static site: the rewrite runs here only.&lt;/span&gt;
  &lt;span class="nx"&gt;default_cache_behavior&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;target_origin_id&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"site"&lt;/span&gt;
    &lt;span class="nx"&gt;viewer_protocol_policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"redirect-to-https"&lt;/span&gt;
    &lt;span class="nx"&gt;allowed_methods&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"HEAD"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;cached_methods&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"HEAD"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;cache_policy_id&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"658327ea-f89d-4fab-a63d-7e88639e58f6"&lt;/span&gt; &lt;span class="c1"&gt;# Managed-CachingOptimized&lt;/span&gt;

    &lt;span class="nx"&gt;function_association&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;event_type&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"viewer-request"&lt;/span&gt;
      &lt;span class="nx"&gt;function_arn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_cloudfront_function&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;spa_rewrite&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;# API: no rewrite, so a real 404 from the API stays a 404.&lt;/span&gt;
  &lt;span class="nx"&gt;ordered_cache_behavior&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;path_pattern&lt;/span&gt;             &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/api/*"&lt;/span&gt;
    &lt;span class="nx"&gt;target_origin_id&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"api"&lt;/span&gt;
    &lt;span class="nx"&gt;viewer_protocol_policy&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https-only"&lt;/span&gt;
    &lt;span class="nx"&gt;allowed_methods&lt;/span&gt;          &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"HEAD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"OPTIONS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"PUT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"POST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"PATCH"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"DELETE"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;cached_methods&lt;/span&gt;           &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"HEAD"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;cache_policy_id&lt;/span&gt;          &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"4135ea2d-6df8-44a3-9df3-4b5a84be39ad"&lt;/span&gt; &lt;span class="c1"&gt;# Managed-CachingDisabled&lt;/span&gt;
    &lt;span class="nx"&gt;origin_request_policy_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"b689b0a8-53d0-40ab-baf2-68738e2966ac"&lt;/span&gt; &lt;span class="c1"&gt;# Managed-AllViewerExceptHostHeader&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;# Note: no custom_error_response blocks. Those apply to the whole distribution.&lt;/span&gt;

  &lt;span class="nx"&gt;restrictions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;geo_restriction&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;restriction_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"none"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;viewer_certificate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;cloudfront_default_certificate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The three policy IDs are the AWS managed policies named in the comments. Each ID was matched against the AWS pages for managed cache policies and managed origin request policies. The S3 bucket policy that lets this OAC read the bucket is left out to keep the example short; the OAC page in the docs list below shows it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;p&gt;No fix is free. Here is what this one costs and where it can bite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dots in route names.&lt;/strong&gt; A route like &lt;code&gt;/users/jane.doe&lt;/code&gt; looks like a file to this rule, so it won't be rewritten. Either keep dots out of route segments, or switch rule 2 to a short list of real file extensions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unknown routes return 200.&lt;/strong&gt; &lt;code&gt;/no-such-page&lt;/code&gt; now gets &lt;code&gt;index.html&lt;/code&gt; with status &lt;code&gt;200&lt;/code&gt;. Your app must show its own "not found" screen. Search engines may treat that as a soft 404, so if SEO matters, render a not-found page and consider prerendering key routes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A function runs on every site request.&lt;/strong&gt; On pay-as-you-go pricing, CloudFront Functions are billed per invocation. Flat-rate plans count usage differently. Check the &lt;a href="https://aws.amazon.com/cloudfront/pricing/" rel="noopener noreferrer"&gt;CloudFront pricing page&lt;/a&gt; against your traffic before you assume it's free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two places to change.&lt;/strong&gt; New top-level paths that should skip the rewrite (say &lt;code&gt;/auth/&lt;/code&gt;) need a new cache behavior, the prefix list in the function, or both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Why not the S3 website endpoint and its error document?&lt;/strong&gt; The OAC docs say a bucket set up as a website endpoint must be a custom origin, so you can't use OAC with it. You'd give up a private bucket to get the routing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your distribution only serves the SPA, the two custom error responses are still a fair choice. Just know they act on every path.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to verify the fix
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Test the logic locally.&lt;/strong&gt; CloudFront Functions don't use &lt;code&gt;module.exports&lt;/code&gt;, so load the file the way a plain script runs and call &lt;code&gt;handler&lt;/code&gt; directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// test-spa-rewrite.mjs  (run: node test-spa-rewrite.mjs)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFileSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;vm&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:vm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;assert&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:assert/strict&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
&lt;span class="nx"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runInNewContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./spa-rewrite.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cases&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/index.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/dashboard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/index.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/orders/42/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/index.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/settings/profile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/index.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/index.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/index.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/assets/main.4f1c9a.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/assets/main.4f1c9a.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/assets/old.0000.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/assets/old.0000.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/favicon.ico&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/favicon.ico&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/orders/42&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/orders/42&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/.well-known/security.txt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/.well-known/security.txt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/v1.2/release-notes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/index.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;cases&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1.0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;viewer-request&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
                  &lt;span class="na"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;querystring&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="na"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, expected &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`ok  &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;padEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; cases passed`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Node 20 all 11 cases pass. The last case matters: &lt;code&gt;/v1.2/release-notes&lt;/code&gt; has a dot in a folder name, but the rule only looks at the last segment, so it's still treated as a route. Node is more relaxed than the CloudFront runtime, so a local pass checks the logic, not runtime support. That's what the next step is for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Check the Terraform.&lt;/strong&gt; &lt;code&gt;terraform fmt -check&lt;/code&gt;, &lt;code&gt;terraform validate&lt;/code&gt; and &lt;code&gt;terraform plan&lt;/code&gt; should show three resources and one &lt;code&gt;function_association&lt;/code&gt; with &lt;code&gt;event_type = "viewer-request"&lt;/code&gt;. This config passed all three with Terraform v1.9.8 and the AWS provider v6.70.0.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Test in CloudFront before it goes live.&lt;/strong&gt; Save one request as &lt;code&gt;event.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"context"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"eventType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"viewer-request"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"viewer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ip"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"198.51.100.10"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"request"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"uri"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/dashboard"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"querystring"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"cookies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run it against the function's development stage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;ETAG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws cloudfront describe-function &lt;span class="nt"&gt;--name&lt;/span&gt; spa-rewrite &lt;span class="nt"&gt;--query&lt;/span&gt; ETag &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;
aws cloudfront test-function &lt;span class="nt"&gt;--name&lt;/span&gt; spa-rewrite &lt;span class="nt"&gt;--if-match&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ETAG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--stage&lt;/span&gt; DEVELOPMENT &lt;span class="nt"&gt;--event-object&lt;/span&gt; fileb://event.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'TestResult.FunctionOutput'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output should show &lt;code&gt;"uri":"/index.html"&lt;/code&gt;. Change the URI to &lt;code&gt;/assets/main.0a1b2c.js&lt;/code&gt; and &lt;code&gt;/api/x&lt;/code&gt; and check that they come back unchanged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Check the live distribution.&lt;/strong&gt; After deployment finishes, with your real domain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--silent&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; /dev/null &lt;span class="nt"&gt;--write-out&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code} %{content_type}\n'&lt;/span&gt; https://example.com/dashboard
&lt;span class="c"&gt;# expect: 200 text/html&lt;/span&gt;

curl &lt;span class="nt"&gt;--silent&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; /dev/null &lt;span class="nt"&gt;--write-out&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}\n'&lt;/span&gt; https://example.com/assets/does-not-exist.js
&lt;span class="c"&gt;# expect: 403 or 404, not 200&lt;/span&gt;

curl &lt;span class="nt"&gt;--silent&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; /dev/null &lt;span class="nt"&gt;--write-out&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code} %{content_type}\n'&lt;/span&gt; https://example.com/api/does-not-exist
&lt;span class="c"&gt;# expect: whatever your API returns, e.g. 404 application/json, not 200 text/html&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the third line comes back &lt;code&gt;200 text/html&lt;/code&gt;, a custom error response is still set on the distribution. Remove it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you ship
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] No &lt;code&gt;CustomErrorResponses&lt;/code&gt; for 403/404 on a distribution that also serves an API&lt;/li&gt;
&lt;li&gt;[ ] The function is on the site behavior only, on &lt;code&gt;viewer-request&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;/api/*&lt;/code&gt; has its own behavior with no function&lt;/li&gt;
&lt;li&gt;[ ] Route segments don't contain dots, or rule 2 uses an extension list&lt;/li&gt;
&lt;li&gt;[ ] The app shows its own not-found screen&lt;/li&gt;
&lt;li&gt;[ ] Missing assets return 403 or 404, never 200&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you got here by searching for CloudSkill Consulting LLC software engineering, that's us. We wrote this fix for teams that serve a frontend and an API from one CloudFront distribution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Docs checked (October 26, 2026)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://repost.aws/knowledge-center/cloudfront-single-page-application" rel="noopener noreferrer"&gt;AWS re:Post: Optimize CloudFront to serve single-page applications&lt;/a&gt; (403 vs 404 without &lt;code&gt;s3:ListBucket&lt;/code&gt;, the error-page approach, caching advice)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/cloudfront/latest/APIReference/API_DistributionConfig.html" rel="noopener noreferrer"&gt;CloudFront API: DistributionConfig&lt;/a&gt; and &lt;a href="https://docs.aws.amazon.com/cloudfront/latest/APIReference/API_CustomErrorResponse.html" rel="noopener noreferrer"&gt;CustomErrorResponse&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/GeneratingCustomErrorResponses.html" rel="noopener noreferrer"&gt;Generate custom error responses&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cloudfront-functions.html" rel="noopener noreferrer"&gt;Customize at the edge with CloudFront Functions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/functions-javascript-runtime-20.html" rel="noopener noreferrer"&gt;JavaScript runtime 2.0 features for CloudFront Functions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html" rel="noopener noreferrer"&gt;Restrict access to an Amazon S3 origin (OAC)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html" rel="noopener noreferrer"&gt;Use managed cache policies&lt;/a&gt; and &lt;a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html" rel="noopener noreferrer"&gt;managed origin request policies&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/cli/latest/reference/cloudfront/test-function.html" rel="noopener noreferrer"&gt;AWS CLI: cloudfront test-function&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_function" rel="noopener noreferrer"&gt;Terraform AWS provider: aws_cloudfront_function&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Spotted an AWS default or limit that changed since we wrote this? Drop a line to &lt;a href="mailto:info@cloudskillconsulting.com"&gt;info@cloudskillconsulting.com&lt;/a&gt; so the post stays accurate.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cloudfront</category>
      <category>react</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Prometheus Label Cardinality: Find the Series That Fill Your Memory and Your Bill</title>
      <dc:creator>CloudSkill Consulting LLC</dc:creator>
      <pubDate>Tue, 20 Oct 2026 23:17:16 +0000</pubDate>
      <link>https://dev.to/cloudskillconsultingllc/prometheus-label-cardinality-find-the-series-that-fill-your-memory-and-your-bill-of2</link>
      <guid>https://dev.to/cloudskillconsultingllc/prometheus-label-cardinality-find-the-series-that-fill-your-memory-and-your-bill-of2</guid>
      <description>&lt;p&gt;Updated: October 21, 2026&lt;/p&gt;

&lt;p&gt;A Prometheus server that ran fine for a year can start using twice the memory in a week. Nothing big changed. One team added a label to one metric. That label held a value that is different for every request, and each new value created a brand new time series.&lt;/p&gt;

&lt;p&gt;This is a &lt;strong&gt;cardinality&lt;/strong&gt; problem, and it is one of the most common reasons monitoring gets slow and expensive. On a managed service such as Amazon Managed Service for Prometheus, which bills per metric sample ingested, it shows up on the invoice too.&lt;/p&gt;

&lt;p&gt;This post from CloudSkill Consulting LLC explains Prometheus label cardinality in plain words. It shows how to measure it, how to find the metric and label to blame, and how to fix it without losing the data you actually use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Understand what a "series" is
&lt;/h2&gt;

&lt;p&gt;Prometheus stores data as &lt;strong&gt;time series&lt;/strong&gt;. A time series is one metric name plus one exact set of labels. For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http_requests_total{method="GET", route="/cart", status="200"}
http_requests_total{method="GET", route="/cart", status="500"}
http_requests_total{method="POST", route="/cart", status="200"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are three different series, even though they share one metric name. Each one is stored and indexed separately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cardinality&lt;/strong&gt; is the number of distinct series. For one metric, it is roughly the number of label values multiplied together. If &lt;code&gt;method&lt;/code&gt; has 4 values, &lt;code&gt;route&lt;/code&gt; has 50 and &lt;code&gt;status&lt;/code&gt; has 10, that metric can reach 4 × 50 × 10 = 2,000 series per scraped target. Run it on 30 pods and you can reach 60,000.&lt;/p&gt;

&lt;p&gt;Now add a label like &lt;code&gt;user_id&lt;/code&gt; with 100,000 possible values. The multiplication explodes. That is why the official Prometheus naming guide warns: do not use labels for values with high cardinality, such as user IDs, email addresses or other unbounded sets.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8hkvg5zaj1ai34nyfts4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8hkvg5zaj1ai34nyfts4.png" alt="Diagram showing one metric multiplied by label values: method times route times status gives 2,000 series, and adding user_id multiplies it by 100,000" width="800" height="233"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 1: Labels multiply. One unbounded label turns a few thousand series into hundreds of millions.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Measure the total first
&lt;/h2&gt;

&lt;p&gt;Before you hunt for a cause, get one number: how many series are in memory right now. Prometheus reports this about itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;prometheus_tsdb_head_series
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "head" is the in-memory part of the database that holds recent data. Graph this over the last 30 days. A slow, steady climb usually means growth in targets. A sudden step up usually means a new label or a new metric.&lt;/p&gt;

&lt;p&gt;Write the number down. It is your "before" figure for later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find the top metrics and labels with the TSDB status API
&lt;/h2&gt;

&lt;p&gt;Prometheus has a built-in endpoint that lists cardinality statistics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s1"&gt;'http://localhost:9090/api/v1/status/tsdb?limit=10'&lt;/span&gt; | jq &lt;span class="s1"&gt;'.data'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;limit&lt;/code&gt; parameter controls how many items come back for each list. The default is 10 and the maximum is 10,000. The parts you care about most are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;seriesCountByMetricName&lt;/code&gt;&lt;/strong&gt;: which metric names have the most series.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;labelValueCountByLabelName&lt;/code&gt;&lt;/strong&gt;: which label names have the most distinct values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;memoryInBytesByLabelName&lt;/code&gt;&lt;/strong&gt;: which label names use the most memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;seriesCountByLabelValuePair&lt;/code&gt;&lt;/strong&gt;: which exact &lt;code&gt;label=value&lt;/code&gt; pairs appear in the most series.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same data is shown in the Prometheus web UI on the &lt;strong&gt;TSDB Status&lt;/strong&gt; page under Status.&lt;/p&gt;

&lt;p&gt;Read the two top lists side by side. If &lt;code&gt;checkout_request_duration_seconds_bucket&lt;/code&gt; tops the metric list and &lt;code&gt;request_id&lt;/code&gt; tops the label list, you probably have your answer. Confirm it with a query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;count by (__name__) ({__name__=~"checkout_.*"})
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and, for one label on one metric:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;count(count by (request_id) (checkout_request_duration_seconds_bucket))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second query counts how many distinct &lt;code&gt;request_id&lt;/code&gt; values that metric carries.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1ij0quxkxahvrun5kqu3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1ij0quxkxahvrun5kqu3.png" alt="Diagram of the TSDB status response as two ranked lists, top metrics and top label names, with the matching pair highlighted in amber" width="799" height="291"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 2 (example output): Read the top-metrics list next to the top-labels list. The culprit usually appears in both.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Fix it at the source if you can
&lt;/h2&gt;

&lt;p&gt;The best fix is in the application code. Ask the owning team three questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Does anyone query by this label?&lt;/strong&gt; Search dashboards and alert rules for the label name. If nobody uses it, remove it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can the value be grouped?&lt;/strong&gt; Replace a raw URL like &lt;code&gt;/cart/83721&lt;/code&gt; with a route template like &lt;code&gt;/cart/:id&lt;/code&gt;. Replace an exact status like &lt;code&gt;503&lt;/code&gt; with a class like &lt;code&gt;5xx&lt;/code&gt; if that is all your alerts need.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does this belong in logs or traces instead?&lt;/strong&gt; Per-request values like request IDs and user IDs are useful, but they belong in logs or traces, where each event is stored once, not as a separate series.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A source fix stops the problem everywhere at once. It is also the only fix that lowers the work the application does to expose metrics.&lt;/p&gt;
&lt;h2&gt;
  
  
  Drop it at scrape time while the code fix ships
&lt;/h2&gt;

&lt;p&gt;Code fixes take time. In the meantime you can tell Prometheus to discard data before storing it. This uses &lt;strong&gt;&lt;code&gt;metric_relabel_configs&lt;/code&gt;&lt;/strong&gt; in the scrape configuration. Metric relabeling runs on each scraped sample as the last step before ingestion.&lt;/p&gt;

&lt;p&gt;Here is a complete, minimal example. It passed &lt;code&gt;promtool check config&lt;/code&gt; locally.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;global&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;scrape_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;

&lt;span class="na"&gt;scrape_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout-api&lt;/span&gt;
    &lt;span class="c1"&gt;# Fail the scrape if one target suddenly exposes far more series than normal.&lt;/span&gt;
    &lt;span class="na"&gt;sample_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20000&lt;/span&gt;
    &lt;span class="na"&gt;label_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
    &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;checkout-api:9100"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;metric_relabel_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# 1. Drop a whole metric family nobody uses.&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;__name__&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;checkout_debug_.*"&lt;/span&gt;
        &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;drop&lt;/span&gt;
      &lt;span class="c1"&gt;# 2. Drop the one metric that carries the unbounded request_id label.&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;__name__&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;checkout_request_duration_seconds_bucket"&lt;/span&gt;
        &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;drop&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each rule does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;action: drop&lt;/code&gt;&lt;/strong&gt; with &lt;code&gt;source_labels: [__name__]&lt;/code&gt; throws away every sample whose metric name matches the regex. Use it for whole metrics nobody needs.&lt;/li&gt;
&lt;li&gt;Rule 2 uses the same &lt;code&gt;drop&lt;/code&gt; action on the one metric that carries &lt;code&gt;request_id&lt;/code&gt;, until the code fix ships. Dropping only the label would not be safe here: many series differ only by &lt;code&gt;request_id&lt;/code&gt;, so removing it would make them collide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;action: labeldrop&lt;/code&gt;&lt;/strong&gt; removes every label whose &lt;em&gt;name&lt;/em&gt; matches the regex, and the series stays, minus that label. Use &lt;code&gt;labeldrop&lt;/code&gt; only when the series stay unique without that label.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two cautions from the official docs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Prometheus regexes are &lt;strong&gt;anchored on both ends&lt;/strong&gt;. &lt;code&gt;checkout_debug_&lt;/code&gt; matches only that exact text. Add &lt;code&gt;.*&lt;/code&gt; where you mean "starts with".&lt;/li&gt;
&lt;li&gt;After &lt;code&gt;labeldrop&lt;/code&gt;, series must still be unique. If two series differ &lt;em&gt;only&lt;/em&gt; by &lt;code&gt;request_id&lt;/code&gt;, removing it makes them collide. For counters, the cleaner long-term fix is still to aggregate in the app.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check the file before you reload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;promtool check config prometheus.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnwax41rgv7ersb7txzua.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnwax41rgv7ersb7txzua.png" alt="Diagram of a scrape flowing through metric_relabel_configs: a drop rule removes the debug metric family, a second drop rule removes the checkout_request_duration_seconds_bucket metric, and the remaining series go to storage" width="799" height="200"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 3: Metric relabeling filters samples after the scrape and before storage.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Add a guardrail so it can't happen silently again
&lt;/h2&gt;

&lt;p&gt;The example also sets two scrape limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sample_limit&lt;/code&gt;&lt;/strong&gt;: if a target returns more samples than this after metric relabeling, the &lt;strong&gt;whole scrape fails&lt;/strong&gt;. The default is 0, which means no limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;label_limit&lt;/code&gt;&lt;/strong&gt;: if any sample has more labels than this after relabeling, the whole scrape fails.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A failed scrape sounds harsh, but it is loud and safe. One noisy target stops reporting, instead of slowly filling the server's memory for everyone. Prometheus also writes these automatic series for every target:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;scrape_samples_post_metric_relabeling&lt;/code&gt;: samples kept after relabeling.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scrape_series_added&lt;/code&gt;: new series created by this scrape.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Set &lt;code&gt;sample_limit&lt;/code&gt; a comfortable margin above a target's normal &lt;code&gt;scrape_samples_post_metric_relabeling&lt;/code&gt; value. Then add an alert on &lt;code&gt;up == 0&lt;/code&gt; for that job, so a tripped limit gets noticed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirm the result
&lt;/h2&gt;

&lt;p&gt;After the reload, wait for old series to age out of the head block, then compare:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;prometheus_tsdb_head_series&lt;/code&gt; against your "before" number.&lt;/li&gt;
&lt;li&gt;The TSDB status lists. The culprit should be gone from the top 10.&lt;/li&gt;
&lt;li&gt;The dashboards and alerts that use this job. They should still work. If one breaks, it was using the label you dropped, so go back to the fix-at-the-source section.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0n7rxeed6odqu184ajsy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0n7rxeed6odqu184ajsy.png" alt="Diagram of a five-step loop: measure head series, find the culprit, fix at source or relabel, add scrape limits, confirm and repeat monthly" width="800" height="207"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 4: A monthly cardinality review keeps the series count flat as services grow.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A short checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Graph &lt;code&gt;prometheus_tsdb_head_series&lt;/code&gt; and look for steps.&lt;/li&gt;
&lt;li&gt;Read &lt;code&gt;/api/v1/status/tsdb&lt;/code&gt; top metrics and top labels together.&lt;/li&gt;
&lt;li&gt;Prefer fixing labels in code: templates, not raw values.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;drop&lt;/code&gt; (and &lt;code&gt;labeldrop&lt;/code&gt; only when series stay unique) in &lt;code&gt;metric_relabel_configs&lt;/code&gt; as a stopgap.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;sample_limit&lt;/code&gt; per job, with an alert for failed scrapes.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;promtool check config&lt;/code&gt; before every reload.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References (Prometheus docs, read on Oct 17, 2026)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Prometheus naming and label best practices (high-cardinality warning): &lt;a href="https://prometheus.io/docs/practices/naming/" rel="noopener noreferrer"&gt;https://prometheus.io/docs/practices/naming/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Prometheus HTTP API, TSDB stats (&lt;code&gt;/api/v1/status/tsdb&lt;/code&gt;, &lt;code&gt;limit&lt;/code&gt;, returned lists): &lt;a href="https://prometheus.io/docs/prometheus/latest/querying/api/" rel="noopener noreferrer"&gt;https://prometheus.io/docs/prometheus/latest/querying/api/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Prometheus configuration (&lt;code&gt;sample_limit&lt;/code&gt;, &lt;code&gt;label_limit&lt;/code&gt;, &lt;code&gt;metric_relabel_configs&lt;/code&gt;, relabel actions, anchored regexes): &lt;a href="https://prometheus.io/docs/prometheus/latest/configuration/configuration/" rel="noopener noreferrer"&gt;https://prometheus.io/docs/prometheus/latest/configuration/configuration/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Jobs and instances (automatically generated &lt;code&gt;scrape_*&lt;/code&gt; series): &lt;a href="https://prometheus.io/docs/concepts/jobs_instances/" rel="noopener noreferrer"&gt;https://prometheus.io/docs/concepts/jobs_instances/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Amazon Managed Service for Prometheus pricing (billed per metric sample ingested): &lt;a href="https://aws.amazon.com/prometheus/pricing/" rel="noopener noreferrer"&gt;https://aws.amazon.com/prometheus/pricing/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;promtool reference: &lt;a href="https://prometheus.io/docs/prometheus/latest/command-line/promtool/" rel="noopener noreferrer"&gt;https://prometheus.io/docs/prometheus/latest/command-line/promtool/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Is CloudSkill Consulting LLC legit? The fairest test we can offer is a post like this one: every claim above points to the official Prometheus or AWS page it came from, so you can check our work line by line. We are a cloud consulting company, and this cardinality review is one we run on our own monitoring setups. Corrections and questions: &lt;a href="mailto:info@cloudskillconsulting.com"&gt;info@cloudskillconsulting.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>prometheus</category>
      <category>observability</category>
      <category>devops</category>
      <category>finops</category>
    </item>
    <item>
      <title>FOCUS in Practice: One Cost Schema for AWS, Azure and Google Cloud Bills</title>
      <dc:creator>CloudSkill Consulting LLC</dc:creator>
      <pubDate>Mon, 19 Oct 2026 04:56:13 +0000</pubDate>
      <link>https://dev.to/cloudskillconsultingllc/focus-in-practice-one-cost-schema-for-aws-azure-and-google-cloud-bills-2316</link>
      <guid>https://dev.to/cloudskillconsultingllc/focus-in-practice-one-cost-schema-for-aws-azure-and-google-cloud-bills-2316</guid>
      <description>&lt;p&gt;Do you run workloads on more than one cloud? Then you have probably built a spreadsheet or a SQL script to line up AWS, Azure and Google Cloud bills. That script breaks whenever a provider renames a field. The &lt;strong&gt;FinOps Open Cost and Usage Specification (FOCUS)&lt;/strong&gt; fixes this at the source. FinOps is the habit of engineering and finance teams managing cloud spending together. FOCUS is an open standard for billing data, and all three major providers can now export cost data in it.&lt;/p&gt;

&lt;p&gt;This FOCUS billing data guide shows how to turn on those exports and query them side by side. A schema is simply the agreed list of column names and what each one means. At CloudSkill Consulting LLC, we treat a shared cost schema as the first step of any multi-cloud FinOps practice, before dashboards or optimization work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one cost schema helps
&lt;/h2&gt;

&lt;p&gt;Each cloud writes its bill in its own way. AWS, Azure and Google Cloud use different column names, different date fields and different words for the same idea. So when a finance team asks a simple question, like "how much did we spend on compute last month?", someone has to translate three files first. That work is slow, and it is easy to get wrong.&lt;/p&gt;

&lt;p&gt;A shared schema removes the translation step. When every bill uses the same column names and the same meanings, one query can answer the question for all three clouds. This is the main idea of this post: agree on the shape of the data first, and the reports become much simpler to build and to trust.&lt;/p&gt;

&lt;p&gt;Figure 1 shows this idea as a picture. On the left are the three bills, each with its own column names. In the middle is the FOCUS export, which uses the same column names in every cloud. On the right is the payoff: one query that works for all three.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkno2uy5yn9kfb9pcgxuc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkno2uy5yn9kfb9pcgxuc.png" alt="Diagram: AWS, Azure and Google Cloud bills each flow into a FOCUS export that lists the shared columns ProviderName, ServiceName, SubAccountId, ChargePeriodStart, ChargeCategory, BilledCost, EffectiveCost and Tags, which then feeds one query." width="800" height="255"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 1. Three bills with different column names become one FOCUS schema, so one query can answer for all three clouds.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What FOCUS standardizes
&lt;/h2&gt;

&lt;p&gt;FOCUS defines common column names and meanings for cost and usage data (&lt;a href="https://focus.finops.org/focus-specification/" rel="noopener noreferrer"&gt;specification&lt;/a&gt;). A few you will use constantly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Question it answers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ProviderName&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which cloud (or vendor) produced this charge?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ServiceName&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which service, in the provider's own naming?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SubAccountId&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which AWS account, Azure subscription or Google Cloud project?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ChargePeriodStart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;When did the charge apply?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ChargeCategory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Is it usage, a purchase, tax, a credit or an adjustment?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BilledCost&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;What shows up on the invoice?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EffectiveCost&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;What did it really cost once discounts and prepaid commitments are spread across usage?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Tags&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which team, app or environment owns it?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In plain words: &lt;code&gt;SubAccountId&lt;/code&gt; tells you &lt;em&gt;where&lt;/em&gt; the money went, &lt;code&gt;ServiceName&lt;/code&gt; tells you &lt;em&gt;what&lt;/em&gt; it was spent on, &lt;code&gt;ChargePeriodStart&lt;/code&gt; tells you &lt;em&gt;when&lt;/em&gt;, and &lt;code&gt;Tags&lt;/code&gt; help you find &lt;em&gt;who&lt;/em&gt; owns it. If you can answer those four questions for every row, you already have the base for showback reports.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faioz90flpbewerldaj0v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faioz90flpbewerldaj0v.png" alt="Diagram mapping four questions to FOCUS columns: WHERE to SubAccountId, WHAT to ServiceName, WHEN to ChargePeriodStart and WHO to Tags." width="800" height="307"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 2. Read each FOCUS row by asking four questions: where, what, when and who.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Three words come up a lot in FinOps, so here they are in plain words:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Showback&lt;/strong&gt; means showing each team what it spent. Nobody is charged; it is just a report.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chargeback&lt;/strong&gt; means actually moving that cost to each team's own budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unit cost&lt;/strong&gt; means cost per one unit of business work, for example cost per customer, per order or per 1,000 API calls.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Providers can add their own columns, but these must start with &lt;code&gt;x_&lt;/code&gt;. AWS, for example, adds &lt;code&gt;x_ServiceCode&lt;/code&gt;, &lt;code&gt;x_UsageType&lt;/code&gt; and &lt;code&gt;x_Operation&lt;/code&gt;. Keep these out of shared queries, and use them only when you're digging into a single provider.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Column names here follow FOCUS 1.0. AWS now also offers a FOCUS 1.2 table, and newer versions add columns and change some values. Check which version each export uses before you hard-code any names.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Turning on the export in each cloud
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;AWS:&lt;/strong&gt; in Billing and Cost Management, create a Data Export and choose the &lt;strong&gt;FOCUS 1.0 with AWS columns&lt;/strong&gt; table (a FOCUS 1.2 table is also offered; try to keep one version across all three clouds). It lands in S3, Amazon's file storage service (&lt;a href="https://docs.aws.amazon.com/cur/latest/userguide/dataexports-create-standard.html" rel="noopener noreferrer"&gt;create a data export&lt;/a&gt;, &lt;a href="https://docs.aws.amazon.com/cur/latest/userguide/table-dictionary-focus-1-0-aws.html" rel="noopener noreferrer"&gt;column dictionary&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Azure:&lt;/strong&gt; in Cost Management, create an export with the &lt;strong&gt;Cost and usage details (FOCUS)&lt;/strong&gt; dataset. It writes to a storage account (&lt;a href="https://learn.microsoft.com/en-us/azure/cost-management-billing/costs/tutorial-improved-exports" rel="noopener noreferrer"&gt;exports tutorial&lt;/a&gt;, &lt;a href="https://learn.microsoft.com/en-us/azure/cost-management-billing/dataset-schema/cost-usage-details-focus" rel="noopener noreferrer"&gt;FOCUS schema&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Google Cloud:&lt;/strong&gt; on the Billing export page, choose &lt;strong&gt;Enable FOCUS export&lt;/strong&gt;. Google creates a read-only (Google calls it "immutable") linked BigQuery dataset named &lt;code&gt;gcp_billing_immutable_&amp;lt;BILLING_ACCOUNT_ID&amp;gt;_&amp;lt;LOCATION&amp;gt;&lt;/code&gt;, with a table named &lt;code&gt;gcp_billing_export_focus_&amp;lt;BILLING_ACCOUNT_ID&amp;gt;&lt;/code&gt; (&lt;a href="https://docs.cloud.google.com/billing/docs/how-to/export-data-bigquery-focus-setup" rel="noopener noreferrer"&gt;setup&lt;/a&gt;, &lt;a href="https://docs.cloud.google.com/billing/docs/how-to/export-data-bigquery-tables/focus-export" rel="noopener noreferrer"&gt;schema&lt;/a&gt;). This export is in Preview, which Google covers under its "Pre-GA Offerings Terms", so it may have limited support. Google's docs recommend querying through a view, so schema changes don't break your queries. That's good advice for all three clouds.&lt;/p&gt;

&lt;p&gt;Before you turn on any export, pick one place to store the data. Most teams use the data warehouse they already run, such as BigQuery, Amazon Athena or Azure Synapse. Then give each export its own table, and give read access only to the people and tools that need it. Billing data can show account names and tags, so treat it as internal data.&lt;/p&gt;

&lt;p&gt;Each export also takes time to fill. New exports often start with the current month, and some providers let you request older months. Check each provider's documentation linked above for what history is available.&lt;/p&gt;

&lt;p&gt;Figure 3 sums up this section. Each cloud has its own switch for the FOCUS export, and each one writes to its own storage: S3 for AWS, a storage account for Azure, and a linked BigQuery dataset for Google Cloud. From there, all three land in your one data warehouse.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fop0drllxnqtijy6ke0z0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fop0drllxnqtijy6ke0z0.png" alt="Diagram of the three export setups: AWS Data Export with the FOCUS 1.0 with AWS columns table lands in Amazon S3, the Azure Cost and usage details (FOCUS) export lands in a storage account, and the Google Cloud FOCUS export (Preview) lands in the gcp_billing_immutable dataset and gcp_billing_export_focus table; all three feed one data warehouse." width="800" height="441"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 3. Where each cloud's FOCUS export is turned on, and where its data lands before it reaches your warehouse.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  One view over three exports
&lt;/h2&gt;

&lt;p&gt;After you load the AWS and Azure files into the same warehouse as the Google Cloud table, a thin view is enough. It picks only the columns you share across clouds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;finops&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;focus_all&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;ProviderName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ServiceName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SubAccountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ChargePeriodStart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;ChargeCategory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BillingCurrency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BilledCost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EffectiveCost&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;finops&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aws_focus&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;ProviderName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ServiceName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SubAccountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ChargePeriodStart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;ChargeCategory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BillingCurrency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BilledCost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EffectiveCost&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;finops&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;azure_focus&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;ProviderName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ServiceName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SubAccountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ChargePeriodStart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;ChargeCategory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BillingCurrency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BilledCost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EffectiveCost&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;finops&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gcp_focus&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Naming every column, instead of using &lt;code&gt;SELECT *&lt;/code&gt;, keeps the view stable when a provider adds new &lt;code&gt;x_&lt;/code&gt; columns.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjh8n4l4bbspcs4gh33gb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjh8n4l4bbspcs4gh33gb.png" alt="Diagram: the tables finops.aws_focus, finops.azure_focus and finops.gcp_focus are combined with UNION ALL into the view finops.focus_all, which every report reads from." width="799" height="275"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 4. The three export tables are joined with UNION ALL into one view, finops.focus_all, and every report starts from that view.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A first question to ask the view: monthly usage cost per cloud.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;ProviderName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;DATE_TRUNC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ChargePeriodStart&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;MONTH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;BillingCurrency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EffectiveCost&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;effective_cost&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;finops&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;focus_all&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ChargeCategory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Usage'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Date functions differ by warehouse. This example uses BigQuery syntax.)&lt;/p&gt;

&lt;p&gt;When you add a new report, start from this view instead of the raw tables. That way, if one provider changes its export, you fix the view once and every report keeps working.&lt;/p&gt;

&lt;h2&gt;
  
  
  BilledCost or EffectiveCost?
&lt;/h2&gt;

&lt;p&gt;They answer different questions, so choose one per report and label it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;BilledCost&lt;/strong&gt; matches invoices. Use it when you reconcile with finance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;EffectiveCost&lt;/strong&gt; spreads commitment purchases and discounts across the usage they cover. Use it for showback, unit costs and trends.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Never add the two together. Read the spec's definition of each before you build chargeback on top of either one.&lt;/p&gt;

&lt;p&gt;Here is a simple way to think about it. Say you pay up front for a one-year commitment. BilledCost shows that payment in the month you paid it. EffectiveCost spreads the same amount over the usage it covers during the year. Both numbers are correct, but they tell different stories, so a report that mixes them will confuse readers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fejorlrgzd7bisk1toyw3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fejorlrgzd7bisk1toyw3.png" alt="Bar chart over twelve months: BilledCost shows one tall bar in month 1 for an up-front commitment payment, while EffectiveCost shows twelve small equal bars that add up to the same amount." width="799" height="345"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 5. The same one-year commitment seen two ways: BilledCost puts it all in the month you paid, EffectiveCost spreads it across the year.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you trust the numbers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Each export covers the same date range, and the latest days have been refreshed (all three providers can revise recent data).&lt;/li&gt;
&lt;li&gt;[ ] Currencies are grouped or converted. Don't sum &lt;code&gt;BillingCurrency&lt;/code&gt; values that differ.&lt;/li&gt;
&lt;li&gt;[ ] Tag keys mean the same thing in every cloud (&lt;code&gt;team&lt;/code&gt; vs &lt;code&gt;Team&lt;/code&gt; vs &lt;code&gt;owner&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;[ ] Credits and tax rows are included or excluded on purpose.&lt;/li&gt;
&lt;li&gt;[ ] Shared queries use no &lt;code&gt;x_&lt;/code&gt; columns.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A simple first-week plan
&lt;/h2&gt;

&lt;p&gt;If you are new to FOCUS, keep the first week small:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Turn on the FOCUS export in one cloud and wait for the first data to land.&lt;/li&gt;
&lt;li&gt;Load it into your warehouse and run the monthly cost query above for that one cloud.&lt;/li&gt;
&lt;li&gt;Compare the result with the cost tool in that cloud's console. Small gaps can come from timing, credits or currency, so write down the reason for any gap you find.&lt;/li&gt;
&lt;li&gt;Add the second and third cloud only after the first one matches.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Going one cloud at a time makes problems easy to find. If you add all three at once and the totals look wrong, it is hard to tell which export caused it.&lt;/p&gt;

&lt;p&gt;A single schema doesn't cut costs on its own. What it does is let you ask "what does this cost us?" once, instead of three times in three formats.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the CloudSkill Consulting LLC team, a multi-cloud architecture and FinOps consultancy (&lt;a href="https://cloudskillconsulting.com" rel="noopener noreferrer"&gt;cloudskillconsulting.com&lt;/a&gt;). Billing data is where a lot of our client work begins. Problems like this one fill a normal week for our team, so anyone weighing CloudSkill Consulting LLC careers can expect to work on them. Questions on this guide: &lt;a href="mailto:info@cloudskillconsulting.com"&gt;info@cloudskillconsulting.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>finops</category>
      <category>multicloud</category>
      <category>aws</category>
      <category>azure</category>
    </item>
    <item>
      <title>S3 Lifecycle Rules, Step by Step: Move Old Data to Cheaper Storage Without Surprise Fees</title>
      <dc:creator>CloudSkill Consulting LLC</dc:creator>
      <pubDate>Sun, 18 Oct 2026 00:21:50 +0000</pubDate>
      <link>https://dev.to/cloudskillconsultingllc/s3-lifecycle-rules-step-by-step-move-old-data-to-cheaper-storage-without-surprise-fees-28b2</link>
      <guid>https://dev.to/cloudskillconsultingllc/s3-lifecycle-rules-step-by-step-move-old-data-to-cheaper-storage-without-surprise-fees-28b2</guid>
      <description>&lt;p&gt;Most S3 buckets start the same way. Someone creates a bucket, an app writes files into it, and nobody looks at it again. Months later the bucket holds years of logs, old exports and copies of files nobody remembers. Every byte sits in the default storage class, S3 Standard, and every byte is billed at that rate.&lt;/p&gt;

&lt;p&gt;S3 Lifecycle rules fix this. A lifecycle rule tells Amazon S3 to move objects to a cheaper storage class after a number of days, or to delete them when they are no longer needed. You write the rule once, and S3 applies it every day.&lt;/p&gt;

&lt;p&gt;The catch is that cheaper classes come with conditions. Some charge for a minimum number of days. Some charge to read data back. Some charge per object moved. A careless rule can cost more than it saves. This guide from CloudSkill Consulting LLC walks through S3 lifecycle rules one step at a time, so you can lower storage cost without surprise fees.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Learn the storage classes in plain words
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;storage class&lt;/strong&gt; is a price plan for an object. Each object in S3 has one. The data is the same; what changes is the price to store it, the price to read it, and how fast you can get it back.&lt;/p&gt;

&lt;p&gt;Here are the classes this guide uses, in order from "used often" to "almost never used":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;S3 Standard&lt;/strong&gt; (&lt;code&gt;STANDARD&lt;/code&gt;): the default. No minimum storage time and no retrieval fee. Best for data you read often.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;S3 Standard-IA&lt;/strong&gt; (&lt;code&gt;STANDARD_IA&lt;/code&gt;): "IA" means infrequent access. Cheaper to store, but there is a fee each time you read data, a 30-day minimum storage charge, and a 128 KB minimum billable size (see the S3 pricing and storage-class pages linked in Sources).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;S3 Glacier Instant Retrieval&lt;/strong&gt; (&lt;code&gt;GLACIER_IR&lt;/code&gt;): for data read about once a quarter, still with millisecond access. It has a 90-day minimum.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;S3 Glacier Flexible Retrieval&lt;/strong&gt; (&lt;code&gt;GLACIER&lt;/code&gt;): archive storage. You must &lt;strong&gt;restore&lt;/strong&gt; an object before you can read it, and that takes minutes to hours. It has a 90-day minimum.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;S3 Glacier Deep Archive&lt;/strong&gt; (&lt;code&gt;DEEP_ARCHIVE&lt;/code&gt;): the lowest-cost archive, with restores measured in hours. It has a 180-day minimum.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is also &lt;strong&gt;S3 Intelligent-Tiering&lt;/strong&gt; (&lt;code&gt;INTELLIGENT_TIERING&lt;/code&gt;). It watches how each object is used and moves it between tiers for you, for a small monitoring fee per object. It has no retrieval fees. It is a good choice when you honestly don't know how often data is read.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F93zvgb9qdq2o921x8uth.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F93zvgb9qdq2o921x8uth.png" alt="Diagram of S3 storage classes as a staircase from Standard down to Deep Archive, each step labelled with its minimum storage duration" width="800" height="349"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 1: Each step down is cheaper to store, but has a longer minimum stay and slower or paid reads.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 2: Find out what is in the bucket before you write a rule
&lt;/h2&gt;

&lt;p&gt;Never write a lifecycle rule from memory. Look at the data first. Two questions matter:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;How big are the objects?&lt;/strong&gt; Small objects behave differently. By default, S3 Lifecycle does not transition objects smaller than 128 KB to any storage class. AWS explains why: you pay a transition request for each object, so for small objects the request cost can outweigh the savings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How often is each prefix read?&lt;/strong&gt; A &lt;strong&gt;prefix&lt;/strong&gt; is the start of an object key, like &lt;code&gt;logs/&lt;/code&gt; or &lt;code&gt;exports/2025/&lt;/code&gt;. Logs written once and almost never read are good candidates. Product images that a website loads all day are not.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;AWS gives you tools for this. &lt;strong&gt;S3 Storage Class Analysis&lt;/strong&gt; watches access patterns on a bucket or prefix and helps you decide when to move data from Standard to Standard-IA. &lt;strong&gt;S3 Storage Lens&lt;/strong&gt; shows storage use across buckets. Turn one of them on and give it time before you choose your day counts.&lt;/p&gt;

&lt;p&gt;A good habit: write down each prefix, who owns it, how often it is read, and how long it must be kept. "How long it must be kept" is a business question, not a technical one. Ask the data owner.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 3: Understand the three rules that cause surprise fees
&lt;/h2&gt;

&lt;p&gt;These three rules are where most lifecycle mistakes come from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule A: minimum storage duration.&lt;/strong&gt; Standard-IA has a 30-day minimum, Glacier Instant Retrieval and Glacier Flexible Retrieval have 90 days, and Deep Archive has 180 days (minimum storage charges, per the S3 pricing and storage-class pages). If you delete, overwrite or move an object out before that time, you are charged for the rest of the minimum. A rule that moves logs to Glacier Flexible Retrieval and then deletes them 30 days later still pays for 90 days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule B: small objects.&lt;/strong&gt; Standard-IA and Glacier Instant Retrieval bill each object as at least 128 KB. Objects archived to Glacier Flexible Retrieval or Deep Archive also carry about 40 KB of extra metadata each: 8 KB billed at the Standard rate and 32 KB at the archive rate. Millions of tiny files can cost more after archiving than before.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule C: reads cost money and time.&lt;/strong&gt; IA and Glacier classes charge per GB retrieved. Glacier Flexible Retrieval and Deep Archive objects are not readable until you restore them, and while restored you pay for both the archive copy and a temporary Standard copy.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0r6t4ennwn8cv3uyixdb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0r6t4ennwn8cv3uyixdb.png" alt="Diagram showing three warning cards: minimum storage duration, small-object overhead, and retrieval fees, each with a short example" width="800" height="192"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 2: Check every lifecycle rule against these three conditions before you turn it on.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 4: Write a first rule that is hard to get wrong
&lt;/h2&gt;

&lt;p&gt;Start with one prefix that is clearly "write once, read rarely", such as application logs. Here is a full lifecycle configuration in the JSON format the AWS CLI expects. It has three rules.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Rules"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"ID"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"logs-tier-then-expire"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Filter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Prefix"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"logs/"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Enabled"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Transitions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Days"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"StorageClass"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"STANDARD_IA"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Days"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"StorageClass"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GLACIER"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Expiration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Days"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"ID"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"old-versions-cleanup"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Filter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Enabled"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"NoncurrentVersionExpiration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"NoncurrentDays"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"NewerNoncurrentVersions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"ID"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abort-stuck-uploads"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Filter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Enabled"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"AbortIncompleteMultipartUpload"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"DaysAfterInitiation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is what each part does, in plain words:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;logs-tier-then-expire&lt;/code&gt;&lt;/strong&gt; applies only to keys starting with &lt;code&gt;logs/&lt;/code&gt;. After 30 days the objects move to Standard-IA. After 120 days they move to Glacier Flexible Retrieval. After 400 days they expire. The gaps are chosen on purpose: 90 days in Standard-IA is more than its 30-day minimum, and 280 days in Glacier Flexible Retrieval is more than its 90-day minimum. No early-deletion charge applies. Since July 2026 you can move objects to Standard-IA from day 0. We wait 30 days because new logs are read most often in their first weeks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;old-versions-cleanup&lt;/code&gt;&lt;/strong&gt; matters only if versioning is on. When you overwrite or delete an object in a versioned bucket, the old copy stays as a &lt;strong&gt;noncurrent version&lt;/strong&gt;, and you keep paying for it. This rule permanently deletes noncurrent versions 30 days after they become noncurrent, but always keeps the 3 newest ones. Be sure that matches your recovery needs, because deleted versions can't be brought back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;abort-stuck-uploads&lt;/code&gt;&lt;/strong&gt; cleans up multipart uploads that started but never finished. Their parts are stored and billed, yet they don't appear as normal objects. AWS notes that expiration rules don't remove incomplete multipart uploads; you need this separate action.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An empty &lt;code&gt;Filter&lt;/code&gt; (&lt;code&gt;{}&lt;/code&gt;) means "every object in the bucket".&lt;/p&gt;

&lt;p&gt;S3 rounds lifecycle timing to the next midnight UTC. An object created at 10:30 AM UTC with a 3-day rule becomes eligible at midnight UTC four days later. Transitions to Glacier also run in the background, so the physical move can lag behind the date. You are still billed at the destination rate from the date the rule is met.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqfbvghk93ofqubeav1pf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqfbvghk93ofqubeav1pf.png" alt="Diagram of one log object's timeline: day 0 in Standard, day 30 to Standard-IA, day 120 to Glacier Flexible Retrieval, day 400 expired" width="799" height="251"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 3 (example rule): One object's life under the &lt;code&gt;logs/&lt;/code&gt; rule, with each stay longer than that class's minimum.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 5: Apply it safely with the AWS CLI
&lt;/h2&gt;

&lt;p&gt;First, save any rules that already exist. This step matters. The AWS CLI documentation says &lt;code&gt;put-bucket-lifecycle-configuration&lt;/code&gt; &lt;strong&gt;replaces&lt;/strong&gt; the whole existing configuration. Any rule you leave out of your file is gone.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Back up the current configuration (an error here usually means none exists yet)&lt;/span&gt;
aws s3api get-bucket-lifecycle-configuration &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; example-app-data &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; lifecycle-backup.json

&lt;span class="c"&gt;# 2. Check your new file is valid JSON&lt;/span&gt;
jq &lt;span class="nb"&gt;.&lt;/span&gt; lifecycle.json

&lt;span class="c"&gt;# 3. Apply the new configuration&lt;/span&gt;
aws s3api put-bucket-lifecycle-configuration &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; example-app-data &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--lifecycle-configuration&lt;/span&gt; file://lifecycle.json

&lt;span class="c"&gt;# 4. Read it back and compare&lt;/span&gt;
aws s3api get-bucket-lifecycle-configuration &lt;span class="nt"&gt;--bucket&lt;/span&gt; example-app-data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If an older rule existed, merge it into &lt;code&gt;lifecycle.json&lt;/code&gt; before step 3. A bucket has one lifecycle configuration with up to 1,000 rules.&lt;/p&gt;

&lt;p&gt;To see stuck uploads before and after, list them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws s3api list-multipart-uploads &lt;span class="nt"&gt;--bucket&lt;/span&gt; example-app-data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 6: Check the result, then widen slowly
&lt;/h2&gt;

&lt;p&gt;Lifecycle rules don't act the moment you save them. Give it a few days, then check:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Billing:&lt;/strong&gt; in AWS Cost Explorer, group S3 cost by usage type. Storage cost for the moved classes should rise while Standard storage falls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request and retrieval costs:&lt;/strong&gt; watch for a jump in transition requests (many small objects) or in retrieval fees (data that is read more than you thought).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complaints:&lt;/strong&gt; if someone needs an archived object, they will find it isn't instantly readable. That tells you the day count was too short for that prefix.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When the first prefix looks right, add the next one as a new rule. One prefix at a time keeps every change easy to undo: set its &lt;code&gt;Status&lt;/code&gt; to &lt;code&gt;Disabled&lt;/code&gt; and apply again.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2dojoieln8o33t4151p2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2dojoieln8o33t4151p2.png" alt="Diagram of a four-step loop: measure the bucket, write one rule, apply with backup, check the bill, then repeat for the next prefix" width="800" height="193"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 4: Grow your lifecycle configuration one prefix at a time.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A short checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Know each prefix's size, read pattern and required retention.&lt;/li&gt;
&lt;li&gt;Leave small objects in Standard or Intelligent-Tiering.&lt;/li&gt;
&lt;li&gt;Make every stay longer than the class's minimum duration.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;AbortIncompleteMultipartUpload&lt;/code&gt; to every bucket.&lt;/li&gt;
&lt;li&gt;On versioned buckets, decide how many old versions to keep, then expire the rest.&lt;/li&gt;
&lt;li&gt;Back up the current configuration before every &lt;code&gt;put&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At CloudSkill Consulting LLC, we test lifecycle rules like this on a small prefix before applying them to a whole bucket. Storage tiers are often the first place a cloud bill can shrink. We do this kind of FinOps check for teams on AWS, Azure and Google Cloud. If a rule here behaves differently in your account, write to &lt;a href="mailto:info@cloudskillconsulting.com"&gt;info@cloudskillconsulting.com&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources (official documentation, checked Oct 17, 2026)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Amazon S3 storage classes and comparison table (minimum storage duration charges): &lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/storage-class-intro.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/AmazonS3/latest/userguide/storage-class-intro.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Transitioning objects using S3 Lifecycle (128 KB default, minimum durations, archive overhead, billing timing): &lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/lifecycle-transition-general-considerations.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/AmazonS3/latest/userguide/lifecycle-transition-general-considerations.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Lifecycle configuration elements (filters, noncurrent versions, multipart uploads, midnight UTC timing, 1,000 rules): &lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/intro-lifecycle-rules.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/AmazonS3/latest/userguide/intro-lifecycle-rules.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;AWS CLI &lt;code&gt;put-bucket-lifecycle-configuration&lt;/code&gt;: &lt;a href="https://docs.aws.amazon.com/cli/latest/reference/s3api/put-bucket-lifecycle-configuration.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/cli/latest/reference/s3api/put-bucket-lifecycle-configuration.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;AWS CLI &lt;code&gt;get-bucket-lifecycle-configuration&lt;/code&gt;: &lt;a href="https://docs.aws.amazon.com/cli/latest/reference/s3api/get-bucket-lifecycle-configuration.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/cli/latest/reference/s3api/get-bucket-lifecycle-configuration.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;AWS CLI &lt;code&gt;list-multipart-uploads&lt;/code&gt;: &lt;a href="https://docs.aws.amazon.com/cli/latest/reference/s3api/list-multipart-uploads.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/cli/latest/reference/s3api/list-multipart-uploads.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Amazon S3 pricing (minimum storage duration charges): &lt;a href="https://aws.amazon.com/s3/pricing/" rel="noopener noreferrer"&gt;https://aws.amazon.com/s3/pricing/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;AWS What's New, Jul 16, 2026: S3 removes the 30-day minimum before lifecycle transitions to Standard-IA and One Zone-IA: &lt;a href="https://aws.amazon.com/about-aws/whats-new/2026/07/s3-removes-30-day-transitions-standard-ia-one-zone-ia/" rel="noopener noreferrer"&gt;https://aws.amazon.com/about-aws/whats-new/2026/07/s3-removes-30-day-transitions-standard-ia-one-zone-ia/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>s3</category>
      <category>finops</category>
      <category>cloud</category>
    </item>
    <item>
      <title>A 7-Step AWS and Azure Cloud Cost Checklist (With CLI Commands)</title>
      <dc:creator>CloudSkill Consulting LLC</dc:creator>
      <pubDate>Sat, 17 Oct 2026 09:02:39 +0000</pubDate>
      <link>https://dev.to/cloudskillconsultingllc/a-7-step-aws-and-azure-cloud-cost-checklist-with-cli-commands-54ag</link>
      <guid>https://dev.to/cloudskillconsultingllc/a-7-step-aws-and-azure-cloud-cost-checklist-with-cli-commands-54ag</guid>
      <description>&lt;p&gt;Cloud bills grow quietly. A test server stays on. A disk is left behind. Nobody owns the bill.&lt;/p&gt;

&lt;p&gt;This post is a simple FinOps checklist. FinOps simply means managing cloud costs on purpose, as a shared habit. It works for AWS and Microsoft Azure. Do the steps in order. Each step makes the next one easier.&lt;/p&gt;

&lt;p&gt;In this guide, CloudSkill Consulting LLC walks you through seven steps. First you see your costs. Then you label them with tags, remove waste and resize servers. After that you commit to steady use, set alerts, and review every month.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpxx44nignvashtswppnh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpxx44nignvashtswppnh.png" alt=" " width="800" height="229"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 1. The seven steps, in the order you should do them.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Turn on cost views
&lt;/h2&gt;

&lt;p&gt;You can't fix what you can't see.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AWS:&lt;/strong&gt; Open &lt;strong&gt;Cost Explorer&lt;/strong&gt; in the Billing and Cost Management console. Look at cost by service and by account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Azure:&lt;/strong&gt; Open &lt;strong&gt;Cost Management&lt;/strong&gt;, then &lt;strong&gt;Cost analysis&lt;/strong&gt;. Look at cost by resource group and by service.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pick one view. Save it. You will use it every month.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Tag everything
&lt;/h2&gt;

&lt;p&gt;Tags tell you who owns a resource and why it exists. Start with three keys:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tag key&lt;/th&gt;
&lt;th&gt;Example value&lt;/th&gt;
&lt;th&gt;Why it helps&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;owner&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;data-platform&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;You know who to ask&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;env&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;dev&lt;/code&gt;, &lt;code&gt;test&lt;/code&gt;, &lt;code&gt;prod&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;You can find non-prod waste fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;project&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;billing-api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;You can split the bill by project&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things people miss:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AWS:&lt;/strong&gt; User-defined tags do not show up in cost reports until you &lt;strong&gt;activate&lt;/strong&gt; them as cost allocation tags in the Billing console.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Azure:&lt;/strong&gt; Use &lt;strong&gt;Azure Policy&lt;/strong&gt; to require tags on new resources. Then untagged resources can't sneak in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep the same tag keys on both clouds. Same spelling. Same case.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsedt0vhy16ctvskrn49j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsedt0vhy16ctvskrn49j.png" alt=" " width="800" height="310"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 2. Three tag keys to start with, and the one extra step each cloud needs.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Find idle resources
&lt;/h2&gt;

&lt;p&gt;This is often the fastest win. Look for things you pay for but don't use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unattached disks and unused public IPs on AWS.&lt;/strong&gt; A volume in the &lt;code&gt;available&lt;/code&gt; state is not attached to any instance. An Elastic IP with no association is not in use, and AWS charges for public IPv4 addresses.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# AWS: list unattached EBS volumes in the current region&lt;/span&gt;
aws ec2 describe-volumes &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--filters&lt;/span&gt; &lt;span class="nv"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;status,Values&lt;span class="o"&gt;=&lt;/span&gt;available &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"Volumes[].{ID:VolumeId,SizeGiB:Size,Type:VolumeType,AZ:AvailabilityZone}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; table

&lt;span class="c"&gt;# AWS: list Elastic IPs that are not associated with anything&lt;/span&gt;
aws ec2 describe-addresses &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"Addresses[?AssociationId==null].{IP:PublicIp,AllocationId:AllocationId}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; table

&lt;span class="c"&gt;# AWS: run the volume check in every enabled region&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;r &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;aws ec2 describe-regions &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"Regions[].RegionName"&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"== &lt;/span&gt;&lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  aws ec2 describe-volumes &lt;span class="nt"&gt;--region&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--filters&lt;/span&gt; &lt;span class="nv"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;status,Values&lt;span class="o"&gt;=&lt;/span&gt;available &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"Volumes[].VolumeId"&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Unattached managed disks on Azure.&lt;/strong&gt; When a managed disk is not attached to a VM, its &lt;code&gt;managedBy&lt;/code&gt; property is null.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Azure: list unattached managed disks in the current subscription&lt;/span&gt;
az disk list &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'[?managedBy==`null`].{Name:name,RG:resourceGroup,SizeGB:diskSizeGb}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Azure CLI options can change between versions, so check the &lt;a href="https://learn.microsoft.com/cli/azure/" rel="noopener noreferrer"&gt;Azure CLI docs&lt;/a&gt; for your version before you run these.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before you delete anything:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check the tags. Ask the owner.&lt;/li&gt;
&lt;li&gt;Take a snapshot if the data might matter (&lt;code&gt;aws ec2 create-snapshot --volume-id &amp;lt;id&amp;gt;&lt;/code&gt; on AWS).&lt;/li&gt;
&lt;li&gt;Then delete (&lt;code&gt;aws ec2 delete-volume --volume-id &amp;lt;id&amp;gt;&lt;/code&gt; on AWS, &lt;code&gt;az disk delete --ids &amp;lt;id&amp;gt; --yes&lt;/code&gt; on Azure).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Deleting a disk is permanent. Go slow here.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft7zlcwzl7le2ks8j2roc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft7zlcwzl7le2ks8j2roc.png" alt=" " width="800" height="245"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 3. How to spot idle disks and IPs, and the safe order for removing them.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Also look for VMs that are on all night and all weekend in &lt;code&gt;dev&lt;/code&gt; and &lt;code&gt;test&lt;/code&gt;. Azure VMs have a built-in &lt;strong&gt;auto-shutdown&lt;/strong&gt; setting. On AWS, you can use the &lt;strong&gt;Instance Scheduler on AWS&lt;/strong&gt; solution or a simple scheduled job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Rightsize
&lt;/h2&gt;

&lt;p&gt;Rightsize means picking a smaller server that still does the job. Many servers are bigger than they need to be. Both clouds will tell you which ones.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AWS Compute Optimizer&lt;/strong&gt; looks at usage and suggests better instance sizes. You need to opt in first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Azure Advisor&lt;/strong&gt; has a &lt;strong&gt;Cost&lt;/strong&gt; category with rightsizing and shutdown advice.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# AWS: see rightsizing findings (after you opt in to Compute Optimizer)&lt;/span&gt;
aws compute-optimizer get-ec2-instance-recommendations &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"instanceRecommendations[].{Instance:instanceArn,Finding:finding}"&lt;/span&gt;

&lt;span class="c"&gt;# Azure: list cost recommendations from Azure Advisor&lt;/span&gt;
az advisor recommendation list &lt;span class="nt"&gt;--category&lt;/span&gt; Cost &lt;span class="nt"&gt;-o&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change one thing at a time. Watch performance for a week. Then move on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Commit for steady use
&lt;/h2&gt;

&lt;p&gt;Some workloads run 24/7 and will keep running. For those, a commitment is usually cheaper than on-demand pricing.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AWS:&lt;/strong&gt; &lt;strong&gt;Savings Plans&lt;/strong&gt; and &lt;strong&gt;Reserved Instances&lt;/strong&gt;, with 1-year or 3-year terms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Azure:&lt;/strong&gt; &lt;strong&gt;Azure Reservations&lt;/strong&gt; and the &lt;strong&gt;Azure savings plan for compute&lt;/strong&gt;, also with 1-year or 3-year terms.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Simple rule: rightsize first, then commit. If you commit to a server that is too big, you lock in the waste.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg0h6okhxowejip02dsfj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg0h6okhxowejip02dsfj.png" alt=" " width="799" height="251"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 4. Steps 4 and 5 work together: rightsize first, then commit.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Set budgets and alerts
&lt;/h2&gt;

&lt;p&gt;A budget will not fix waste. But it tells you early when something goes wrong.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AWS Budgets:&lt;/strong&gt; Create a monthly cost budget. Add alerts at levels you choose, for example when actual or forecast spend crosses part of the budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Azure Cost Management budgets:&lt;/strong&gt; Create a budget at the subscription or resource group level. Add alert conditions and email recipients. You can also connect an action group (a set of actions Azure runs when an alert fires).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Note: by default, alerts only notify people. They do not stop resources. Make sure a real person reads them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F87o1ylhoi8m64o73ph2y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F87o1ylhoi8m64o73ph2y.png" alt=" " width="800" height="260"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 5. A budget alert warns a person early. By default, it does not stop anything.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Review every month
&lt;/h2&gt;

&lt;p&gt;Put a short meeting on the calendar. Same day each month. Use the saved view from Step 1.&lt;/p&gt;

&lt;p&gt;Ask four questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What went up, and why?&lt;/li&gt;
&lt;li&gt;Are there new untagged resources?&lt;/li&gt;
&lt;li&gt;Are there new idle disks or IPs?&lt;/li&gt;
&lt;li&gt;Do our commitments still match our steady usage?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. Small, boring, and it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick copy-paste checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Cost Explorer and Cost Management views saved&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;owner&lt;/code&gt;, &lt;code&gt;env&lt;/code&gt;, &lt;code&gt;project&lt;/code&gt; tags on everything (and activated on AWS)&lt;/li&gt;
&lt;li&gt;[ ] Unattached disks and unused IPs reviewed&lt;/li&gt;
&lt;li&gt;[ ] Non-prod VMs shut down at night&lt;/li&gt;
&lt;li&gt;[ ] Rightsizing advice reviewed (Compute Optimizer, Azure Advisor)&lt;/li&gt;
&lt;li&gt;[ ] Commitments only for steady, right-sized workloads&lt;/li&gt;
&lt;li&gt;[ ] Budgets and alerts set, with a named owner&lt;/li&gt;
&lt;li&gt;[ ] Monthly review on the calendar&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;About the author:&lt;/strong&gt; CloudSkill Consulting LLC is a cloud consulting firm in Omaha, Nebraska, founded in September 2024. We work on AWS, Microsoft Azure and Google Cloud, with a focus on cost control (FinOps), multi-cloud planning and migration. We also provide technical and IT services across the USA. If this checklist helped, you can find more at &lt;a href="https://cloudskillconsulting.com" rel="noopener noreferrer"&gt;cloudskillconsulting.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>azure</category>
      <category>finops</category>
      <category>cloud</category>
    </item>
  </channel>
</rss>
