<?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: TOMOAKI ishihara</title>
    <description>The latest articles on DEV Community by TOMOAKI ishihara (@issy929).</description>
    <link>https://dev.to/issy929</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%2F2925874%2F3ce4b2bd-9f95-499f-85a4-4170ed4c116a.jpg</url>
      <title>DEV Community: TOMOAKI ishihara</title>
      <link>https://dev.to/issy929</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/issy929"/>
    <language>en</language>
    <item>
      <title>Automating CodeCommit PR Reviews with Amazon Bedrock: 986 Reviews in 10 Weeks, $0.18 Each, and What We Could Not Measure</title>
      <dc:creator>TOMOAKI ishihara</dc:creator>
      <pubDate>Thu, 29 Oct 2026 13:43:21 +0000</pubDate>
      <link>https://dev.to/aws-builders/automating-codecommit-pr-reviews-with-amazon-bedrock-986-reviews-in-10-weeks-018-each-and-what-28e6</link>
      <guid>https://dev.to/aws-builders/automating-codecommit-pr-reviews-with-amazon-bedrock-986-reviews-in-10-weeks-018-each-and-what-28e6</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A team of 5 ran 986 reviews over about 10 weeks on a single CDK infrastructure repository. Each review took about 60 seconds on average, and the actual Bedrock bill totaled $180.60 (about $0.18 per review)&lt;/li&gt;
&lt;li&gt;Review count, cost, and duration are now visible as numbers, but we still can't measure whether the findings were correct, or how lead time changed before and after adoption&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Introduction&lt;/li&gt;
&lt;li&gt;Architecture Overview&lt;/li&gt;
&lt;li&gt;How the PR Review Works&lt;/li&gt;
&lt;li&gt;Measuring the Effect&lt;/li&gt;
&lt;li&gt;Visualization&lt;/li&gt;
&lt;li&gt;Thinking About Cost&lt;/li&gt;
&lt;li&gt;Results in Production&lt;/li&gt;
&lt;li&gt;Design Decisions&lt;/li&gt;
&lt;li&gt;Limitations and Future Improvements&lt;/li&gt;
&lt;li&gt;Conclusion&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;CodeCommit is a very convenient service that lets you keep your whole development environment inside AWS. However, compared with GitHub, its surrounding ecosystem is limited. An OSS tool such as &lt;a href="https://github.com/The-PR-Agent/pr-agent" rel="noopener noreferrer"&gt;Qodo AI's PR-Agent&lt;/a&gt; has a provider for CodeCommit, but its features are limited and it assumes you use it from the CLI.&lt;/p&gt;

&lt;p&gt;So I built a mechanism that reviews CodeCommit pull requests (PRs) with Amazon Bedrock and posts the result as comments. I did not use the OSS PR-Agent. Instead, I built it from scratch with EventBridge and Lambda so that it runs automatically whenever a PR is created or updated. Many of the articles I referred to ended at "post the comment", but once you think about operating it, you also need to make the following visible:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How much does it cost per month?&lt;/li&gt;
&lt;li&gt;Whose PRs are they, and how many tokens does each model use?&lt;/li&gt;
&lt;li&gt;Are findings actually being raised, and if so, which severity is most common?&lt;/li&gt;
&lt;li&gt;Has the time until a PR is merged changed?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This article also explains how to collect and report these numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&gt;

&lt;p&gt;The overall architecture looks like this.&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%2Fhyf4b51qmgyl8seo7bog.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%2Fhyf4b51qmgyl8seo7bog.png" alt="Architecture diagram (review, knowledge base, report generation)" width="800" height="431"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Full architecture diagram (including CDK test CI, usage dashboard, and Slack/Teams notifications)&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%2F0n0lvbkgd9npb68qjpb4.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%2F0n0lvbkgd9npb68qjpb4.png" alt="Full architecture diagram" width="800" height="1162"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the review flow after a PR is made.&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%2Fi7htem7faeipmg2w57g2.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%2Fi7htem7faeipmg2w57g2.png" alt="PR review flow" width="799" height="333"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;EventBridge receives PR creation and update events, and a Lambda function (Python) builds the diff and passes it to Bedrock. The result is posted to CodeCommit as a PR comment. The posted comments look like the following (only the beginning is shown). Code and documents are posted as separate comments.&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%2Fsfijjmoi4ujyu9spiwwy.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%2Fsfijjmoi4ujyu9spiwwy.png" alt="Code review" width="800" height="320"&gt;&lt;/a&gt; &lt;em&gt;Code review&lt;/em&gt;&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%2Fbj7iqilr5ff13kuxvb50.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%2Fbj7iqilr5ff13kuxvb50.png" alt="Doc review" width="799" height="277"&gt;&lt;/a&gt; &lt;em&gt;Doc review&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The system keeps three kinds of data needed for visualization.&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%2F1a3b0wuk3zktw1xo2cni.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%2F1a3b0wuk3zktw1xo2cni.png" alt="Three layers for measuring the effect" width="800" height="417"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What you learn&lt;/th&gt;
&lt;th&gt;Source data&lt;/th&gt;
&lt;th&gt;Storage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;① Review content&lt;/td&gt;
&lt;td&gt;Findings (by severity), LGTM, agreement between models, duration, errors&lt;/td&gt;
&lt;td&gt;Bedrock structured output&lt;/td&gt;
&lt;td&gt;CloudWatch custom metrics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;② Cost and usage&lt;/td&gt;
&lt;td&gt;Token counts and estimated cost by repository, PR author, and model&lt;/td&gt;
&lt;td&gt;Bedrock invocation logs&lt;/td&gt;
&lt;td&gt;CloudWatch Logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;③ Development flow&lt;/td&gt;
&lt;td&gt;Time from PR creation to merge&lt;/td&gt;
&lt;td&gt;PR status change events&lt;/td&gt;
&lt;td&gt;JSON in S3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How the PR Review Works
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Building the diff from blobs
&lt;/h3&gt;

&lt;p&gt;CodeCommit's &lt;code&gt;GetDifferences&lt;/code&gt; returns the list of changed files and their blob IDs, but not the diff text. So I fetch the before and after blobs with &lt;code&gt;GetBlob&lt;/code&gt; and assemble a unified diff with Python's &lt;code&gt;difflib&lt;/code&gt;.&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="n"&gt;diff_lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;difflib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unified_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;before_content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;after_content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;fromfile&lt;/span&gt;&lt;span class="o"&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;a/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tofile&lt;/span&gt;&lt;span class="o"&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;b/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;# lines of context around each change
&lt;/span&gt;    &lt;span class="n"&gt;lineterm&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="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is how each change type is handled. Only the before/after diff is passed to the model, so unchanged lines consume no tokens.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change type&lt;/th&gt;
&lt;th&gt;What is passed to the model&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Modified (M)&lt;/td&gt;
&lt;td&gt;Unified diff (with 5 lines of context before and after)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Added (A)&lt;/td&gt;
&lt;td&gt;The whole file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deleted (D)&lt;/td&gt;
&lt;td&gt;Path only (noted as "deleted")&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Binary / empty&lt;/td&gt;
&lt;td&gt;No body (noted as such)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I also set an upper limit on the number of diff lines handled in one review. When the limit is reached, the rest is cut off per file, and the PR comment states that it was truncated.&lt;/p&gt;

&lt;h3&gt;
  
  
  Routing by path
&lt;/h3&gt;

&lt;p&gt;Changed files are classified by path into &lt;code&gt;code&lt;/code&gt; / &lt;code&gt;document&lt;/code&gt; / &lt;code&gt;skip&lt;/code&gt;. Code and documents are reviewed separately, and paths that match &lt;code&gt;skip&lt;/code&gt; are excluded from the review. The patterns are set with the Lambda environment variables &lt;code&gt;DOCUMENT_PATH_PATTERNS&lt;/code&gt; and &lt;code&gt;SKIP_PATH_PATTERNS&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Skipping a review
&lt;/h3&gt;

&lt;p&gt;I also added a feature to skip the review of a whole PR when the title contains &lt;code&gt;[skip-review]&lt;/code&gt;. Excluding by path is also possible, but writing it in the title skips the review immediately. In this repository, documentation is generated as HTML with MkDocs, so regenerating it doesn't trigger a review of a huge number of files.&lt;/p&gt;

&lt;h3&gt;
  
  
  Combining multiple models with Agents-as-Tools
&lt;/h3&gt;

&lt;p&gt;You can specify multiple models through parameters. When you specify multiple models for review, you can also specify a separate model that summarizes their results. For example, you can use two models, Nova Pro and Claude Sonnet 4.6, for review, and Claude Haiku 4.5 to summarize the results.&lt;/p&gt;

&lt;p&gt;In the per-environment parameter file, set &lt;code&gt;reviewMode&lt;/code&gt; to &lt;code&gt;multi&lt;/code&gt; and write the model IDs in &lt;code&gt;multiModelConfig&lt;/code&gt;. To run with a single model, use &lt;code&gt;reviewMode: "single"&lt;/code&gt; and &lt;code&gt;singleModelConfig: { modelId }&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;reviewMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;multi&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="nx"&gt;multiModelConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;modelIds&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="s2"&gt;apac.amazon.nova-pro-v1:0&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="s2"&gt;jp.anthropic.claude-sonnet-4-6&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;  &lt;span class="c1"&gt;// reviewers&lt;/span&gt;
  &lt;span class="nx"&gt;summaryModelId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jp.anthropic.claude-haiku-4-5-20251001-v1:0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;              &lt;span class="c1"&gt;// merges the results&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reviewer models are exposed as &lt;code&gt;@tool&lt;/code&gt;s and called by the summary agent. At first, I passed the diff to each reviewer tool as an argument. However, since the one calling the tool is the summary agent (an LLM), it had to generate the diff character by character as an argument. The diff is large, so this generation took a long time and caused timeouts. Now the tools take no arguments. The prompt containing the diff is held in a variable outside the tool, and the tool references it directly. Because the diff no longer passes through the LLM, this problem doesn't occur.&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_make_reviewer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mid&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="nd"&gt;@tool&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reviewer_tool&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="c1"&gt;# no arguments: the diff never passes through the LLM
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;mid&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;reviewer_results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# each reviewer runs only once
&lt;/span&gt;            &lt;span class="n"&gt;sub_agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;_build_bedrock_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;repository_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pr_author&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="n"&gt;reviewer_results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sub_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt_text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# prompt_text holds the diff
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;reviewer_results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;mid&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;reviewer_tool&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Comment limit: 10,240 bytes
&lt;/h3&gt;

&lt;p&gt;CodeCommit comments have a &lt;a href="https://docs.aws.amazon.com/codecommit/latest/userguide/limits.html" rel="noopener noreferrer"&gt;limit&lt;/a&gt; of 10,240 bytes. Code reviews and document reviews are posted as separate comments, and if a comment still exceeds the limit, the end is truncated with a note that content was omitted.&lt;/p&gt;

&lt;h3&gt;
  
  
  Avoiding duplicate posts for the same commit
&lt;/h3&gt;

&lt;p&gt;An HTML comment containing the commit ID is appended to the end of each posted comment. Before reviewing, the Lambda reads the existing comments and skips the review if it finds the same marker and commit ID.&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="n"&gt;suffix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;!-- commit:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;commit_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; --&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# appended to every review comment
&lt;/span&gt;
&lt;span class="c1"&gt;# before reviewing: look through the existing comments on the PR
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;DUPLICATE_MARKER&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;source_commit_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;  &lt;span class="c1"&gt;# already reviewed this commit, skip
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Measuring the Effect
&lt;/h2&gt;

&lt;p&gt;This time, I designed and implemented the system with the goal not only of building the review mechanism but also of reporting the adoption effect inside the company. So I decided which numbers to collect up front, in parallel with implementing the review.&lt;/p&gt;

&lt;p&gt;That said, not everything was in place from the start. I added the metrics I needed little by little while operating the system.&lt;/p&gt;

&lt;h3&gt;
  
  
  ① Receive review content as structured data, not free text
&lt;/h3&gt;

&lt;p&gt;I make the summary agent always call a tool named &lt;code&gt;report_review_result&lt;/code&gt; at the end.&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="nd"&gt;@tool&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;report_review_result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;summary_markdown&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="c1"&gt;# the PR comment body
&lt;/span&gt;    &lt;span class="n"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;    &lt;span class="c1"&gt;# [{"severity": "critical|warning|info", "count": 2, "detected_by": ["nova", "sonnet"]}]
&lt;/span&gt;    &lt;span class="n"&gt;lgtm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&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;Report the final review result.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You could extract severity from the comment's Markdown with a regular expression, but even a small change in the body's format would break it. If you receive it as tool arguments, the number of findings and LGTM can be used directly as numbers.&lt;/p&gt;

&lt;p&gt;The received values are sent to the &lt;code&gt;PRAgent/Review&lt;/code&gt; namespace with &lt;code&gt;PutMetricData&lt;/code&gt;.&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="nf"&gt;put_metric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FindingCount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dimensions&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;Repository&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ReviewGroup&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Severity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Environment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;env&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;These are the main metrics sent.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FindingCount&lt;/td&gt;
&lt;td&gt;Number of findings (by repository, review type, severity, and environment)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ReviewCount / ReviewLatencyMs&lt;/td&gt;
&lt;td&gt;Number of reviews and duration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ReviewWithinSLACount / ReviewExceededSLACount&lt;/td&gt;
&lt;td&gt;Whether the review finished within the target time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;InputTokenCount / OutputTokenCount&lt;/td&gt;
&lt;td&gt;Token counts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MultiModelAgreementCount / MultiModelUniqueCount&lt;/td&gt;
&lt;td&gt;Findings detected by 2 or more models / findings detected by only 1 model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ToolUseFallbackCount / KbFallbackCount / ErrorCount&lt;/td&gt;
&lt;td&gt;Fallbacks and errors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ChangedFileCount / ChunkCount / SkipCount&lt;/td&gt;
&lt;td&gt;PR size, number of chunks, and number of skips&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sending can be switched on and off with the deploy parameter &lt;code&gt;ENABLE_METRICS&lt;/code&gt;. If sending fails, the review itself does not fail; the error is only logged.&lt;/p&gt;

&lt;h4&gt;
  
  
  Agreement between models
&lt;/h4&gt;

&lt;p&gt;With two models, you can tell whether both raised the same finding or only one did. A finding with two or more entries in &lt;code&gt;detected_by&lt;/code&gt; counts as agreement, and one with a single entry counts as uniquely detected. A high agreement rate is a reason to cut down to one model and lower the cost, and a low rate suggests that using two is worthwhile. Note, however, that "findings both models agree on are correct" is not guaranteed (see the limitations below).&lt;/p&gt;

&lt;h3&gt;
  
  
  ② Cost and usage: add metadata to the invocation log
&lt;/h3&gt;

&lt;p&gt;If you enable Bedrock's model invocation logging, the token count of each call is stored in the log. However, as it is, you can't tell which repository and whose PR a call belongs to.&lt;/p&gt;

&lt;p&gt;Bedrock's &lt;code&gt;Converse&lt;/code&gt; has &lt;code&gt;requestMetadata&lt;/code&gt;, and the values you put there are written to the invocation log. With Strands' &lt;code&gt;BedrockModel&lt;/code&gt;, you can pass it through &lt;code&gt;additional_args&lt;/code&gt;.&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="nc"&gt;BedrockModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;model_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;additional_args&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;requestMetadata&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;repository&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prAuthor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;environment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;env&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reporting Lambda aggregates this log with Logs Insights.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fields @timestamp
| filter ispresent(requestMetadata.repository)
| stats sum(input.inputTokenCount) as inTok, sum(output.outputTokenCount) as outTok
  by requestMetadata.repository, modelId
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is an upper limit on the number of fields you can specify in &lt;code&gt;stats ... by&lt;/code&gt; in Logs Insights, so "repository × model" and "PR author × repository" are split into separate queries.&lt;/p&gt;

&lt;p&gt;Cost is estimated by multiplying the aggregated token counts by the configured unit prices. The unit prices are given in &lt;code&gt;modelPricing&lt;/code&gt; in the parameter file (USD per 1,000 tokens for input and output). This is only an estimate, so check the exact amount in Cost Explorer or similar.&lt;/p&gt;

&lt;h3&gt;
  
  
  ③ Record the time until merge
&lt;/h3&gt;

&lt;p&gt;A separate Lambda receives the PR status change event (&lt;code&gt;pullRequestStatusChanged&lt;/code&gt;) and records the lead time.&lt;/p&gt;

&lt;p&gt;Note that this event alone cannot tell you whether the PR was merged or closed without merging. So the Lambda calls &lt;code&gt;GetPullRequest&lt;/code&gt; and checks &lt;code&gt;pullRequestTargets[].mergeMetadata.isMerged&lt;/code&gt;. PRs that were closed without being merged are not recorded.&lt;/p&gt;

&lt;p&gt;Records are saved to S3 as JSON, partitioned by date.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reports/pr_lead_time/YYYY/MM/DD/&amp;lt;repository&amp;gt;_pr-&amp;lt;id&amp;gt;.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;"recordType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pr_merge_lead_time"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"repositoryName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sample-repo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"pullRequestId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"author"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"alice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mergeOption"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SQUASH_MERGE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"createdAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-01-01T00:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mergedAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-01-01T03:30:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"leadTimeSeconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"leadTimeHours"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;3.5&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;h2&gt;
  
  
  Visualization
&lt;/h2&gt;

&lt;p&gt;The collected numbers can be viewed in the following three ways.&lt;/p&gt;

&lt;h3&gt;
  
  
  CloudWatch dashboard
&lt;/h3&gt;

&lt;p&gt;The metrics from ① are shown as graphs such as the number of reviews per repository, findings by severity, agreement and unique detections between models, and token usage per model. Repositories come and go, so the widgets are built with &lt;code&gt;SEARCH&lt;/code&gt; expressions and no repository name is hard-coded.&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%2Fmqci9nn7ws22h3oyb7rm.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%2Fmqci9nn7ws22h3oyb7rm.png" alt="dashboard" width="800" height="319"&gt;&lt;/a&gt; &lt;em&gt;CloudWatch dashboard (excerpt showing findings by severity and token usage per model)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Alarms are set on review errors, SLA breaches, messages in the DLQ, and so on.&lt;/p&gt;

&lt;h3&gt;
  
  
  Daily, weekly, and monthly reports
&lt;/h3&gt;

&lt;p&gt;A report-generating Lambda is started on a schedule, runs Logs Insights, and saves JSON like the following to S3.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reports/daily/YYYY/MM/DD/report.json
reports/weekly/YYYY/Www/report.json
reports/bedrock/...        # Bedrock usage by model and PR author
reports/monthly/...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The content is sent as a notification through SNS.&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%2F1gorzp7chu8e844v7b27.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%2F1gorzp7chu8e844v7b27.png" alt="report-mail-monthly.png?raw=true" width="800" height="524"&gt;&lt;/a&gt; &lt;em&gt;Monthly report&lt;/em&gt;&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%2Fplxh1wgoyldjtgm05ukp.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%2Fplxh1wgoyldjtgm05ukp.png" alt="report-mail-daily.png?raw=true" width="800" height="439"&gt;&lt;/a&gt; &lt;em&gt;Daily report&lt;/em&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;"reportType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"daily"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"environment"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dev"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"periodStart"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-29T00:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"periodEnd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-30T00:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"generatedAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-30T00:05:40Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"summary"&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;"reviewCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"lgtmCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;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;"totalInputTokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1073657&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"totalOutputTokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;167242&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"avgLatencySeconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;72.403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"totalReviewedFiles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;188&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"totalReviewedLines"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12995&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="err"&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;h3&gt;
  
  
  Usage dashboard (optional)
&lt;/h3&gt;

&lt;p&gt;For those who want to see the JSON in S3 on a web page, I also built a static site on CloudFront + S3, and an API Gateway (HTTP API) protected by Cognito JWT authentication. The API reads the reports in S3 through a Lambda function and shows them on two screens: an overall view and a per-PR-author view.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Authentication uses Authorization Code + PKCE, and self sign-up is disabled&lt;/li&gt;
&lt;li&gt;The static site is locked down in S3 with OAC and is reachable only through CloudFront&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This can be enabled as an option.&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%2Flhxmxd0ve021uf9ljzr9.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%2Flhxmxd0ve021uf9ljzr9.png" alt="dashboard-opt-1" width="800" height="492"&gt;&lt;/a&gt; &lt;em&gt;Usage dashboard: overall (top part excerpted)&lt;/em&gt;&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%2Fqqpgiha6ylspynqsilg7.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%2Fqqpgiha6ylspynqsilg7.png" alt="dashboard-opt-2" width="800" height="648"&gt;&lt;/a&gt; &lt;em&gt;Usage dashboard: individual (top part excerpted; review lead time not yet supported)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Thinking About Cost
&lt;/h2&gt;

&lt;p&gt;If you have a few hundred PRs a month, the main cost is Bedrock token charges. The rough formula is as follows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Monthly cost ≈ number of PRs × reviews per PR × (input tokens × input price + output tokens × output price) × number of models
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The extra cost of collecting metrics is determined by the number of custom metrics (one per combination of dimensions) and the number of &lt;code&gt;PutMetricData&lt;/code&gt; calls. The count grows with the combinations of repository, severity, and environment, so check it if you have many repositories. Check the latest CloudWatch pricing page for unit prices.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results in Production
&lt;/h2&gt;

&lt;p&gt;These are the results of a development team (5 people) running the system for about 10 weeks on a single CDK infrastructure repository. The numbers come from the report JSON in S3 and the billed amounts in Cost Explorer. This environment also includes a few reviews of other repositories, but most are for the repository under evaluation. The setup used a single model (&lt;code&gt;reviewMode: "single"&lt;/code&gt;), Claude Sonnet 4.6 (cross-region inference). This is not an operating record of multiple models.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Month&lt;/th&gt;
&lt;th&gt;Reviews&lt;/th&gt;
&lt;th&gt;Findings (Critical / Warning / Info)&lt;/th&gt;
&lt;th&gt;LGTM&lt;/th&gt;
&lt;th&gt;Avg. duration&lt;/th&gt;
&lt;th&gt;Actual Bedrock bill&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;July (from the 23rd)&lt;/td&gt;
&lt;td&gt;73&lt;/td&gt;
&lt;td&gt;112 / 319 / 272&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;68 s&lt;/td&gt;
&lt;td&gt;$20.94&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;August&lt;/td&gt;
&lt;td&gt;362&lt;/td&gt;
&lt;td&gt;406 / 1,236 / 1,027&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;61 s&lt;/td&gt;
&lt;td&gt;$54.65&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;September&lt;/td&gt;
&lt;td&gt;551&lt;/td&gt;
&lt;td&gt;530 / 1,861 / 1,653&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;58 s&lt;/td&gt;
&lt;td&gt;$105.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total&lt;/td&gt;
&lt;td&gt;986&lt;/td&gt;
&lt;td&gt;1,048 / 3,416 / 2,952&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;about 60 s&lt;/td&gt;
&lt;td&gt;$180.60&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;On average, one review covered 12 files and about 300 lines, with 1.1 Critical and 3.5 Warning findings. No findings (LGTM) occurred in 21 of 986 reviews (about 2%), so almost every review raised something&lt;/li&gt;
&lt;li&gt;Duration averaged about 1 minute. The weekly p95 was in the 2–3 minute range, and the slowest week was about 5 minutes&lt;/li&gt;
&lt;li&gt;By author, 2 of the 5 people accounted for about 80% of Bedrock calls. The difference in how often people open PRs shows up directly&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Estimate vs. actual
&lt;/h3&gt;

&lt;p&gt;One review cost about $0.18. From July to September, the number of reviews grew about 7.5 times and the bill about 5 times. The per-review cost was $0.29 in July, $0.15 in August, and $0.19 in September.&lt;/p&gt;

&lt;p&gt;Estimating from token counts with the formula above (29.58 million input and 4.33 million output tokens, about $154 at Sonnet's public list price) came out about 15% lower than the actual bill. It seems better to check the exact amount in Cost Explorer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design Decisions
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;th&gt;Reason and trade-off&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Receiving results&lt;/td&gt;
&lt;td&gt;tool_use (structured output)&lt;/td&gt;
&lt;td&gt;Robust against changes in the body format. However, it counts the severity self-reported by the model as is, so correctness isn't guaranteed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sending metrics&lt;/td&gt;
&lt;td&gt;Call &lt;code&gt;PutMetricData&lt;/code&gt; directly&lt;/td&gt;
&lt;td&gt;About 10–15 calls per review is too little to justify EMF (estimated at about $0.015/month for 1,500 calls). Consider EMF if the volume grows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Detecting merges&lt;/td&gt;
&lt;td&gt;Check with &lt;code&gt;GetPullRequest&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The event alone can't distinguish a merge from a rejection. The trade-off is one extra API call per PR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adding data to invocation logs&lt;/td&gt;
&lt;td&gt;&lt;code&gt;requestMetadata&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Uses the values measured by Bedrock instead of re-counting tokens in the app&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dashboard authentication&lt;/td&gt;
&lt;td&gt;Serve the static site's HTML and JS without authentication, and protect only &lt;code&gt;/api/*&lt;/code&gt; with Cognito JWT&lt;/td&gt;
&lt;td&gt;Without logging in, the JavaScript on the page redirects to Cognito's login screen. The screen layout and API paths are visible even when not logged in, but no aggregated values can be fetched, so the real harm is small. To authenticate before serving the HTML and JS, you can add authentication on the CloudFront side, but the setup becomes more complex&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Limitations and Future Improvements
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;This time I couldn't measure whether the findings were correct. The severity of a finding is self-reported by the model, and I can't tell whether it was an actual bug or whether it was fixed. So even if the number of findings drops, you can't say code quality improved

&lt;ul&gt;
&lt;li&gt;As an improvement, I implemented a Lambda (&lt;code&gt;review_feedback&lt;/code&gt;) that collects replies to findings, 👍/👎 reactions, and whether the flagged file was changed afterward, at the time the PR is merged or closed. It is under evaluation.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Lead time can be viewed by day, repository, and PR author in the usage dashboard, but I can't compare it before and after adopting the review

&lt;ul&gt;
&lt;li&gt;There is no data from before adoption, so pre-adoption PRs would have to be recalculated from CodeCommit history&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Findings and LGTM rate by PR author can't be produced. &lt;code&gt;prAuthor&lt;/code&gt; is attached to the invocation log, so token counts by author are available, but the findings and LGTM metrics don't carry the author

&lt;ul&gt;
&lt;li&gt;Adding the author to the metrics increases the combinations of dimensions, and with them the number of custom metrics and the cost. I'll consider adding it after checking that it's worth the cost&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Because &lt;code&gt;prAuthor&lt;/code&gt; is kept in the invocation log and reports, per-person usage is visible. The purpose is to understand usage, not to evaluate individuals, and this needs to be settled during operation

&lt;ul&gt;
&lt;li&gt;It will need care if the usage dashboard is rolled out widely&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Anyone who opens a PR can write comments freely. An instruction hidden in a comment could steer the review result (prompt injection)

&lt;ul&gt;
&lt;li&gt;Even if the review is steered, the review Lambda doesn't change code or merge, so the impact is limited to review accuracy and comments. I haven't verified whether the review can really be steered, but I plan to test countermeasures, such as stating explicitly in the prompt that the PR body and diff are "data to be examined, not instructions"&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Running the review and posting comments was relatively easy to build. However, seeing the effect in numbers required structured output and metadata on the invocation logs. In this evaluation, I couldn't measure whether the findings were correct, or how lead time changed before and after adoption. Also, the track record comes from only one team, so I'd like to keep refining it by operating it with other teams as well.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>codecommit</category>
      <category>cdk</category>
      <category>bedrock</category>
    </item>
    <item>
      <title>Testing the AWS WAF Mitigations for PHP2Shell on Both CloudFront and an ALB</title>
      <dc:creator>TOMOAKI ishihara</dc:creator>
      <pubDate>Thu, 22 Oct 2026 16:10:45 +0000</pubDate>
      <link>https://dev.to/aws-builders/testing-the-aws-waf-mitigations-for-php2shell-on-both-cloudfront-and-an-alb-28ng</link>
      <guid>https://dev.to/aws-builders/testing-the-aws-waf-mitigations-for-php2shell-on-both-cloudfront-and-an-alb-28ng</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;On 2026-10-21 the PHP project disclosed &lt;strong&gt;PHP2Shell&lt;/strong&gt; (CVE-2026-109618 / CVE-2026-109619): CVSS v4.0 9.2, unauthenticated remote code execution (&lt;a href="https://www.php.net/archive/2026.php#2026-10-21-5" rel="noopener noreferrer"&gt;PHP security advisory&lt;/a&gt;, &lt;a href="https://cert.europa.eu/publications/security-advisories/2026-018/" rel="noopener noreferrer"&gt;CERT-EU Security Advisory 2026-018&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;As a stopgap until PHP can be upgraded, an &lt;a href="https://aws.amazon.com/blogs/security/using-aws-waf-to-apply-mitigations-for-php2shell-rce-vulnerabilities-cve-2026-109618-and-cve-2026-109619/" rel="noopener noreferrer"&gt;AWS Security Blog post&lt;/a&gt; published four AWS WAF custom rules: &lt;code&gt;PHP2Shell&lt;/code&gt;, &lt;code&gt;Block-Multipart-FormData&lt;/code&gt;, &lt;code&gt;Block-Oversize-Body&lt;/code&gt; and &lt;code&gt;RateLimit-Posts&lt;/code&gt;. The &lt;a href="https://aws.amazon.com/security/security-bulletins/2026-143-aws/" rel="noopener noreferrer"&gt;AWS Security Bulletin&lt;/a&gt; also points to &lt;code&gt;SizeRestrictions_BODY&lt;/code&gt; in &lt;code&gt;AWSManagedRulesCommonRuleSet&lt;/code&gt; and to the PHP managed rule group (version 2.3, now the default), which adds protection for CVE-2026-109619.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.classmethod.jp/en/articles/aws-security-bulletin-php2shell-waf-rules/" rel="noopener noreferrer"&gt;Classmethod&lt;/a&gt; (a Japanese company that provides AWS consulting and operations support) has already tested the AWS Security Blog rules thoroughly on an ALB, including the fact that &lt;code&gt;PHP_SESSION_UPLOAD_PROGRESS&lt;/code&gt; placed after the first 8 KB of the body goes through.&lt;/p&gt;

&lt;p&gt;This article reproduces that test on both CloudFront and an ALB and verifies what was still open:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How far does raising CloudFront's body inspection limit (16 KB default, 64 KB max) get you?&lt;/li&gt;
&lt;li&gt;How does the ALB's Web ACL behind CloudFront affect the result?&lt;/li&gt;
&lt;li&gt;What changes when the origin only accepts traffic through CloudFront?&lt;/li&gt;
&lt;li&gt;How long does the rate-based rule take to react once the threshold is crossed?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;📁 Code: &lt;a href="https://github.com/ishiharatma/aws-cdk-reference-architectures/tree/main/infrastructure/workspaces/waf-php2shell-mitigation" rel="noopener noreferrer"&gt;waf-php2shell-mitigation&lt;/a&gt; (part of &lt;a href="https://ishiharatma.github.io/aws-cdk-reference-architectures/" rel="noopener noreferrer"&gt;AWS CDK Reference Architectures&lt;/a&gt;).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;br&gt;
AWS WAF is a mitigation, not a fix. Upgrading to PHP 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 or later is the only complete remediation. PHP 8.1 and older are also affected but are end-of-life, so there is no official fixed release (&lt;a href="https://www.php.net/archive/2026.php#2026-10-21-5" rel="noopener noreferrer"&gt;the PHP team&lt;/a&gt; published minimal source backports, and some distributions and LTS vendors provide backported packages). Move to a supported version.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://aws.amazon.com/blogs/security/using-aws-waf-to-apply-mitigations-for-php2shell-rce-vulnerabilities-cve-2026-109618-and-cve-2026-109619/" rel="noopener noreferrer"&gt;The AWS Security Blog rules&lt;/a&gt; are a stopgap until PHP is upgraded; the AWS Security Blog post itself states that the real fix is upgrading PHP. As a stopgap, the rules behaved as specified on both CloudFront and the ALB.&lt;/p&gt;

&lt;p&gt;AWS WAF, however, inspects a request body only from the start up to a size limit: 8 KB on an ALB (fixed), 16 KB by default on CloudFront, up to 64 KB (&lt;a href="https://docs.aws.amazon.com/waf/latest/developerguide/web-acl-setting-body-inspection-limit.html" rel="noopener noreferrer"&gt;AWS WAF documentation&lt;/a&gt;). On the ALB, a string ending at byte 8,192 was blocked; shifting it by one byte let it through. The &lt;code&gt;Block-Oversize-Body&lt;/code&gt; rule from the same post (a size limit meant for CVE-2026-109618), when set to BLOCK, also stops requests whose bodies go past that limit.&lt;/p&gt;

&lt;p&gt;How to configure that rule depends on your setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you use CloudFront, raise the inspection limit to 64 KB and block bodies larger than 64 KB with &lt;code&gt;Block-Oversize-Body&lt;/code&gt;. The whole body is then inspected, and legitimate requests up to 64 KB still pass.&lt;/li&gt;
&lt;li&gt;With CloudFront + ALB + WAF, set the ALB's "block bodies over 8 KB" rule to COUNT and let the ALB accept traffic from CloudFront only. Left in BLOCK, the ALB stops legitimate requests that CloudFront already allowed. If the ALB Web ACL includes &lt;code&gt;AWSManagedRulesCommonRuleSet&lt;/code&gt;, override its &lt;code&gt;SizeRestrictions_BODY&lt;/code&gt; (blocks bodies over 8 KB) to COUNT for the same reason.&lt;/li&gt;
&lt;li&gt;If you expose only an ALB, blocking bodies over 8 KB is the baseline. According to the AWS Security Blog post, both CVEs sit on the &lt;code&gt;multipart/form-data&lt;/code&gt; processing path, so an app that takes no file uploads and only accepts JSON over 8 KB could block &lt;code&gt;Block-Multipart-FormData&lt;/code&gt; instead of using the size limit (not verified in this article). An app that accepts file uploads over 8 KB cannot be fully protected by WAF, so prioritize the PHP upgrade.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your app changes &lt;code&gt;session.name&lt;/code&gt; or &lt;code&gt;session.upload_progress.name&lt;/code&gt; from the defaults, rewrite the rule strings to the actual names. Matching is case-sensitive, so this also applies when only the letter case differs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&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%2Ff4vdd22qhl737hp1zic3.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%2Ff4vdd22qhl737hp1zic3.png" alt="Architecture" width="800" height="473"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client ─▶ CloudFront + Web ACL (us-east-1, 64 KB body inspection)
           ─▶ ALB + Web ACL (ap-northeast-1, fixed 8 KB)
              ─▶ Lambda container (nginx + PHP-FPM 8.4.27 + Lambda Web Adapter)
Client ─────────▶ ALB direct (comparison only, allow-listed IP)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A WAF decision does not depend on the PHP version behind it, so the origin runs a patched PHP 8.4.27 as a Lambda container image. On the PHP side, it applies these server-side mitigations from the AWS Security Blog post:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;; php.ini
&lt;/span&gt;&lt;span class="py"&gt;session.upload_progress.enabled&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="s"&gt;0&lt;/span&gt;
&lt;span class="py"&gt;memory_limit&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="s"&gt;-1        ; the real ceiling is the Lambda memory size&lt;/span&gt;
&lt;span class="py"&gt;post_max_size&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="s"&gt;8M&lt;/span&gt;
&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;; PHP-FPM pool
&lt;/span&gt;&lt;span class="py"&gt;pm&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="s"&gt;static&lt;/span&gt;
&lt;span class="py"&gt;pm.max_children&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="s"&gt;1&lt;/span&gt;
&lt;span class="py"&gt;pm.max_requests&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="s"&gt;1      ; replace the worker after every request&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Telling which WAF and which rule acted, from HTTP headers
&lt;/h2&gt;

&lt;p&gt;With WAF on both CloudFront and the ALB, a bare 403 does not tell you which layer answered. Every rule therefore names itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ruleAction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ruleName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RuleAction&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;wafv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CfnWebACL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RuleActionProperty&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BLOCK&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="na"&gt;block&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;customResponse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;responseCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;responseHeaders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-php2shell-blocked-by&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&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;label&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;ruleName&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="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="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;customRequestHandling&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;insertHeaders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`php2shell-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;label&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;ruleName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&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="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;counted&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="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;BLOCK returns &lt;code&gt;x-php2shell-blocked-by: cloudfront:PHP2Shell&lt;/code&gt;; COUNT inserts &lt;code&gt;x-amzn-waf-php2shell-alb-oversize: counted&lt;/code&gt; into the origin request, and the PHP app echoes it back. Headers inserted by the CloudFront Web ACL travel through the ALB to the origin, and the BLOCK response header keeps its own name (no &lt;code&gt;x-amzn-waf-&lt;/code&gt; prefix).&lt;/p&gt;

&lt;p&gt;Note that clients can also send headers starting with &lt;code&gt;x-amzn-waf-&lt;/code&gt;. If your application changes its behavior based on these headers in production, add a rule that blocks &lt;code&gt;x-amzn-waf-&lt;/code&gt; headers arriving from outside, as the &lt;a href="https://docs.aws.amazon.com/waf/latest/developerguide/customizing-the-incoming-request.html" rel="noopener noreferrer"&gt;AWS WAF documentation&lt;/a&gt; describes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 1: rule conditions (reproducing the ALB test on both layers)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;via CloudFront&lt;/th&gt;
&lt;th&gt;ALB direct&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;POST, cookie &lt;code&gt;PHPSESSID&lt;/code&gt; + &lt;code&gt;PHP_SESSION_UPLOAD_PROGRESS&lt;/code&gt; in body&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;POST, both strings in body&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;POST, PROGRESS only / cookie only&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PUT, both strings in body&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;multipart PROGRESS field + cookie&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;lowercase cookie key &lt;code&gt;phpsessid&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;lowercase &lt;code&gt;php_session_upload_progress&lt;/code&gt; in body&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JSON or text/plain containing both strings (false positive)&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rule uses &lt;code&gt;TextTransformation: NONE&lt;/code&gt;, so it only matches PHP's default names. If your app renames the session cookie or the upload progress field, change the rule strings too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 2: string position vs inspection limit
&lt;/h2&gt;

&lt;p&gt;Cookie &lt;code&gt;PHPSESSID&lt;/code&gt;, with &lt;code&gt;PHP_SESSION_UPLOAD_PROGRESS&lt;/code&gt; ending at byte N (oversize rule in COUNT):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;N&lt;/th&gt;
&lt;th&gt;ALB direct&lt;/th&gt;
&lt;th&gt;CloudFront &lt;code&gt;KB_16&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;CloudFront &lt;code&gt;KB_64&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;8,192&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8,193&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16,385&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;65,536&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;65,537&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;WAF logs agree: &lt;code&gt;requestBodySizeInspectedByWAF&lt;/code&gt; is &lt;code&gt;8192&lt;/code&gt; on the ALB and &lt;code&gt;65536&lt;/code&gt; on CloudFront &lt;code&gt;KB_64&lt;/code&gt;, and larger bodies carry &lt;code&gt;oversizeFields: ["REQUEST_BODY"]&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 3: oversize rule in BLOCK (8,192 bytes)
&lt;/h2&gt;

&lt;p&gt;Everything from 8,193 bytes is blocked regardless of position. So is a legitimate ~19 KB JSON POST with no rule string at all. An API that accepts JSON over 8 KB cannot use this as is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 4: with WAF on both CloudFront and the ALB, the ALB blocks legitimate requests
&lt;/h2&gt;

&lt;p&gt;CloudFront can inspect the first 64 KB of the body, so I raised CloudFront's &lt;code&gt;Block-Oversize-Body&lt;/code&gt; threshold to 65,536 bytes. CloudFront then passes legitimate requests up to 64 KB and blocks bodies too large to inspect in full.&lt;/p&gt;

&lt;p&gt;The ALB's Web ACL is where this goes wrong. Putting CloudFront in front of an ALB that already had WAF, or adding the same rules to both layers as defense in depth, is common. Even when the origin only accepts CloudFront, requests through CloudFront are evaluated again by the ALB Web ACL.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;ALB &lt;code&gt;Block-Oversize-Body&lt;/code&gt; (8,192 bytes)&lt;/th&gt;
&lt;th&gt;Legit 19 KB JSON&lt;/th&gt;
&lt;th&gt;PROGRESS at byte 65,537&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;BLOCK&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;403 &lt;code&gt;alb:Oversize&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;403 &lt;code&gt;cloudfront:Oversize&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;COUNT&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;403 &lt;code&gt;cloudfront:Oversize&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;(Both through CloudFront; CloudFront blocks above 65,536 bytes.)&lt;/p&gt;

&lt;p&gt;At first I thought raising CloudFront's limit would be enough. With the ALB rule in BLOCK, though, the ALB's 8 KB rule stops the 19 KB JSON that CloudFront inspected and allowed. To benefit from CloudFront's setting, the ALB size rule has to be COUNT.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 5: restricting ALB access to CloudFront
&lt;/h2&gt;

&lt;p&gt;COUNT on the ALB opens a different problem: a request sent straight to the ALB skips CloudFront, and the ALB inspects only 8 KB. A request with PROGRESS ending at byte 8,193, sent directly, was 403 with the ALB rule in BLOCK and 200 with it in COUNT.&lt;/p&gt;

&lt;p&gt;If you follow the &lt;a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/restrict-access-to-load-balancer.html" rel="noopener noreferrer"&gt;AWS best practice&lt;/a&gt; and restrict the ALB to traffic from CloudFront, the Result 4 fix (COUNT on the ALB) is not a problem. Setting it to COUNT while the ALB is still directly reachable, however, is dangerous.&lt;/p&gt;

&lt;p&gt;As the documentation describes, I combined a security group and a custom header. The security group admits only the CloudFront origin-facing managed prefix list (&lt;code&gt;com.amazonaws.global.cloudfront.origin-facing&lt;/code&gt;). That list is shared by every CloudFront distribution, so on its own it also lets other distributions in. I therefore set an &lt;code&gt;X-Origin-Verify&lt;/code&gt; origin custom header on CloudFront, and the ALB listener rule forwards only requests whose header value matches (default action 403).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cloudFrontPrefixList&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ec2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PrefixList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromLookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CloudFrontOriginFacing&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="na"&gt;prefixListName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;com.amazonaws.global.cloudfront.origin-facing&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="nx"&gt;albSecurityGroup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addIngressRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ec2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prefixList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cloudFrontPrefixList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prefixListId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;ec2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Port&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tcp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="nx"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FromCloudFront&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="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;conditions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;elbv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ListenerCondition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;httpHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-Origin-Verify&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="nx"&gt;originVerifyValue&lt;/span&gt;&lt;span class="p"&gt;])],&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;elbv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ListenerAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forward&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;targetGroup&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;This approach relies on keeping the header name and value secret. For this verification CloudFront talks to the ALB over HTTP (port 80); the documentation recommends HTTPS and rotating the header regularly. Another option is to keep the ALB off the internet and connect an internal ALB to CloudFront as a &lt;a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-vpc-origins.html" rel="noopener noreferrer"&gt;VPC origin&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For Results 1–4, I had added an exception that let my own IP address reach the ALB directly, for comparison. After removing that exception and redeploying, a direct &lt;code&gt;curl&lt;/code&gt; to the ALB got no response at all, not even a 403, and timed out: the security group drops the traffic before it reaches the ALB. Through CloudFront, requests still returned 200, and requests with the PHP2Shell pattern were still blocked with 403.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other observations
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;RateLimit-Posts&lt;/code&gt; (100 per 60 s, COUNT) at ~2.4 req/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;first match on request #138, 57 s in&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,100,000-byte body, ALB → Lambda&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;413&lt;/strong&gt; (1 MB Lambda target limit), not in the ALB WAF log&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WCU&lt;/td&gt;
&lt;td&gt;CloudFront 50 / ALB 45 (&lt;code&gt;AWSManagedRulesCommonRuleSet&lt;/code&gt; alone is 700)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pm.max_requests=1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;FPM worker PID changes on every request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lambda container cold start&lt;/td&gt;
&lt;td&gt;3.2 s first, 0.2 s after; ~48 MB memory&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rate-based rule first matched on request #138 (in COUNT; in BLOCK, blocking would start there). The threshold is 100 per 60 seconds, so about 16 seconds and 38 requests went through after it was crossed. If you set the threshold to exactly the number you can tolerate, requests that arrive while detection lags go through on top of it. The overshoot is roughly "request rate × lag": here, 2.4 req/s × ~16 s ≈ 38. Faster attacks overshoot more, so set the threshold below what you can tolerate. The &lt;a href="https://docs.aws.amazon.com/waf/latest/developerguide/waf-rule-statement-type-rate-based-caveats.html" rel="noopener noreferrer"&gt;AWS WAF documentation&lt;/a&gt; also says rate-based rules are not meant for precise rate limiting, and that the detection lag is usually under 30 seconds but can stretch to minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost
&lt;/h2&gt;

&lt;p&gt;About $40/month if left running (ALB ~$18, two Web ACLs $10, eight rules $8, plus requests). With &lt;code&gt;KB_64&lt;/code&gt;, only requests whose bodies exceed 16 KB pay an extra $0.30 per million per additional 16 KB. The verification itself, deploy to destroy within a few hours, cost under $1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;The AWS Security Blog rules behave as specified on both CloudFront and the ALB, but only for strings within the inspected part of the body. Even with CloudFront's limit raised to 64 KB, an 8 KB rule left on the ALB blocks legitimate requests, and a directly reachable ALB lets requests through with everything past 8 KB uninspected. Configure WAF for the whole request path rather than for CloudFront and the ALB separately.&lt;/p&gt;

&lt;p&gt;That said, every measure verified here is a mitigation. Upgrade PHP first.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/blogs/security/using-aws-waf-to-apply-mitigations-for-php2shell-rce-vulnerabilities-cve-2026-109618-and-cve-2026-109619/" rel="noopener noreferrer"&gt;Using AWS WAF to apply mitigations for PHP2Shell RCE vulnerabilities (AWS Security Blog)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.php.net/archive/2026.php#2026-10-21-5" rel="noopener noreferrer"&gt;PHP security advisory (2026-10-21)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w" rel="noopener noreferrer"&gt;PHP security advisory GHSA-w8hw-w9fx-j79w (CVE-2026-109618)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp" rel="noopener noreferrer"&gt;PHP security advisory GHSA-6wmf-4rv5-p3rp (CVE-2026-109619)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cert.europa.eu/publications/security-advisories/2026-018/" rel="noopener noreferrer"&gt;CERT-EU Security Advisory 2026-018: Critical Vulnerabilities in PHP&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/NCSC-NL/php2shell" rel="noopener noreferrer"&gt;NCSC-NL php2shell operational information&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/blogs/security/amazon-threat-intelligence-identifies-php2shell-exploitation-prior-to-public-disclosure-in-madpot/" rel="noopener noreferrer"&gt;Amazon Threat Intelligence identifies PHP2Shell exploitation prior to public disclosure in MadPot&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/security/security-bulletins/2026-143-aws/" rel="noopener noreferrer"&gt;AWS Security Bulletin 2026-143-AWS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.classmethod.jp/en/articles/aws-security-bulletin-php2shell-waf-rules/" rel="noopener noreferrer"&gt;Classmethod: Trying the AWS WAF rules from the AWS Security Bulletin (2026-143-AWS) and the AWS Security Blog to mitigate PHP2Shell&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/restrict-access-to-load-balancer.html" rel="noopener noreferrer"&gt;Restrict access to Application Load Balancers (CloudFront Developer Guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/waf/latest/developerguide/web-acl-setting-body-inspection-limit.html" rel="noopener noreferrer"&gt;AWS WAF: Considerations for managing body inspection&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/waf/latest/developerguide/waf-rule-statement-type-rate-based-caveats.html" rel="noopener noreferrer"&gt;AWS WAF: Rate-based rule caveats&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/waf/pricing/" rel="noopener noreferrer"&gt;AWS WAF pricing&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>security</category>
      <category>cdk</category>
      <category>php</category>
    </item>
    <item>
      <title>AWS CDK 100 Drill Exercises #013: CloudFront S3 Static Website — Serving a Private Bucket over HTTPS with OAC and Cross-Region WAF</title>
      <dc:creator>TOMOAKI ishihara</dc:creator>
      <pubDate>Sat, 29 Aug 2026 07:27:51 +0000</pubDate>
      <link>https://dev.to/aws-builders/aws-cdk-100-drill-exercises-013-cloudfront-s3-static-website-serving-a-private-bucket-over-413g</link>
      <guid>https://dev.to/aws-builders/aws-cdk-100-drill-exercises-013-cloudfront-s3-static-website-serving-a-private-bucket-over-413g</guid>
      <description>&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.amazonaws.com%2Fuploads%2Farticles%2Fvkpovqovupuz0fqscdot.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.amazonaws.com%2Fuploads%2Farticles%2Fvkpovqovupuz0fqscdot.png" alt="Level 200" width="70" height="20"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;This is the 13th installment of "AWS CDK 100 Drill Exercises." See &lt;a href="https://dev.to/aws-builders/introducing-aws-cdk-100-drill-exercises-learn-by-building-5949"&gt;here&lt;/a&gt; for an overview of the series.&lt;/p&gt;

&lt;p&gt;Last time (#012), we covered publishing a site using S3 static website hosting alone. That setup had two clear limitations: no HTTPS support, and the bucket itself sitting open to Principal &lt;code&gt;*&lt;/code&gt; (albeit IP-restricted).&lt;/p&gt;

&lt;p&gt;This time we rebuild the same static site behind &lt;strong&gt;Amazon CloudFront&lt;/strong&gt;, with a completely private S3 bucket. The bucket can never be read directly from the outside again, traffic is served as HTTPS over TLS 1.3, and you can optionally add defense-in-depth with WAFv2.&lt;/p&gt;

&lt;h3&gt;
  
  
  What you'll learn in this article
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;How to serve from CloudFront without ever making the S3 bucket public, using Origin Access Control (OAC)&lt;/li&gt;
&lt;li&gt;Applying security headers like CSP and HSTS via a &lt;code&gt;ResponseHeadersPolicy&lt;/code&gt;, and hiding the &lt;code&gt;Server&lt;/code&gt; header&lt;/li&gt;
&lt;li&gt;A design that rewrites &lt;code&gt;403&lt;/code&gt;/&lt;code&gt;404&lt;/code&gt; to &lt;code&gt;index.html&lt;/code&gt; for SPA client-side routing, while showing genuine &lt;code&gt;5xx&lt;/code&gt; failures with their real status codes intact&lt;/li&gt;
&lt;li&gt;How to handle the constraint that &lt;strong&gt;CLOUDFRONT-scoped WAFv2 is pinned to us-east-1&lt;/strong&gt; using &lt;code&gt;crossRegionReferences&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A two-stage IP allow-list design: "before managed rule evaluation" and "after managed rule evaluation"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;📁 &lt;strong&gt;Code repository&lt;/strong&gt;: &lt;a href="https://github.com/ishiharatma/aws-cdk-reference-architectures/tree/main/infrastructure/workspaces/cloudfront-s3-static-website" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&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%2Ffij6cvbfji1vz2qn7wqq.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%2Ffij6cvbfji1vz2qn7wqq.png" alt="Architecture Overview" width="593" height="444"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trait&lt;/th&gt;
&lt;th&gt;Benefit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Private bucket + OAC&lt;/td&gt;
&lt;td&gt;Unlike S3 website hosting, the bucket itself is never publicly reachable. Only CloudFront's OAC ID can read it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-region WAF stack&lt;/td&gt;
&lt;td&gt;The Web ACL and its related resources live in a stack pinned to us-east-1, with only the ARN passed to the main stack via &lt;code&gt;crossRegionReferences: true&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error mapping that doesn't hide real failures&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;403&lt;/code&gt;/&lt;code&gt;404&lt;/code&gt; go to the SPA's &lt;code&gt;index.html&lt;/code&gt;; &lt;code&gt;500&lt;/code&gt;-&lt;code&gt;504&lt;/code&gt; keep their original status codes and show a dedicated error page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single-flag toggles&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;enableWaf&lt;/code&gt; and &lt;code&gt;geoRestrictionCountries&lt;/code&gt; can each be turned on/off with one optional parameter&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Data Flow
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Viewer
  │  HTTPS (min TLS 1.3), optional geo-restriction
  ▼
CloudFront Distribution
  ├─ WAFv2 Web ACL (optional, us-east-1): managed rule groups + IP allow-list → default action: block
  ├─ ResponseHeadersPolicy: applies CSP + security headers to every response
  ├─ Default behavior "/*"
  │     └─ S3 origin (OAC) ───────────────────► WebsiteBucket (private)
  │
  └─ Error handling
        ├─ 403 / 404  → /index.html, HTTP 200, TTL 5 min
        └─ 500-504    → /error.html, original status code preserved, TTL 1 min
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Implementation Highlights
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Private bucket + OAC — the polar opposite of "who can read this"
&lt;/h3&gt;

&lt;p&gt;In the previous S3 website hosting version, the bucket was (conditionally) open to &lt;code&gt;Principal: *&lt;/code&gt;. This time it's the exact opposite: the bucket itself is completely private.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;websiteBucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createAccountRegionalBucket&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WebsiteBucket&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// no websiteIndexDocument or similar settings at all&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;distribution&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;cloudfront&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Distribution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WebsiteDistribution&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="na"&gt;defaultBehavior&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cloudfrontOrigins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;S3BucketOrigin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withOriginAccessControl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;websiteBucket&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;viewerProtocolPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cloudfront&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ViewerProtocolPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;REDIRECT_TO_HTTPS&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;S3BucketOrigin.withOriginAccessControl()&lt;/code&gt; handles both creating the OAC and adding a bucket policy scoped to the distribution's ARN in a single line. Nobody can read the bucket on its own — the only relationship that grants read access is a resource-level one with "this specific CloudFront distribution."&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Bulk security headers via &lt;code&gt;ResponseHeadersPolicy&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;CloudFront's &lt;code&gt;ResponseHeadersPolicy&lt;/code&gt; can inject response headers regardless of how the origin is implemented. On top of CSP, HSTS, and &lt;code&gt;X-Frame-Options&lt;/code&gt;, we overwrite the &lt;code&gt;Server&lt;/code&gt; header with an empty string to hide server information.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;securityHeadersBehavior&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;contentSecurityPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;contentSecurityPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;default-src 'self'; script-src 'self'; ...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;override&lt;/span&gt;&lt;span class="p"&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;span class="nx"&gt;strictTransportSecurity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;accessControlMaxAge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;days&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;365&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;includeSubdomains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;preload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;override&lt;/span&gt;&lt;span class="p"&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;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="nx"&gt;customHeadersBehavior&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;customHeaders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;override&lt;/span&gt;&lt;span class="p"&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;Even though this setup only serves static HTML, the CSP explicitly blocking anything beyond self-hosted scripts/styles — and not allowing &lt;code&gt;unsafe-inline&lt;/code&gt; — sets a useful design precedent for whenever a JS framework gets layered on top later.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Gentle with the SPA, honest about real failures
&lt;/h3&gt;

&lt;p&gt;CloudFront's custom error responses rewrite both &lt;code&gt;403&lt;/code&gt; and &lt;code&gt;404&lt;/code&gt; to &lt;code&gt;index.html&lt;/code&gt; + &lt;code&gt;200&lt;/code&gt;. This ensures that when a path that doesn't exist as an S3 object (an SPA client-side route) is accessed directly, control is correctly handed off to the app's own routing.&lt;/p&gt;

&lt;p&gt;Genuine origin/edge failures like &lt;code&gt;500&lt;/code&gt;-&lt;code&gt;504&lt;/code&gt;, on the other hand, keep their original status code and only swap in &lt;code&gt;error.html&lt;/code&gt; for the body.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;errorResponses&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="na"&gt;httpStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;responseHttpStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;responsePagePath&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="na"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&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="na"&gt;httpStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;responseHttpStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;responsePagePath&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="na"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// 5xx are genuine failures, not SPA routing concerns, so keep the status code as-is&lt;/span&gt;
  &lt;span class="c1"&gt;// and only replace the body, so origin error details aren't leaked&lt;/span&gt;
  &lt;span class="p"&gt;...[&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;504&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;httpStatus&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;httpStatus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;responseHttpStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;httpStatus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;responsePagePath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/error.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minutes&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;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;You'll often see implementations that just turn everything into a &lt;code&gt;200&lt;/code&gt; to paper over errors, but doing that buries genuine failures under a &lt;code&gt;200&lt;/code&gt; from a monitoring perspective. Here, &lt;code&gt;403&lt;/code&gt;/&lt;code&gt;404&lt;/code&gt; are treated as SPA-specific, while &lt;code&gt;5xx&lt;/code&gt; stays honest — the two roles are kept separate.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. CLOUDFRONT-scoped WAFv2 is pinned to us-east-1 — wire it with &lt;code&gt;crossRegionReferences&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;When you specify &lt;code&gt;scope: 'CLOUDFRONT'&lt;/code&gt; for a WAFv2 Web ACL, that Web ACL &lt;strong&gt;can only be created in us-east-1&lt;/strong&gt;, regardless of which region the distribution itself lives in. Try creating it elsewhere and you get rejected with &lt;code&gt;The scope is not valid.&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;In this reference implementation, all WAF-related resources are factored out into a separate stack (&lt;code&gt;CloudfrontWafStack&lt;/code&gt;) that always deploys to us-east-1. Only its ARN is passed to the main stack, using &lt;code&gt;crossRegionReferences: true&lt;/code&gt; set on both stacks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wafStack&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CloudfrontWafStack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;pascalCase&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;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;Waf`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;account&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;us-east-1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;crossRegionReferences&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;enableWaf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;enableWaf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;allowedIpsAfterRules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowedIps&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;mainStack&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CloudfrontS3StaticWebsiteStack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;pascalCase&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;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;Main`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;webAclArn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;wafStack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webAclArn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;crossRegionReferences&lt;/span&gt;&lt;span class="p"&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;span class="nx"&gt;mainStack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addStackDependency&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wafStack&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even when &lt;code&gt;enableWaf: false&lt;/code&gt; (or unset), &lt;code&gt;CloudfrontWafStack&lt;/code&gt; itself is still deployed — it just doesn't create a Web ACL, and &lt;code&gt;webAclArn&lt;/code&gt; becomes an empty string, so the distribution deploys without a &lt;code&gt;webAclId&lt;/code&gt;. By designing this as "don't create the Web ACL" rather than "remove the whole stack when WAF isn't needed," toggling &lt;code&gt;enableWaf&lt;/code&gt; on and off later never breaks the cross-region reference wiring itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Allow-listing before or after the managed rules
&lt;/h3&gt;

&lt;p&gt;The Web ACL's &lt;code&gt;defaultAction&lt;/code&gt; is &lt;code&gt;block&lt;/code&gt;. That means nothing gets through unless there's an explicit &lt;code&gt;Allow&lt;/code&gt; rule. This stack has a two-stage IP allow list.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;AllowSpecificIPsBeforeRules&lt;/code&gt;&lt;/strong&gt; (priority 1, created only when configured): bypasses evaluation of the AWS managed rule groups (Common/KnownBadInputs/AdminProtection/IpReputation/AnonymousIp) entirely&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;AllowSpecificIPsAfterRules&lt;/code&gt;&lt;/strong&gt; (priority 100, always created): evaluated &lt;strong&gt;after&lt;/strong&gt; the managed rule groups. When no IPs are specified, it defaults to allowing the entire IPv4/IPv6 address space (the managed rules still apply)
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;addresses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowedIpsAfterRules&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowedIpsAfterRules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;ip&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/32`&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0.0.0.0/1&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;128.0.0.0/1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;// WAF rejects /0, so the full IPv4 range is split into two /1s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the default wiring, the deploy operator's IP is passed as &lt;code&gt;allowedIpsAfterRules&lt;/code&gt;. That means even allow-listed IPs first get inspected for known attack patterns by rules like Core/KnownBadInputs before being let through. Use &lt;code&gt;allowedIpsBeforeRules&lt;/code&gt; only for the exceptional case where you want to bypass managed-rule false positives during active testing.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. WAF logs go directly to S3, with sensitive headers masked
&lt;/h3&gt;

&lt;p&gt;To deliver WAF logs directly to S3, the bucket name must start with &lt;code&gt;aws-waf-logs-&lt;/code&gt; (otherwise creating the &lt;code&gt;CfnLoggingConfiguration&lt;/code&gt; itself is rejected). On top of that, &lt;code&gt;authorization&lt;/code&gt; and &lt;code&gt;cookie&lt;/code&gt; headers are masked before being logged.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;redactedFields&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="na"&gt;singleHeader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;authorization&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;singleHeader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cookie&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="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Deploy &amp;amp; Verify
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PROJECT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-project
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ENV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev

npm run bootstrap   &lt;span class="c"&gt;# first time only&lt;/span&gt;
&lt;span class="c"&gt;# Also bootstrap us-east-1 once — the WAF stack deploys there and crossRegionReferences&lt;/span&gt;
&lt;span class="c"&gt;# needs that region bootstrapped as well:&lt;/span&gt;
&lt;span class="c"&gt;#   npx cdk bootstrap aws://&amp;lt;account-id&amp;gt;/us-east-1 --profile $PROJECT-$ENV&lt;/span&gt;

npm run stage:deploy:all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://&amp;lt;output WebsiteDistributionDomainName&amp;gt;/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;The reference &lt;code&gt;dev&lt;/code&gt; config ships with &lt;code&gt;enableWaf: true&lt;/code&gt; and &lt;code&gt;geoRestrictionCountries: ['JP']&lt;/code&gt;, so a straight deploy gives you a WAF-protected, Japan-only distribution. Adjust &lt;code&gt;parameters/dev-params.ts&lt;/code&gt; to change either.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Access from an IP not in the WAF's allow list, and WAF returns a &lt;code&gt;403&lt;/code&gt; before the request ever reaches CloudFront's own error page.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cost Estimate
&lt;/h2&gt;

&lt;p&gt;💰 Rough monthly estimate (Tokyo region, low traffic)&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Usage&lt;/th&gt;
&lt;th&gt;Rough monthly cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CloudFront&lt;/td&gt;
&lt;td&gt;Low request volume, minimal data transfer&lt;/td&gt;
&lt;td&gt;~$1-2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS WAF&lt;/td&gt;
&lt;td&gt;Web ACL ($5) + 6 rules ($1 each), low request volume&lt;/td&gt;
&lt;td&gt;~$11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 (2 buckets)&lt;/td&gt;
&lt;td&gt;A few MB of content + access/WAF logs&lt;/td&gt;
&lt;td&gt;Under $0.10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Total (WAF enabled): ~$12-14/month&lt;br&gt;
Total (&lt;code&gt;enableWaf: false&lt;/code&gt;): ~$1-2/month&lt;/p&gt;

&lt;p&gt;WAF is billed per Web ACL, per rule, and per evaluated request, making it the dominant cost factor at low traffic volumes. For pure demo use where IP/geo restriction isn't needed, &lt;code&gt;enableWaf: false&lt;/code&gt; significantly lowers the cost.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;What we learned from this pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Origin Access Control lets you serve from CloudFront without ever making the S3 bucket public.&lt;/strong&gt; The bucket alone is unreadable by anyone — the polar opposite of last time's setup&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ResponseHeadersPolicy&lt;/code&gt; lets you apply security headers in bulk, regardless of the origin's implementation.&lt;/strong&gt; Even for a static site, designing the CSP to explicitly deny anything beyond self-hosted resources holds up well for future expansion&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't conflate error responses for SPA routing rewrites with visibility into genuine failures.&lt;/strong&gt; &lt;code&gt;403&lt;/code&gt;/&lt;code&gt;404&lt;/code&gt; become &lt;code&gt;200&lt;/code&gt;; &lt;code&gt;5xx&lt;/code&gt; keeps its status code — both are "show a friendly page," but they mean very different things&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CLOUDFRONT-scoped WAFv2 is pinned to us-east-1.&lt;/strong&gt; This constraint applies regardless of which region the distribution itself lives in, and it never goes away. Separating the feature on/off toggle from the region-constraint wiring via &lt;code&gt;crossRegionReferences: true&lt;/code&gt; makes future changes more resilient&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;defaultAction: block&lt;/code&gt; pairs naturally with a design where even allow-listed IPs still get inspected for attack patterns once.&lt;/strong&gt; Allowing before managed-rule evaluation should stay the exception, not the norm&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&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;Restricting access to an Amazon S3 origin with OAC&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/response-headers-policies.html" rel="noopener noreferrer"&gt;Adding a response headers policy&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/waf/latest/developerguide/waf-chapter.html" rel="noopener noreferrer"&gt;AWS WAF Developer Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/georestrictions.html" rel="noopener noreferrer"&gt;Restricting the geographic distribution of your content&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/cdk/v2/guide/environments.html" rel="noopener noreferrer"&gt;Cross-region references in CDK&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/cdklabs/cdk-nag" rel="noopener noreferrer"&gt;cdk-nag&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Let's keep learning practical AWS CDK patterns through the 100 drill exercises!&lt;br&gt;
If you found this helpful, please ⭐ the repository!&lt;/p&gt;

&lt;p&gt;📌 You can see the entire code in &lt;a href="https://github.com/ishiharatma/aws-cdk-reference-architectures" rel="noopener noreferrer"&gt;my GitHub repository&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cdk</category>
      <category>100drillexercises</category>
      <category>cloudfront</category>
    </item>
    <item>
      <title>AWS CDK 100 Drill Exercises #012: S3 Static Web Site — Getting a "Public" Bucket Policy Past Block Public Access</title>
      <dc:creator>TOMOAKI ishihara</dc:creator>
      <pubDate>Sat, 29 Aug 2026 07:27:43 +0000</pubDate>
      <link>https://dev.to/aws-builders/aws-cdk-100-drill-exercises-012-s3-static-web-site-getting-a-public-bucket-policy-past-block-3b18</link>
      <guid>https://dev.to/aws-builders/aws-cdk-100-drill-exercises-012-s3-static-web-site-getting-a-public-bucket-policy-past-block-3b18</guid>
      <description>&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.amazonaws.com%2Fuploads%2Farticles%2Fz27r3b4j3ikksvzdfre9.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.amazonaws.com%2Fuploads%2Farticles%2Fz27r3b4j3ikksvzdfre9.png" alt="Level 100" width="70" height="20"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;This is the 12th installment of "AWS CDK 100 Drill Exercises." See &lt;a href="https://dev.to/aws-builders/introducing-aws-cdk-100-drill-exercises-learn-by-building-5949"&gt;here&lt;/a&gt; for an overview of the series.&lt;/p&gt;

&lt;p&gt;This time we build the simplest way to publish a static site on AWS — &lt;strong&gt;S3 static website hosting&lt;/strong&gt; — with no CDN, no WAF, and no custom domain. It looks like a plain, unremarkable pattern, but implementing it surfaces one genuinely interesting question.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"A bucket policy that allows &lt;code&gt;s3:GetObject&lt;/code&gt; to &lt;code&gt;Principal: *&lt;/code&gt; should count as 'public' — so why does deploying it against a bucket with &lt;code&gt;BlockPublicAccess.BLOCK_ALL&lt;/code&gt; enabled succeed?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The short answer: S3's Block Public Access doesn't decide public vs. private purely based on whether &lt;code&gt;Principal&lt;/code&gt; is &lt;code&gt;*&lt;/code&gt;. This time we verify that behavior with actual CDK code.&lt;/p&gt;

&lt;h3&gt;
  
  
  What you'll learn in this article
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;The basics of S3 static website hosting (&lt;code&gt;websiteIndexDocument&lt;/code&gt;/&lt;code&gt;websiteErrorDocument&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;How the &lt;code&gt;aws:SourceIp&lt;/code&gt; condition exempts a "public" policy from S3's Block Public Access evaluation&lt;/li&gt;
&lt;li&gt;Why &lt;code&gt;enforceSSL&lt;/code&gt; can't be enabled, because the S3 website endpoint doesn't support HTTPS&lt;/li&gt;
&lt;li&gt;How to auto-detect the deploy operator's own global IP so the site is viewable immediately after deployment&lt;/li&gt;
&lt;li&gt;The limits of this pattern — what you can and can't do without CloudFront&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;📁 &lt;strong&gt;Code repository&lt;/strong&gt;: &lt;a href="https://github.com/ishiharatma/aws-cdk-reference-architectures/tree/main/infrastructure/workspaces/s3-static-web-site" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&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%2Ftu5j9xpiw12dem0h8nmj.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%2Ftu5j9xpiw12dem0h8nmj.png" alt="Architecture Overview" width="798" height="524"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trait&lt;/th&gt;
&lt;th&gt;Benefit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No CloudFront, no WAF, no ACM certificate&lt;/td&gt;
&lt;td&gt;The fastest, cheapest way to get a static site online. A solid baseline before moving on to &lt;a href="https://github.com/ishiharatma/aws-cdk-reference-architectures/tree/main/infrastructure/workspaces/cloudfront-s3-static-website" rel="noopener noreferrer"&gt;&lt;code&gt;cloudfront-s3-static-website&lt;/code&gt;&lt;/a&gt;, covered next time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An IP allow list that coexists with Block Public Access&lt;/td&gt;
&lt;td&gt;Lets you actually verify that a &lt;code&gt;Principal: *&lt;/code&gt; policy scoped with an &lt;code&gt;aws:SourceIp&lt;/code&gt; condition is exempted from S3's "public" determination&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Automatic detection of the operator's IP&lt;/td&gt;
&lt;td&gt;Detects the deploying machine's own IP via &lt;code&gt;curl&lt;/code&gt;, so the site is viewable right after deployment with no manual configuration&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Data Flow
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser
  │  HTTP (the S3 website endpoint doesn't support HTTPS)
  ▼
S3 bucket website endpoint (&amp;lt;bucket&amp;gt;.s3-website-&amp;lt;region&amp;gt;.amazonaws.com)
  │  Evaluated by bucket policy: source IP must match allowedIps/allowedIpv6s
  ▼
index.html / error.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every request is anonymous. The website endpoint doesn't support SigV4 authentication, so the only access control available is the bucket policy's &lt;code&gt;aws:SourceIp&lt;/code&gt; condition.&lt;/p&gt;




&lt;h2&gt;
  
  
  Implementation Highlights
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. &lt;code&gt;websiteIndexDocument&lt;/code&gt; alone doesn't finish the job — the tug-of-war with Block Public Access
&lt;/h3&gt;

&lt;p&gt;Just enabling S3 static website hosting is only a few lines of CDK code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Bucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WebsiteBucket&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="na"&gt;websiteIndexDocument&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="na"&gt;websiteErrorDocument&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error.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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But that alone leaves nobody able to view the site. New S3 buckets have public access blocked by default (at the account level), and this reference implementation also sets &lt;code&gt;blockPublicAccess: BLOCK_ALL&lt;/code&gt; on the bucket explicitly (via the repo's shared bucket helper) — so every anonymous &lt;code&gt;s3:GetObject&lt;/code&gt; gets denied. Simply turning Block Public Access off, though, risks unintentionally opening the bucket to the entire world.&lt;/p&gt;

&lt;p&gt;Instead, the stack keeps &lt;code&gt;blockPublicAccess: BLOCK_ALL&lt;/code&gt; in place and adds only a bucket policy scoped down by &lt;code&gt;aws:SourceIp&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;websiteBucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addToResourcePolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PolicyStatement&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;effect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ALLOW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;principals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AnyPrincipal&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
  &lt;span class="na"&gt;actions&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;s3:GetObject&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;websiteBucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arnForObjects&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="na"&gt;conditions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;IpAddress&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;aws:SourceIp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;allowedIps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;ip&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/32`&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At first glance, this policy — which includes &lt;code&gt;Principal: *&lt;/code&gt; — looks like it should get the &lt;code&gt;PutBucketPolicy&lt;/code&gt; call itself rejected by Block Public Access's &lt;code&gt;BlockPublicPolicy: true&lt;/code&gt;. In practice, though, the deployment succeeds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why&lt;/strong&gt;: when S3's Block Public Access decides "is this policy public?", it looks at more than just whether &lt;code&gt;Principal&lt;/code&gt; is &lt;code&gt;*&lt;/code&gt; — it also inspects the &lt;code&gt;Condition&lt;/code&gt; clause. To be judged &lt;em&gt;non&lt;/em&gt;-public, a statement has to be scoped to a fixed value of one of a specific set of keys: &lt;code&gt;aws:SourceIp&lt;/code&gt;, &lt;code&gt;aws:SourceArn&lt;/code&gt;, &lt;code&gt;aws:SourceVpc&lt;/code&gt;, &lt;code&gt;aws:SourceVpce&lt;/code&gt;, &lt;code&gt;aws:SourceOwner&lt;/code&gt;, &lt;code&gt;aws:SourceAccount&lt;/code&gt;, &lt;code&gt;aws:PrincipalOrgID&lt;/code&gt;, &lt;code&gt;s3:DataAccessPointArn&lt;/code&gt;, &lt;code&gt;s3:DataAccessPointAccount&lt;/code&gt;, or &lt;code&gt;aws:userid&lt;/code&gt;. A &lt;code&gt;Principal: *&lt;/code&gt; policy whose reachable callers are pinned down by an &lt;code&gt;aws:SourceIp&lt;/code&gt; CIDR condition therefore isn't treated as "public." (One caveat: an &lt;code&gt;aws:SourceIp&lt;/code&gt; range broader than &lt;code&gt;/8&lt;/code&gt; for IPv4 — e.g. &lt;code&gt;0.0.0.0/1&lt;/code&gt; — is still considered public. The &lt;code&gt;/32&lt;/code&gt; host addresses this stack uses are well within bounds.)&lt;/p&gt;

&lt;p&gt;This behavior also comes up frequently in the context of SCPs and IAM Access Analyzer, but actually deploying an IP-restricted bucket policy against a Block-Public-Access-enabled bucket in CDK and confirming it firsthand deepens the understanding considerably.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ Note: if you omit both &lt;code&gt;allowedIps&lt;/code&gt; and &lt;code&gt;allowedIpv6s&lt;/code&gt;, no bucket policy is added at all. &lt;code&gt;blockPublicAccess: BLOCK_ALL&lt;/code&gt; still applies, so the result is a site that's "unreachable by anyone," not "publicly open." This stack has no "allow everyone if unspecified" fallback.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  2. &lt;code&gt;enforceSSL: false&lt;/code&gt; — a setting that looks wrong at first glance
&lt;/h3&gt;

&lt;p&gt;Every other bucket in this repository follows a baseline policy of &lt;code&gt;enforceSSL: true&lt;/code&gt;, rejecting HTTP connections. &lt;code&gt;WebsiteBucket&lt;/code&gt; is the one exception, set to &lt;code&gt;enforceSSL: false&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Bucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scope&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="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;enforceSSL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// the website endpoint only supports HTTP&lt;/span&gt;
  &lt;span class="na"&gt;websiteIndexDocument&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="na"&gt;websiteErrorDocument&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&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 S3 static website hosting endpoint (&lt;code&gt;*.s3-website-&amp;lt;region&amp;gt;.amazonaws.com&lt;/code&gt;) doesn't support HTTPS at all, to begin with. Set &lt;code&gt;enforceSSL: true&lt;/code&gt; here, and the bucket policy would reject the very (plaintext HTTP) requests the website endpoint actually receives, breaking the site entirely.&lt;/p&gt;

&lt;p&gt;In other words, "I want this over HTTPS" isn't something this bucket can solve on its own — it's solved by putting CloudFront in front of it. That's exactly the bridge to &lt;a href="https://github.com/ishiharatma/aws-cdk-reference-architectures/tree/main/infrastructure/workspaces/cloudfront-s3-static-website" rel="noopener noreferrer"&gt;&lt;code&gt;cloudfront-s3-static-website&lt;/code&gt;&lt;/a&gt;, covered in the next article.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Auto-detecting the deploy operator's IP so the site is instantly viewable
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;bin/s3-static-web-site.ts&lt;/code&gt; fetches the deploying machine's own global IP via &lt;code&gt;curl&lt;/code&gt; and feeds it straight into the bucket policy's allow list.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allowedIps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseIpListEnv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ALLOWED_IPS&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="nf"&gt;getMyGlobalIp&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;allowedIpv6s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseIpListEnv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ALLOWED_IPV6S&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="o"&gt;=&amp;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;myIpv6&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getMyGlobalIpv6&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;myIpv6&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;myIpv6&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;IPv6 detection (&lt;code&gt;getMyGlobalIpv6()&lt;/code&gt;) returns &lt;code&gt;undefined&lt;/code&gt; quietly when it can't be determined (e.g. in a devcontainer/CI environment with no IPv6 connectivity), while IPv4 detection (&lt;code&gt;getMyGlobalIp()&lt;/code&gt;) &lt;strong&gt;throws an exception on failure&lt;/strong&gt;. That means deploying from a fully offline machine fails unless you explicitly set the &lt;code&gt;ALLOWED_IPS&lt;/code&gt; environment variable. The same override applies when the machine you're deploying from differs from the machine whose browser will view the site (e.g. deploying from a devcontainer but browsing from the host PC) — use &lt;code&gt;ALLOWED_IPS&lt;/code&gt;/&lt;code&gt;ALLOWED_IPV6S&lt;/code&gt; explicitly in that case too.&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;ALLOWED_IPS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;203.0.113.10,203.0.113.20 &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nv"&gt;ALLOWED_IPV6S&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2001:db8::1 &lt;span class="se"&gt;\&lt;/span&gt;
npm run stage:deploy:all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Deploy &amp;amp; Verify
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PROJECT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-project
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ENV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev

npm run bootstrap   &lt;span class="c"&gt;# first time only&lt;/span&gt;
npm run stage:deploy:all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://&amp;lt;output WebsiteBucketUrl&amp;gt;/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Access from an IP not on the allow list, and the S3 website endpoint returns an HTTP &lt;code&gt;403&lt;/code&gt; (Access Denied) page.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cost Estimate
&lt;/h2&gt;

&lt;p&gt;💰 Rough monthly estimate (Tokyo region, low traffic)&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Usage&lt;/th&gt;
&lt;th&gt;Rough monthly cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;S3 storage&lt;/td&gt;
&lt;td&gt;A few MB of static content&lt;/td&gt;
&lt;td&gt;Under $0.01&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 requests&lt;/td&gt;
&lt;td&gt;Low-volume GET&lt;/td&gt;
&lt;td&gt;Under $0.01&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 server access logs&lt;/td&gt;
&lt;td&gt;Small volume&lt;/td&gt;
&lt;td&gt;Under $0.01&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Total: under $0.05/month&lt;/p&gt;

&lt;p&gt;With no CloudFront, no WAF, and no compute in the picture at all, the cost is essentially just S3 storage and request charges.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;What we learned from this pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;S3's Block Public Access evaluation doesn't decide public/private purely by whether &lt;code&gt;Principal&lt;/code&gt; is &lt;code&gt;*&lt;/code&gt;.&lt;/strong&gt; Statements scoped by a specific set of condition keys that includes &lt;code&gt;aws:SourceIp&lt;/code&gt; are exempted from that "public" determination&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The S3 static website hosting endpoint is HTTP-only.&lt;/strong&gt; Enabling &lt;code&gt;enforceSSL&lt;/code&gt; breaks the site entirely — going HTTPS requires an additional layer such as CloudFront&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The "fallback when unspecified" behavior isn't consistent across helpers.&lt;/strong&gt; IPv6 detection quietly returns &lt;code&gt;undefined&lt;/code&gt; on failure, while IPv4 detection throws. When using helper functions, design your error handling around this asymmetry rather than assuming both behave the same&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An IP allow list is not a substitute for authentication.&lt;/strong&gt; &lt;code&gt;aws:SourceIp&lt;/code&gt; is only evaluated per request, so while it's useful as a restriction for demos or personal use, it's insufficient as access control tied to a specific individual&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Next time, we'll evolve this same workspace into CloudFront + WAF + OAC to add HTTPS and a private origin, in &lt;a href="https://github.com/ishiharatma/aws-cdk-reference-architectures/tree/main/infrastructure/workspaces/cloudfront-s3-static-website" rel="noopener noreferrer"&gt;&lt;code&gt;cloudfront-s3-static-website&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/WebsiteHosting.html" rel="noopener noreferrer"&gt;Hosting a static website using Amazon S3&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-control-block-public-access.html" rel="noopener noreferrer"&gt;Blocking public access to your Amazon S3 storage&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/example-bucket-policies.html#example-bucket-policies-use-case-3" rel="noopener noreferrer"&gt;Bucket policy examples — IP address condition&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_s3_deployment-readme.html" rel="noopener noreferrer"&gt;aws-s3-deployment module&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Let's keep learning practical AWS CDK patterns through the 100 drill exercises!&lt;br&gt;
If you found this helpful, please ⭐ the repository!&lt;/p&gt;

&lt;p&gt;📌 You can see the entire code in &lt;a href="https://github.com/ishiharatma/aws-cdk-reference-architectures" rel="noopener noreferrer"&gt;my GitHub repository&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cdk</category>
      <category>100drillexercises</category>
      <category>s3</category>
    </item>
    <item>
      <title>AWS CDK 100 Drill Exercises #011: A CodePipeline for CloudFront &amp; S3 — and a self-reference bug</title>
      <dc:creator>TOMOAKI ishihara</dc:creator>
      <pubDate>Sat, 29 Aug 2026 07:12:17 +0000</pubDate>
      <link>https://dev.to/aws-builders/aws-cdk-100-drill-exercises-011-a-codepipeline-for-cloudfront-s3-and-a-self-reference-bug-22ml</link>
      <guid>https://dev.to/aws-builders/aws-cdk-100-drill-exercises-011-a-codepipeline-for-cloudfront-s3-and-a-self-reference-bug-22ml</guid>
      <description>&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.amazonaws.com%2Fuploads%2Farticles%2Fr1wyy0xk7f46wb5kdjqb.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.amazonaws.com%2Fuploads%2Farticles%2Fr1wyy0xk7f46wb5kdjqb.png" alt="Level 300" width="70" height="20"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;This is the 11th installment of "AWS CDK 100 Drill Exercises." See &lt;a href="https://dev.to/aws-builders/introducing-aws-cdk-100-drill-exercises-learn-by-building-5949"&gt;here&lt;/a&gt; for an overview of the series.&lt;/p&gt;

&lt;p&gt;This time, the pattern itself is a plain one: a &lt;strong&gt;CodePipeline&lt;/strong&gt; that pulls a static site from CodeCommit, builds it with CodeBuild, uploads it to S3, and invalidates CloudFront. Nothing exotic. What made this one worth writing up is what happened when I actually wired the test suite up and ran it for the first time: the synthesized CloudFormation template came back with&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Template is undeployable, these resources have a dependency cycle: ... -&amp;gt; PipelineXXXX -&amp;gt; PipelineXXXX
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pipeline was referencing itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  What you'll learn in this article
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;A CodePipeline with &lt;strong&gt;no hand-written pipeline or build role&lt;/strong&gt; — each pipeline action grants itself only the resource-scoped permissions it needs (the invoked Lambdas still carry their own execution-role policies)&lt;/li&gt;
&lt;li&gt;Why "upload" and "delete stale files" are worth splitting into two separate pipeline stages instead of one &lt;code&gt;aws s3 sync --delete&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The CodePipeline &lt;strong&gt;continuation-token&lt;/strong&gt; pattern for polling a long-running operation (CloudFront invalidation) without blocking a Lambda invocation&lt;/li&gt;
&lt;li&gt;How a CDK token used &lt;em&gt;inside a pipeline's own stage configuration&lt;/em&gt; can create a genuine CloudFormation self-reference cycle — and how to avoid it&lt;/li&gt;
&lt;li&gt;Two more deploy-time landmines that only show up once you actually run &lt;code&gt;Template.fromStack()&lt;/code&gt;: an S3 bucket name that isn't lowercase, and a notification rule with zero targets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;📁 &lt;strong&gt;Code repository&lt;/strong&gt;: &lt;a href="https://github.com/ishiharatma/aws-cdk-reference-architectures/tree/main/infrastructure/workspaces/cicd-cloudfront-s3" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&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%2Fjqjc2z5iy91jx0gilacl.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%2Fjqjc2z5iy91jx0gilacl.png" alt="Architecture Overview" width="800" height="664"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Benefit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No custom pipeline/build IAM roles&lt;/td&gt;
&lt;td&gt;Every action grants itself exactly the resource-scoped permissions it needs when bound to the pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upload/cleanup split across stages&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;S3DeployAction&lt;/code&gt; uploads; a dedicated Lambda removes what's no longer in the build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Continuation-token invalidation&lt;/td&gt;
&lt;td&gt;CloudFront invalidation status is polled via CodePipeline's own re-invocation mechanism, not a blocking wait&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Opt-in approval + notifications&lt;/td&gt;
&lt;td&gt;A single &lt;code&gt;approvalTopicArn&lt;/code&gt; parameter turns on both a Manual Approval stage and pipeline-result notifications&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Data Flow
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CodeCommit (push to branch)
    │  EventBridge rule triggers the pipeline
    ▼
CodePipeline
  ├─ Source              : CodeCommitSourceAction → SourceOutput artifact
  ├─ Build (optional)    : CodeBuildAction (buildspec.yml) → BuildOutput artifact   ← enabled by envParams.enableBuild
  ├─ Approval (optional) : ManualApprovalAction, notifies approvalTopicArn
  ├─ Deploy              : S3DeployAction → upload the build to the target bucket
  ├─ Sync                : Lambda → delete target-bucket objects not present in the build
  └─ InvalidateCache     : Lambda → CloudFront CreateInvalidation, polled via continuation token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;The Build stage is opt-in (&lt;code&gt;envParams.enableBuild&lt;/code&gt;, &lt;strong&gt;off by default&lt;/strong&gt; in the reference config). When it's disabled, the source artifact is deployed to S3 directly and the Deploy/Sync stages consume &lt;code&gt;SourceOutput&lt;/code&gt; instead of &lt;code&gt;BuildOutput&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Implementation Highlights
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Let CDK grant IAM permissions per action — don't hand-roll a pipeline role
&lt;/h3&gt;

&lt;p&gt;Every CodePipeline action construct (&lt;code&gt;CodeCommitSourceAction&lt;/code&gt;, &lt;code&gt;CodeBuildAction&lt;/code&gt;, &lt;code&gt;S3DeployAction&lt;/code&gt;, &lt;code&gt;LambdaInvokeAction&lt;/code&gt;) grants itself the exact, resource-scoped permissions it needs the moment it's bound to a stage. So the pipeline is created with no custom &lt;code&gt;role&lt;/code&gt; at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pipeline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;codepipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Pipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Pipeline&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="na"&gt;pipelineName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pipelineName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;restartExecutionOnUpdate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;artifactBucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;artifactBucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// no `role` — CDK creates one and grants each action's permissions as stages are added&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This reference implementation originally had a hand-written pipeline role with &lt;code&gt;artifactBucket.grantReadWrite()&lt;/code&gt;, &lt;code&gt;repository.grantRead()&lt;/code&gt;, and three &lt;code&gt;addToPolicy()&lt;/code&gt; calls including &lt;code&gt;codedeploy:*&lt;/code&gt; and &lt;code&gt;codestar-notifications:*&lt;/code&gt; on &lt;code&gt;Resource: '*'&lt;/code&gt;. None of those wildcard grants were actually needed — nothing in the pipeline used CodeDeploy, and CodeStarNotifications doesn't check permissions on the pipeline's own role at all. Once I traced through what each action construct grants on &lt;code&gt;bind()&lt;/code&gt; (&lt;code&gt;CodeBuildAction&lt;/code&gt; scopes &lt;code&gt;codebuild:BatchGetBuilds&lt;/code&gt;/&lt;code&gt;StartBuild&lt;/code&gt;/&lt;code&gt;StopBuild&lt;/code&gt; to the project ARN, &lt;code&gt;S3DeployAction&lt;/code&gt; calls &lt;code&gt;bucket.grantWrite()&lt;/code&gt; on the deploy target and &lt;code&gt;grantRead()&lt;/code&gt; on the artifact bucket, &lt;code&gt;LambdaInvokeAction&lt;/code&gt; calls &lt;code&gt;lambda.grantInvoke()&lt;/code&gt;, and the &lt;code&gt;Pipeline&lt;/code&gt; construct itself calls &lt;code&gt;artifactBucket.grantReadWrite(this.role)&lt;/code&gt; internally), the entire hand-written &lt;em&gt;pipeline&lt;/em&gt; role and its policies could just be deleted.&lt;/p&gt;

&lt;p&gt;The two Lambdas the pipeline invokes are a separate matter: their execution roles still get explicit, resource-scoped policies in the stack (&lt;code&gt;s3:ListBucket&lt;/code&gt;/&lt;code&gt;GetObject&lt;/code&gt;/&lt;code&gt;PutObject&lt;/code&gt;/&lt;code&gt;DeleteObject&lt;/code&gt; on the target bucket for the sync Lambda; &lt;code&gt;cloudfront:CreateInvalidation&lt;/code&gt;/&lt;code&gt;GetInvalidation&lt;/code&gt; and &lt;code&gt;sns:Publish&lt;/code&gt; for the invalidation Lambda), plus an &lt;code&gt;artifactBucket.grantRead()&lt;/code&gt; so the sync Lambda can read the input artifact. &lt;code&gt;LambdaInvokeAction&lt;/code&gt; only grants the &lt;em&gt;pipeline&lt;/em&gt; role permission to invoke the function — it doesn't touch the function's own role.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Split "upload" from "cleanup" instead of one &lt;code&gt;aws s3 sync --delete&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Deploy stage: upload/overwrite only&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;codepipeline_action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;S3DeployAction&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;actionName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;S3_Deploy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromBucketName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;TargetBucket&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;envParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deploymentTargetBucketName&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;buildOutput&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Sync stage: a separate Lambda removes stale objects afterward&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;codepipeline_action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;LambdaInvokeAction&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;actionName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Lambda_S3_Sync&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s3SyncLambda&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;buildOutput&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;userParameters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;DEST_BUCKET_NAME&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;envParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deploymentTargetBucketName&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;&lt;code&gt;S3DeployAction&lt;/code&gt; is a managed action, so the common "upload the build" case needs no custom code at all. Deletion — which requires diffing the new build's file list against what's already in the bucket — is isolated in its own Lambda, testable and replaceable independently of the upload step.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The continuation-token pattern for CloudFront invalidation
&lt;/h3&gt;

&lt;p&gt;CloudFront invalidations can take minutes to complete. Instead of a Lambda that polls in a loop until it's done (risking a timeout), the invalidation Lambda reports a &lt;code&gt;continuationToken&lt;/code&gt; back to CodePipeline (via &lt;code&gt;put_job_success_result&lt;/code&gt;), and CodePipeline re-invokes it until it reports success:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lambda_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="n"&gt;job_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&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;CodePipeline.job&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;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;job_data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&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;CodePipeline.job&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;data&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;continuationToken&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;job_data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Re-invoked: check on the invalidation we created last time
&lt;/span&gt;        &lt;span class="n"&gt;continuation_token&lt;/span&gt; &lt;span class="o"&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;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job_data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;continuationToken&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;invalidation_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;continuation_token&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;InvalidationId&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;monitor_invalidation_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;distribution_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;invalidation_id&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;status&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Completed&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;continue_job_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;invalidation_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# ask CodePipeline to call us again
&lt;/span&gt;        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;put_job_success&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# First invocation: create the invalidation and ask to be re-invoked
&lt;/span&gt;        &lt;span class="n"&gt;invalidation_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_invalidation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;distribution_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;continue_job_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;invalidation_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each invocation is short-lived; CodePipeline itself handles the "come back later and check again" scheduling. On completion (or failure) the Lambda also publishes a short status message to a dedicated SNS topic the stack always creates — that notification is independent of the opt-in &lt;code&gt;approvalTopicArn&lt;/code&gt; feature below.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The bug: a pipeline that referenced itself
&lt;/h3&gt;

&lt;p&gt;The InvalidateCache Lambda's &lt;code&gt;userParameters&lt;/code&gt; originally included the pipeline's own name, read straight off the construct:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before — looks harmless&lt;/span&gt;
&lt;span class="nx"&gt;userParameters&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="s2"&gt;PIPELINE_NAME&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pipelineName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DISTRIBUTION_ID&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;envParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cloudfrontDistributionId&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;&lt;code&gt;pipeline.pipelineName&lt;/code&gt; is a CDK token that resolves to &lt;code&gt;{ "Ref": "PipelineXXXX" }&lt;/code&gt;. And this &lt;code&gt;userParameters&lt;/code&gt; blob is itself embedded &lt;em&gt;inside&lt;/em&gt; one of the Pipeline resource's own &lt;code&gt;Stages[].Actions[].Configuration&lt;/code&gt; properties — because the InvalidateCache action is a stage of that very pipeline. The rendered template ends up with the Pipeline resource containing a &lt;code&gt;Ref&lt;/code&gt; to itself, which &lt;code&gt;Template.fromStack()&lt;/code&gt;'s cyclic-dependency checker correctly rejects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Template is undeployable, these resources have a dependency cycle: ... -&amp;gt; PipelineXXXX -&amp;gt; PipelineXXXX
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I never hit this while the stack only existed in &lt;code&gt;bin/&lt;/code&gt;, because nothing ever called &lt;code&gt;Template.fromStack()&lt;/code&gt; on it. The moment I wrote a real snapshot test, it surfaced immediately. The fix is to compute the name as a plain string &lt;em&gt;before&lt;/em&gt; constructing the &lt;code&gt;Pipeline&lt;/code&gt;, and reuse that instead of the token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Fixed — a real string, not a Ref back to the Pipeline resource&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pipelineName&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="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&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;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-pipeline`&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;pipeline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;codepipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Pipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Pipeline&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="na"&gt;pipelineName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pipelineName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// later, in the InvalidateCache stage:&lt;/span&gt;
&lt;span class="nl"&gt;userParameters&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="s2"&gt;PIPELINE_NAME&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pipelineName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// plain string, no cycle&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DISTRIBUTION_ID&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;envParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cloudfrontDistributionId&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 general lesson: any token that resolves to &lt;code&gt;Ref&lt;/code&gt;/&lt;code&gt;Fn::GetAtt&lt;/code&gt; on a construct is safe to use in most places, but if it ends up embedded inside that &lt;em&gt;same&lt;/em&gt; construct's own properties (because the reference lives inside an action/stage that belongs to that construct), CloudFormation sees a resource pointing at itself. If you already have the plain value on hand before construction, prefer it.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Two more bugs that only a real &lt;code&gt;Template.fromStack()&lt;/code&gt; run would catch
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;An S3 bucket name that wasn't guaranteed lowercase.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before&lt;/span&gt;
&lt;span class="nx"&gt;bucketName&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;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&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;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-artifact-bucket`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;S3 bucket names must be lowercase. A &lt;code&gt;props.project&lt;/code&gt; of &lt;code&gt;"TestProject"&lt;/code&gt; produced &lt;code&gt;InvalidBucketNameValue&lt;/code&gt; at synth time. The fix, matching the convention already used elsewhere in this repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;bucketName&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;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&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;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-artifact-bucket`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A &lt;code&gt;NotificationRule&lt;/code&gt; that could get zero targets.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The pipeline notification rule is only meaningful when an approval/notification topic is configured. The original code always created it, with an empty &lt;code&gt;targets: []&lt;/code&gt; when no topic was set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before — deploys fine when a topic is set, fails when it isn't&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;codestar_notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NotificationRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PipelineNotificationRule&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="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;envParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;approvalTopicArn&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;sns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Topic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromTopicArn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NotificationTopic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;envParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;approvalTopicArn&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AWS::CodeStarNotifications::NotificationRule&lt;/code&gt; requires at least one target — an empty list deploys to a CloudFormation error, not a synth-time one, which makes it even easier to miss in local testing. The fix is to skip creating the resource entirely when there's no topic to notify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;envParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;approvalTopicArn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;codestar_notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NotificationRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PipelineNotificationRule&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="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;sns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Topic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromTopicArn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NotificationTopic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;envParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;approvalTopicArn&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same &lt;code&gt;if&lt;/code&gt; also gates the Manual Approval stage, so a single optional parameter (&lt;code&gt;approvalTopicArn&lt;/code&gt;) turns the entire "gate deployments and notify someone" feature on or off per environment.&lt;/p&gt;




&lt;h2&gt;
  
  
  Deploy &amp;amp; Verify
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# The npm scripts read $PROJECT / $ENV and target the AWS named profile ${PROJECT}-${ENV}&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PROJECT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-project
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ENV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev

npm run bootstrap   &lt;span class="c"&gt;# first time only&lt;/span&gt;
npm run deploy:all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Push to the configured branch to trigger the pipeline, then check its status&lt;/span&gt;
aws codepipeline get-pipeline-state &lt;span class="nt"&gt;--name&lt;/span&gt; &amp;lt;project&amp;gt;-&amp;lt;&lt;span class="nb"&gt;env&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;-pipeline&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Cost Estimate
&lt;/h2&gt;

&lt;p&gt;💰 Rough monthly estimate (Tokyo region, ~20 pipeline executions/month)&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Usage&lt;/th&gt;
&lt;th&gt;Rough monthly cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CodePipeline (V2 pricing)&lt;/td&gt;
&lt;td&gt;~20 runs&lt;/td&gt;
&lt;td&gt;~$0.40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CodeBuild (&lt;code&gt;BUILD_GENERAL1_SMALL&lt;/code&gt;, ~3 min/build) — only when &lt;code&gt;enableBuild&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;~20 builds&lt;/td&gt;
&lt;td&gt;~$0.30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lambda (Sync + Invalidate)&lt;/td&gt;
&lt;td&gt;Light invocations&lt;/td&gt;
&lt;td&gt;~$0.05&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 artifact bucket&lt;/td&gt;
&lt;td&gt;A few MB&lt;/td&gt;
&lt;td&gt;&amp;lt; $0.05&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudWatch Logs (2 log groups, or 3 with the build stage; 1-week retention)&lt;/td&gt;
&lt;td&gt;Light volume&lt;/td&gt;
&lt;td&gt;&amp;lt; $0.05&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Total: roughly $1/month with the build stage enabled, ~$0.55 without&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Cost scales with pipeline executions, not idle time — nothing runs between deploys.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;What we learned from this pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Let CDK's action constructs grant their own IAM permissions.&lt;/strong&gt; A hand-written pipeline role tends to accumulate wildcard grants that nothing in the pipeline actually uses&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split "upload" and "cleanup" across stages&lt;/strong&gt; when a managed action (&lt;code&gt;S3DeployAction&lt;/code&gt;) covers the common case and only the edge case (stale-object deletion) needs custom code&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The continuation-token pattern&lt;/strong&gt; lets a Lambda-backed pipeline action represent a long-running operation without blocking on it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A token that resolves to a construct's own &lt;code&gt;Ref&lt;/code&gt; can create a self-reference&lt;/strong&gt; if it ends up embedded inside that construct's own properties — compute plain values ahead of time when they're going to be reused inside the same construct's actions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Template.fromStack()&lt;/code&gt; is the fastest way to catch deploy-time-only failures&lt;/strong&gt; (bucket naming rules, resources with mandatory non-empty properties, dependency cycles) — none of the three bugs in this article showed up until the test suite actually synthesized and validated the template&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guard optional child resources with the same condition as the feature they belong to&lt;/strong&gt; — a &lt;code&gt;NotificationRule&lt;/code&gt; with zero targets and a Manual Approval stage that's never wired to a topic are two symptoms of the same missing guard&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/codepipeline/latest/userguide/welcome.html" rel="noopener noreferrer"&gt;AWS CodePipeline User Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/codepipeline/latest/userguide/actions-invoke-lambda-function.html" rel="noopener noreferrer"&gt;Continuation tokens for the CodePipeline Lambda invoke action&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html" rel="noopener noreferrer"&gt;S3 bucket naming rules&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-codestarnotifications-notificationrule.html" rel="noopener noreferrer"&gt;AWS::CodeStarNotifications::NotificationRule&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/cdk/v2/guide/testing.html" rel="noopener noreferrer"&gt;AWS CDK: Testing constructs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/cdklabs/cdk-nag" rel="noopener noreferrer"&gt;cdk-nag&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Let's keep learning practical AWS CDK patterns through the 100 drill exercises!&lt;br&gt;
If you found this helpful, please ⭐ the repository!&lt;/p&gt;

&lt;p&gt;📌 You can see the entire code in &lt;a href="https://github.com/ishiharatma/aws-cdk-reference-architectures" rel="noopener noreferrer"&gt;my GitHub repository&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cdk</category>
      <category>100drillexercises</category>
      <category>codepipeline</category>
    </item>
    <item>
      <title>AWS CDK 100 Drill Exercises #009: CloudWatch Logs S3 Archive — Comparing 3 Log Archive Patterns in One Repository</title>
      <dc:creator>TOMOAKI ishihara</dc:creator>
      <pubDate>Mon, 17 Aug 2026 17:24:58 +0000</pubDate>
      <link>https://dev.to/aws-builders/aws-cdk-100-drill-exercises-009-cloudwatch-logs-s3-archive-comparing-3-log-archive-patterns-3ia6</link>
      <guid>https://dev.to/aws-builders/aws-cdk-100-drill-exercises-009-cloudwatch-logs-s3-archive-comparing-3-log-archive-patterns-3ia6</guid>
      <description>&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.amazonaws.com%2Fuploads%2Farticles%2Fvkpovqovupuz0fqscdot.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.amazonaws.com%2Fuploads%2Farticles%2Fvkpovqovupuz0fqscdot.png" alt="Level 200" width="70" height="20"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;This is the 9th installment of "AWS CDK 100 Drill Exercises." See &lt;a href="https://dev.to/aws-builders/introducing-aws-cdk-100-drill-exercises-learn-by-building-5949"&gt;here&lt;/a&gt; for an overview of the series.&lt;/p&gt;

&lt;p&gt;Leave CloudWatch Logs alone and anything past its retention period simply disappears. When you need to keep logs around for audits or incident investigation, most teams eventually land on "archive it to S3" — but there's more than one way to get there.&lt;/p&gt;

&lt;p&gt;This time we implement &lt;strong&gt;three different patterns&lt;/strong&gt; for archiving CloudWatch Logs to S3 as &lt;strong&gt;five independent CDK stacks&lt;/strong&gt;, and compare the latency, cost, and operational-simplicity trade-offs with concrete code.&lt;/p&gt;

&lt;h3&gt;
  
  
  What you'll learn in this article
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Near-real-time log archiving with Kinesis Data Firehose (via a subscription filter)&lt;/li&gt;
&lt;li&gt;Tiered storage-class transitions with S3 lifecycle rules (Standard → IA → Glacier IR → Deep Archive)&lt;/li&gt;
&lt;li&gt;How to bolt archiving onto an &lt;em&gt;existing&lt;/em&gt; CloudWatch Logs log group after the fact&lt;/li&gt;
&lt;li&gt;Scheduled batch export with the CloudWatch Logs Export Task API (and how to work around the one-concurrent-execution limit)&lt;/li&gt;
&lt;li&gt;Decoding the CWL payload in a Lambda subscription filter and writing it to S3 in a custom format&lt;/li&gt;
&lt;li&gt;How the required IAM trust relationships and bucket policies differ between patterns for the CloudWatch Logs service&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;📁 &lt;strong&gt;Code repository&lt;/strong&gt;: &lt;a href="https://github.com/ishiharatma/aws-cdk-reference-architectures/tree/main/infrastructure/workspaces/cloudwatch-logs-s3-archive" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&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%2Fs2s1qqqkilb9gx2sqke4.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%2Fs2s1qqqkilb9gx2sqke4.png" alt="Architecture Overview" width="800" height="574"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Stacks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A: Firehose&lt;/td&gt;
&lt;td&gt;Subscription filter → Kinesis Data Firehose → S3&lt;/td&gt;
&lt;td&gt;Basic / Lifecycle / Existing (3 stacks)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B: Export Task&lt;/td&gt;
&lt;td&gt;EventBridge Scheduler → Lambda → Export Task API → S3&lt;/td&gt;
&lt;td&gt;Export (1 stack)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C: Direct Lambda write&lt;/td&gt;
&lt;td&gt;Subscription filter → Lambda → S3&lt;/td&gt;
&lt;td&gt;Lambda (1 stack)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Why three patterns
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trait&lt;/th&gt;
&lt;th&gt;Pattern A (Firehose)&lt;/th&gt;
&lt;th&gt;Pattern B (Export Task)&lt;/th&gt;
&lt;th&gt;Pattern C (Lambda)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Delivery latency&lt;/td&gt;
&lt;td&gt;Near real-time (seconds to minutes)&lt;/td&gt;
&lt;td&gt;Scheduled (tolerates up to ~12h of delay)&lt;/td&gt;
&lt;td&gt;Near real-time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost at high volume&lt;/td&gt;
&lt;td&gt;Moderate (Firehose + S3)&lt;/td&gt;
&lt;td&gt;Low (Lambda + S3 only)&lt;/td&gt;
&lt;td&gt;Moderate (scales with Lambda invocation count)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output format&lt;/td&gt;
&lt;td&gt;Raw CWL JSON lines, GZIP-compressed&lt;/td&gt;
&lt;td&gt;Raw CWL format&lt;/td&gt;
&lt;td&gt;Custom JSON (fully controllable)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operational simplicity&lt;/td&gt;
&lt;td&gt;High (managed Firehose)&lt;/td&gt;
&lt;td&gt;High (managed Export API)&lt;/td&gt;
&lt;td&gt;Low (Lambda code needs maintenance)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom transformation&lt;/td&gt;
&lt;td&gt;Limited (depends on Firehose's data transformation feature)&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Fully controllable inside Lambda&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In short: &lt;strong&gt;A&lt;/strong&gt; if you need near-real-time delivery, &lt;strong&gt;B&lt;/strong&gt; if a cost-optimized batch job is good enough, &lt;strong&gt;C&lt;/strong&gt; if you want to define the output format yourself.&lt;/p&gt;

&lt;h3&gt;
  
  
  Data Flow
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Pattern A (Stacks 1-3)
CloudWatch Log Group
  → Subscription filter (SubscriptionFilter + FirehoseDestination, role = CwlToFirehoseRole)
  → Kinesis Data Firehose (GZIP compression)
  → S3 archive bucket
  * Stack 1 (Basic) shares 5 log groups and uses dynamic partitioning on owner/logGroup
    to route each log group to its own S3 prefix. Stack 2 (Lifecycle) uses a single log
    group with a plain date prefix and no dynamic partitioning.

Pattern B (Stack 4)
EventBridge Scheduler
  → Lambda (calls logs:CreateExportTask for the previous day's logs)
  → CloudWatch Logs Export Task API
  → S3 archive bucket (bucket policy allows writes from logs.amazonaws.com)

Pattern C (Stack 5)
CloudWatch Log Group
  → Subscription filter (LambdaDestination, CDK manages invoke permissions automatically)
  → Lambda (decodes the gzip+base64 CWL payload and writes JSON to S3)
  → S3 archive bucket
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Key Components
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Design point&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;S3 archive bucket&lt;/td&gt;
&lt;td&gt;SSE-S3 encryption, versioning enabled, public access blocked, SSL enforced. Provisioned independently per stack (5 buckets total)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CwlToFirehoseRole&lt;/td&gt;
&lt;td&gt;Assumed by &lt;code&gt;logs.amazonaws.com&lt;/code&gt;. Scoped to log groups via an &lt;code&gt;aws:SourceArn&lt;/code&gt; condition&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FirehoseRole&lt;/td&gt;
&lt;td&gt;Assumed by &lt;code&gt;firehose.amazonaws.com&lt;/code&gt;. Grants the standard Firehose→S3 permission set (&lt;code&gt;PutObject&lt;/code&gt;, &lt;code&gt;GetObject&lt;/code&gt;, &lt;code&gt;ListBucket&lt;/code&gt;, multipart upload actions) scoped to the archive bucket&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynamic partitioning (Stack 1)&lt;/td&gt;
&lt;td&gt;jq extracts &lt;code&gt;owner&lt;/code&gt;/&lt;code&gt;logGroup&lt;/code&gt; to route 5 shared log groups into separate S3 prefixes. &lt;code&gt;CloudWatchLogProcessor&lt;/code&gt; can't be used because it strips those fields; a jq fallback is required to handle &lt;code&gt;CONTROL_MESSAGE&lt;/code&gt; records&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tiered lifecycle rules (Stack 2)&lt;/td&gt;
&lt;td&gt;Standard → IA (30 days) → Glacier IR (90 days) → Deep Archive (365 days) → expiration (7 years)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Importing an existing log group (Stack 3)&lt;/td&gt;
&lt;td&gt;Referenced via &lt;code&gt;LogGroup.fromLogGroupName()&lt;/code&gt;. No new &lt;code&gt;AWS::Logs::LogGroup&lt;/code&gt; resource is created&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Export Task Lambda (Stack 4)&lt;/td&gt;
&lt;td&gt;A concurrency guard that checks for running/pending tasks before calling &lt;code&gt;CreateExportTask&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Direct Lambda write (Stack 5)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;LambdaDestination&lt;/code&gt; automatically manages invoke permissions. The payload is decoded and saved as custom JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Implementation Highlights
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Pattern A — Kinesis Data Firehose subscription
&lt;/h3&gt;

&lt;p&gt;For CloudWatch Logs to deliver to Firehose, it needs an explicit IAM role. We split responsibilities across two roles.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// CwlToFirehoseRole: assumed by logs.amazonaws.com. Only the trust policy is defined here&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cwlToFirehoseRole&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CwlToFirehoseRole&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="na"&gt;assumedBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ServicePrincipal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;logs.amazonaws.com&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="na"&gt;conditions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;StringLike&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;aws:SourceArn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`arn:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;partition&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:logs:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;region&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:log-group:*`&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="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// L2 SubscriptionFilter + FirehoseDestination(role: cwlToFirehoseRole)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;filterPattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;logGroupParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;filterPattern&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;defaultLogGroupArchiveConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;filterPattern&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SubscriptionFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CwlSubscriptionFilter&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="na"&gt;logGroup&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logGroup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;logs_destinations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FirehoseDestination&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deliveryStream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cwlToFirehoseRole&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="na"&gt;filterPattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;filterPattern&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FilterPattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;literal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filterPattern&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FilterPattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allEvents&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;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why not attach &lt;code&gt;firehose:PutRecord&lt;/code&gt;/&lt;code&gt;PutRecordBatch&lt;/code&gt; permissions directly on the role?&lt;/strong&gt;&lt;br&gt;
Inside &lt;code&gt;bind()&lt;/code&gt;, &lt;code&gt;aws-logs-destinations&lt;/code&gt;'s &lt;code&gt;FirehoseDestination&lt;/code&gt; calls &lt;code&gt;deliveryStream.grantPutRecords(role)&lt;/code&gt; on the role you pass in (or one it generates), automatically granting the necessary permissions. If you also add your own inline policy to the role, you end up with two &lt;code&gt;AWS::IAM::Policy&lt;/code&gt; resources granting the same permission. The trick is to define only the trust policy yourself (scoped by &lt;code&gt;SourceArn&lt;/code&gt;) and let the L2 construct handle the permission grant.&lt;/p&gt;

&lt;p&gt;I used to write this with the L1 &lt;code&gt;CfnSubscriptionFilter&lt;/code&gt; plus a hand-rolled inline policy, on the assumption that "the L2 &lt;code&gt;SubscriptionFilter&lt;/code&gt; can't accept a &lt;code&gt;roleArn&lt;/code&gt; for Firehose." That assumption was wrong. In the current &lt;code&gt;aws-cdk-lib&lt;/code&gt; (2.261.0 in this repository), &lt;code&gt;FirehoseDestination&lt;/code&gt; wires the role's ARN all the way through to &lt;code&gt;CfnSubscriptionFilter&lt;/code&gt; for you, so the L2 construct alone is sufficient.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The &lt;code&gt;aws:SourceArn&lt;/code&gt; condition scopes who can assume this role down to "log groups within this account/region." Combined with scoping the access policy side to just this specific Firehose delivery stream, permissions are minimized in two independent layers.&lt;/p&gt;

&lt;h3&gt;
  
  
  1a. Per-log-group dynamic partitioning (Stack 1 – Basic)
&lt;/h3&gt;

&lt;p&gt;Stack 1 creates &lt;strong&gt;5 log groups&lt;/strong&gt; that share the same Firehose delivery stream. Without partitioning, events from all five would end up mixed together in the same S3 object, so dynamic partitioning routes each log group to its own prefix.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s3Destination&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;firehose&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;S3Bucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;archiveBucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;dataOutputPrefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AWSLogs/!{partitionKeyFromQuery:owner}/CWLogGroup/!{partitionKeyFromQuery:logGroup}/!{timestamp:yyyy/MM/dd/HH}/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;dynamicPartitioning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;bufferingInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// dynamic partitioning requires &amp;gt;= 60s&lt;/span&gt;
    &lt;span class="na"&gt;bufferingSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Size&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mebibytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;        &lt;span class="c1"&gt;// and &amp;gt;= 64MiB&lt;/span&gt;
    &lt;span class="na"&gt;processors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;firehose&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DecompressionProcessor&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="na"&gt;compressionFormat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;firehose&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DecompressionProcessorCompressionFormat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GZIP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="nx"&gt;firehose&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MetadataExtractionProcessor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;jq16&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="na"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;(if (.owner // "") == "" then "controlmessages" else .owner end)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;logGroup&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;(if (.logGroup // "") == "" then "controlmessages" else (.logGroup | ltrimstr("/")) end)&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;new&lt;/span&gt; &lt;span class="nx"&gt;firehose&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AppendDelimiterToRecordProcessor&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I hit two snags here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First: don't add the &lt;code&gt;CloudWatchLogProcessor&lt;/code&gt; (message extraction).&lt;/strong&gt; Firehose has a "message extraction" feature that pulls just the contents of &lt;code&gt;message&lt;/code&gt; out of a decompressed CWL record — but enabling it wipes out &lt;code&gt;owner&lt;/code&gt;/&lt;code&gt;logGroup&lt;/code&gt;/&lt;code&gt;logStream&lt;/code&gt; entirely, leaving only the raw contents of &lt;code&gt;message&lt;/code&gt;. With those gone, the downstream &lt;code&gt;MetadataExtractionProcessor&lt;/code&gt; fails with &lt;code&gt;DynamicPartitioning.MetadataExtractionFailed&lt;/code&gt; when it tries to reference &lt;code&gt;.owner&lt;/code&gt;/&lt;code&gt;.logGroup&lt;/code&gt; via jq. Right after &lt;code&gt;DecompressionProcessor&lt;/code&gt;, the record still has &lt;code&gt;owner&lt;/code&gt;/&lt;code&gt;logGroup&lt;/code&gt; at the top level (see &lt;a href="https://docs.aws.amazon.com/firehose/latest/dev/Message_extraction.html" rel="noopener noreferrer"&gt;Fig. 1 of Firehose's "message extraction" documentation&lt;/a&gt;), so jq can read them directly without a message extraction processor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second: handling the &lt;code&gt;CONTROL_MESSAGE&lt;/code&gt; records CloudWatch Logs periodically sends as a health check.&lt;/strong&gt; These records have &lt;code&gt;owner&lt;/code&gt;/&lt;code&gt;logGroup&lt;/code&gt; set to the empty string &lt;code&gt;""&lt;/code&gt;, so a naive &lt;code&gt;.logGroup | ltrimstr("/")&lt;/code&gt; returns the empty string as-is, which fails with &lt;code&gt;partitionKeys values must not be null or empty&lt;/code&gt;. You need a jq &lt;code&gt;if/then/else&lt;/code&gt; to supply a fallback value (&lt;code&gt;"controlmessages"&lt;/code&gt;). It's tempting to reach for &lt;code&gt;//&lt;/code&gt; (the alternative operator) here, but &lt;code&gt;//&lt;/code&gt; only substitutes on &lt;code&gt;null&lt;/code&gt;/&lt;code&gt;false&lt;/code&gt;, not on an empty string.&lt;/p&gt;

&lt;p&gt;The trade-off is that each record written to S3 is the &lt;strong&gt;entire CWL envelope&lt;/strong&gt; (with a &lt;code&gt;logEvents&lt;/code&gt; array that may contain multiple events), not a flat one-row-per-log-event line. Flattening (&lt;code&gt;CloudWatchLogProcessor&lt;/code&gt;) is incompatible with metadata-based partitioning. If you need individual messages, &lt;code&gt;UNNEST&lt;/code&gt;/&lt;code&gt;json_extract&lt;/code&gt; the &lt;code&gt;logEvents&lt;/code&gt; array in something like Athena.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Tiered S3 lifecycle (Stack 2 – Lifecycle)
&lt;/h3&gt;

&lt;p&gt;For long-term archives, you can optimize cost by moving objects to progressively cheaper storage classes as access frequency drops.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;archiveBucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addLifecycleRule&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`MoveToIAAfter&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;moveToIaAfterDays&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;Days`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;transitions&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="na"&gt;storageClass&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="nx"&gt;StorageClass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;INFREQUENT_ACCESS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;transitionAfter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;days&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;moveToIaAfterDays&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;archiveBucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addLifecycleRule&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`MoveToGlacierAfter&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;moveToGlacierAfterDays&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;Days`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;transitions&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="na"&gt;storageClass&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="nx"&gt;StorageClass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GLACIER_INSTANT_RETRIEVAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;transitionAfter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;days&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;moveToGlacierAfterDays&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;archiveBucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addLifecycleRule&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`MoveToDeepArchiveAfter&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;moveToDeepArchiveAfterDays&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;Days`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;transitions&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="na"&gt;storageClass&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="nx"&gt;StorageClass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DEEP_ARCHIVE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;transitionAfter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;days&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;moveToDeepArchiveAfterDays&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;archiveBucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addLifecycleRule&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`ExpireCurrentObjectsAfter&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;expireAfterDays&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;Days`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;expiration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;days&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expireAfterDays&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;Non-current versions (older objects overwritten while versioning is enabled) get their own IA transition and expiration rule, and incomplete multipart uploads are aborted after 7 days. Rather than bundling everything into one giant lifecycle configuration, each transition gets its own &lt;code&gt;addLifecycleRule&lt;/code&gt; so parameters like &lt;code&gt;moveToIaAfterDays&lt;/code&gt; can be tuned per environment independently.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Attaching to an existing log group (Stack 3 – Existing)
&lt;/h3&gt;

&lt;p&gt;Sometimes you don't want to create a new log group — you want to bolt archiving onto a log group an application is already writing to.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existingLogGroup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LogGroup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromLogGroupName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ExistingLogGroup&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;existingLogGroupParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logGroupName&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;&lt;code&gt;fromLogGroupName()&lt;/code&gt; only references the log group in CDK — it doesn't create an &lt;code&gt;AWS::Logs::LogGroup&lt;/code&gt; resource. Since you can pass this existing log group's ARN (&lt;code&gt;existingLogGroup.logGroupArn&lt;/code&gt;) straight into the trust policy's &lt;code&gt;aws:SourceArn&lt;/code&gt; condition, the subscription filter and IAM role wiring can reuse the exact same code as Stacks 1 and 2.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Pattern B — Scheduled Export Task
&lt;/h3&gt;

&lt;p&gt;The CloudWatch Logs Export Task API has a &lt;strong&gt;one concurrent execution per account&lt;/strong&gt; limit. Before submitting a new task, the Lambda checks for running or pending tasks to avoid errors.&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;# export-task/index.py (core logic)
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lambda_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="n"&gt;logs_client&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;logs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Skip if a task is already running or pending
&lt;/span&gt;    &lt;span class="n"&gt;running&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logs_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe_export_tasks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RUNNING&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logs_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe_export_tasks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PENDING&lt;/span&gt;&lt;span class="sh"&gt;"&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;running&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exportTasks&lt;/span&gt;&lt;span class="sh"&gt;"&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;pending&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exportTasks&lt;/span&gt;&lt;span class="sh"&gt;"&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;An export task is already running or pending. Skipping.&lt;/span&gt;&lt;span class="sh"&gt;"&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;status&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;skipped&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;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;task_in_progress&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# Export the previous day's (UTC) logs
&lt;/span&gt;    &lt;span class="n"&gt;yesterday&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utcnow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&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="n"&gt;from_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;yesterday&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hour&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="n"&gt;minute&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="n"&gt;second&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="n"&gt;microsecond&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="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;to_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;yesterday&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;23&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minute&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;59&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;second&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;59&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;microsecond&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;999999&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logs_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_export_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;taskName&lt;/span&gt;&lt;span class="o"&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;export-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;yesterday&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%Y-%m-%d&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;logGroupName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;LOG_GROUP_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;fromTime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;from_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;to_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;S3_BUCKET_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;destinationPrefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;S3_PREFIX&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;yesterday&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%Y/%m/%d&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&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;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;status&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;created&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;taskId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;taskId&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;The key detail is checking &lt;code&gt;PENDING&lt;/code&gt; in addition to &lt;code&gt;RUNNING&lt;/code&gt;. If a scheduled task is merely sitting in the queue and you submit the next one anyway, you get a &lt;code&gt;LimitExceededException&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The S3 bucket side needs two resource policies allowing writes from the CloudWatch Logs service principal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 1. Permission to check the bucket ACL before exporting&lt;/span&gt;
&lt;span class="nx"&gt;archiveBucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addToResourcePolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PolicyStatement&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;effect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ALLOW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;principals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ServicePrincipal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;logs.amazonaws.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
        &lt;span class="na"&gt;actions&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;s3:GetBucketAcl&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;archiveBucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bucketArn&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;conditions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;ArnLike&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;aws:SourceArn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`arn:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;partition&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:logs:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;region&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:log-group:*`&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="c1"&gt;// 2. Permission to write the exported objects&lt;/span&gt;
&lt;span class="nx"&gt;archiveBucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addToResourcePolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PolicyStatement&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;effect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ALLOW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;principals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ServicePrincipal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;logs.amazonaws.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
        &lt;span class="na"&gt;actions&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;s3:PutObject&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;archiveBucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arnForObjects&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="na"&gt;conditions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;StringEquals&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;s3:x-amz-acl&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;bucket-owner-full-control&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="na"&gt;ArnLike&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;aws:SourceArn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`arn:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;partition&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:logs:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;region&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:log-group:*`&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The condition pinning &lt;code&gt;s3:x-amz-acl&lt;/code&gt; to &lt;code&gt;bucket-owner-full-control&lt;/code&gt; is explicitly required by &lt;a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/S3ExportTasksConsole.html" rel="noopener noreferrer"&gt;CloudWatch Logs' official documentation&lt;/a&gt;. Omit it and the export task fails with a permissions error.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Pattern C — Subscription filter → Lambda → S3
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;LambdaDestination&lt;/code&gt;, CDK automatically manages the resource policy (&lt;code&gt;Lambda::Permission&lt;/code&gt;) that lets &lt;code&gt;logs.amazonaws.com&lt;/code&gt; invoke your Lambda.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SubscriptionFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CwlSubscriptionFilter&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="na"&gt;logGroup&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logGroup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;logDestinations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;LambdaDestination&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;archiveFunction&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;filterPattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;filterPattern&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FilterPattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;literal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filterPattern&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FilterPattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allEvents&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;On the Lambda side, the code decodes the CWL compressed payload and writes it to S3 in a JSON format it defines itself, per invocation.&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;# cwl-to-s3/index.py (core logic)
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lambda_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="n"&gt;compressed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64decode&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;awslogs&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;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&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;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gzip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decompress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;compressed&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;payload&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;messageType&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CONTROL_MESSAGE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Health-check message CloudWatch Logs sends, e.g. when a subscription starts
&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;status&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;skipped&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;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;control_message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utcnow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;safe_stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;logStream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&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;_&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$&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="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[&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="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;]&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="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;s3_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;S3_PREFIX&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%Y/%m/%d/%H&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;safe_stream&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nb"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;record&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;logGroup&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;logGroup&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;logStream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;logStream&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;owner&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;payload&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;owner&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;exportedAt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isoformat&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="s"&gt;Z&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;events&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;logEvents&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;s3_client&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;S3_BUCKET_NAME&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;s3_key&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;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;record&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ensure_ascii&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&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="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;status&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;ok&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="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;logEvents&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;s3Key&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_key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the early skip for payloads with &lt;code&gt;messageType == "CONTROL_MESSAGE"&lt;/code&gt;. CloudWatch Logs sends these health-check control messages right after a subscription starts (among other times), and they have no &lt;code&gt;logEvents&lt;/code&gt; key at all — without this check, the invocation fails with a &lt;code&gt;KeyError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Both patterns A and C deliver via a subscription filter for near-immediate delivery, but with A you can let Firehose handle the transformation, while with C you write the Lambda code yourself and get complete control over the output format in exchange.&lt;/p&gt;




&lt;h2&gt;
  
  
  Deploy &amp;amp; Verify
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PROJECT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-project
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ENV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev

npm run bootstrap &lt;span class="nt"&gt;-w&lt;/span&gt; workspaces/cloudwatch-logs-s3-archive   &lt;span class="c"&gt;# first time only&lt;/span&gt;
npm run stage:deploy:all &lt;span class="nt"&gt;-w&lt;/span&gt; workspaces/cloudwatch-logs-s3-archive
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To deploy individual stacks, use the per-stack &lt;code&gt;stage:deploy:*&lt;/code&gt; scripts (each scoped to a &lt;code&gt;cdk deploy&lt;/code&gt; stack selector like &lt;code&gt;**/*Basic&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;npm run stage:deploy:basic &lt;span class="nt"&gt;-w&lt;/span&gt; workspaces/cloudwatch-logs-s3-archive
npm run stage:deploy:lifecycle &lt;span class="nt"&gt;-w&lt;/span&gt; workspaces/cloudwatch-logs-s3-archive
npm run stage:deploy:export &lt;span class="nt"&gt;-w&lt;/span&gt; workspaces/cloudwatch-logs-s3-archive
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Pattern A Stack 1 (Basic): write test data into 5 log groups (/&amp;lt;project&amp;gt;/&amp;lt;env&amp;gt;/basic-*)&lt;/span&gt;
&lt;span class="c"&gt;# (given the dynamic-partitioning buffering requirements of &amp;gt;= 60s / &amp;gt;= 64MiB, use the&lt;/span&gt;
&lt;span class="c"&gt;#  repo's helper script rather than a one-off put-log-events call)&lt;/span&gt;
./write-test-logs.sh &lt;span class="nt"&gt;--project&lt;/span&gt; &amp;lt;project&amp;gt; &lt;span class="nt"&gt;--env&lt;/span&gt; &amp;lt;&lt;span class="nb"&gt;env&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;# After ~60s, output appears partitioned under AWSLogs/&amp;lt;owner&amp;gt;/CWLogGroup/&amp;lt;logGroup&amp;gt;/...&lt;/span&gt;
aws s3 &lt;span class="nb"&gt;ls &lt;/span&gt;s3://&amp;lt;archive-bucket&amp;gt;/AWSLogs/ &lt;span class="nt"&gt;--recursive&lt;/span&gt;

&lt;span class="c"&gt;# Pattern A Stack 2 (Lifecycle): single log group, no dynamic partitioning&lt;/span&gt;
./write-test-logs-lifecycle.sh &lt;span class="nt"&gt;--project&lt;/span&gt; &amp;lt;project&amp;gt; &lt;span class="nt"&gt;--env&lt;/span&gt; &amp;lt;&lt;span class="nb"&gt;env&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
aws s3 &lt;span class="nb"&gt;ls &lt;/span&gt;s3://&amp;lt;archive-bucket&amp;gt;/ &lt;span class="nt"&gt;--recursive&lt;/span&gt;

&lt;span class="c"&gt;# Pattern B: manually invoke the export Lambda&lt;/span&gt;
aws lambda invoke &lt;span class="nt"&gt;--function-name&lt;/span&gt; &amp;lt;project&amp;gt;-&amp;lt;&lt;span class="nb"&gt;env&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;-cwl-export-task&lt;/span&gt; &lt;span class="nt"&gt;--payload&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt; response.json
&lt;span class="nb"&gt;cat &lt;/span&gt;response.json

&lt;span class="c"&gt;# Pattern C: write a test log entry (the subscription filter invokes the Lambda immediately)&lt;/span&gt;
aws logs put-log-events &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--log-group-name&lt;/span&gt; /&amp;lt;project&amp;gt;/&amp;lt;&lt;span class="nb"&gt;env&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/app-lambda &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--log-stream-name&lt;/span&gt; test-stream &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--log-events&lt;/span&gt; &lt;span class="nv"&gt;timestamp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s000&lt;span class="si"&gt;)&lt;/span&gt;,message&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"hello lambda"&lt;/span&gt;
aws s3 &lt;span class="nb"&gt;ls &lt;/span&gt;s3://&amp;lt;archive-bucket&amp;gt;/subscriptions/ &lt;span class="nt"&gt;--recursive&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Cost Estimate
&lt;/h2&gt;

&lt;p&gt;💰 Rough monthly estimate (Tokyo region, 1 GB of log data per day)&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Applies to&lt;/th&gt;
&lt;th&gt;Rough monthly cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Kinesis Data Firehose&lt;/td&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;~$0.90 (at $0.03/GB)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lambda&lt;/td&gt;
&lt;td&gt;B, C&lt;/td&gt;
&lt;td&gt;Effectively negligible at low invocation counts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 (Standard)&lt;/td&gt;
&lt;td&gt;All&lt;/td&gt;
&lt;td&gt;~$0.75 (at $0.025/GB-month)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudWatch Logs (ingestion)&lt;/td&gt;
&lt;td&gt;All&lt;/td&gt;
&lt;td&gt;~$0.76/GB (charged regardless of which archive pattern you use, and not included in the total below)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EventBridge&lt;/td&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;$1.00 per million events (negligible at minimal frequency)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Rough total for Pattern A's &lt;em&gt;archiving&lt;/em&gt; cost alone: about $2/month at 1 GB/day of log volume. CloudWatch Logs ingestion itself (~$23/month at 1 GB/day) is a separate cost you pay upfront, before any archive pattern runs&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Running all five stacks simultaneously at production-scale traffic for comparison testing will cost more than the above. Don't forget to clean up afterward with &lt;code&gt;npm run stage:destroy:all -w workspaces/cloudwatch-logs-s3-archive&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;What we learned from this pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pattern A (Firehose)&lt;/strong&gt;: Minimal code, well-suited for near-real-time archiving. Delivery from CloudWatch Logs requires &lt;code&gt;FirehoseDestination&lt;/code&gt; (L2) plus two IAM roles with distinct responsibilities&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pattern B (Export Task)&lt;/strong&gt;: The lowest cost for batch use cases, but limited to one concurrent execution per account. A guard checking for running/pending tasks on the Lambda side is essential&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pattern C (Lambda)&lt;/strong&gt;: Full control over the output format, at the cost of ongoing Lambda code maintenance, including handling control messages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IAM trust relationships differ by service&lt;/strong&gt;: For Firehose, &lt;code&gt;FirehoseDestination&lt;/code&gt; grants permission to the trusted role via &lt;code&gt;grantPutRecords&lt;/code&gt;; for Export Task, an S3 bucket policy with an &lt;code&gt;s3:x-amz-acl&lt;/code&gt; condition is required; for &lt;code&gt;LambdaDestination&lt;/code&gt;, CDK auto-generates the invoke permission — the same "delivery from CloudWatch Logs" concept wires up differently every time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split tiered lifecycle rules by transition&lt;/strong&gt;: rather than one giant rule, separate rules make the &lt;code&gt;transitionAfter&lt;/code&gt; thresholds easy to tune independently as environment parameters&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fromLogGroupName()&lt;/code&gt; is enough for bolting archiving onto an existing log group&lt;/strong&gt;: pass the existing ARN into the trust policy directly and reuse the same wiring, with no new resource created&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firehose dynamic partitioning is incompatible with message extraction&lt;/strong&gt;: &lt;code&gt;CloudWatchLogProcessor&lt;/code&gt; strips &lt;code&gt;owner&lt;/code&gt;/&lt;code&gt;logGroup&lt;/code&gt;, so drop it if you're using jq to derive partition keys from those fields. You also need to account for CloudWatch Logs' &lt;code&gt;CONTROL_MESSAGE&lt;/code&gt; records (where &lt;code&gt;owner&lt;/code&gt;/&lt;code&gt;logGroup&lt;/code&gt; are empty strings) with a jq &lt;code&gt;if/then/else&lt;/code&gt; fallback (&lt;code&gt;//&lt;/code&gt; doesn't catch empty strings)&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/Subscriptions.html" rel="noopener noreferrer"&gt;Subscription filters for CloudWatch Logs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/S3Export.html" rel="noopener noreferrer"&gt;Exporting CloudWatch Logs to S3&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/firehose/latest/dev/what-is-this-service.html" rel="noopener noreferrer"&gt;Kinesis Data Firehose Developer Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lifecycle-mgmt.html" rel="noopener noreferrer"&gt;S3 lifecycle configuration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/cdklabs/cdk-nag/blob/main/RULES.md" rel="noopener noreferrer"&gt;CDK Nag AwsSolutions rules&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Let's keep learning practical AWS CDK patterns through the 100 drill exercises!&lt;br&gt;
If you found this helpful, please ⭐ the repository!&lt;/p&gt;

&lt;p&gt;📌 You can see the entire code in &lt;a href="https://github.com/ishiharatma/aws-cdk-reference-architectures" rel="noopener noreferrer"&gt;my GitHub repository&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cdk</category>
      <category>100drillexercises</category>
      <category>cloudwatchlogs</category>
    </item>
    <item>
      <title>AWS CDK 100 Drill Exercises #010: Budget Alerts &amp; Cost Anomaly Detection — 5 FinOps Alerting Patterns in CDK</title>
      <dc:creator>TOMOAKI ishihara</dc:creator>
      <pubDate>Mon, 17 Aug 2026 15:24:33 +0000</pubDate>
      <link>https://dev.to/aws-builders/aws-cdk-100-drill-exercises-010-budget-alerts-cost-anomaly-detection-5-finops-alerting-23g2</link>
      <guid>https://dev.to/aws-builders/aws-cdk-100-drill-exercises-010-budget-alerts-cost-anomaly-detection-5-finops-alerting-23g2</guid>
      <description>&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.amazonaws.com%2Fuploads%2Farticles%2Fr1wyy0xk7f46wb5kdjqb.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.amazonaws.com%2Fuploads%2Farticles%2Fr1wyy0xk7f46wb5kdjqb.png" alt="Level 300" width="70" height="20"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;This is the 10th installment of "AWS CDK 100 Drill Exercises." See &lt;a href="https://dev.to/aws-builders/introducing-aws-cdk-100-drill-exercises-learn-by-building-5949"&gt;here&lt;/a&gt; for an overview of the series.&lt;/p&gt;

&lt;p&gt;By the time you notice, AWS costs have often already piled up. You catch the anomaly when the invoice arrives, or you open the console and think "wait, I shouldn't have spent this much this month." AWS gives you several ways to prevent this, but they're actually quite different in nature. Do you want to be told when you cross a known threshold? Do you want to catch unusual activity even while still within budget? Or do you want weekly usage automatically shared with your team? The right mechanism depends on which of these you're actually after.&lt;/p&gt;

&lt;p&gt;This time we implement &lt;strong&gt;five different patterns&lt;/strong&gt; for AWS cost alerting as &lt;strong&gt;five independent CDK stacks&lt;/strong&gt;, and compare each one's detection trigger, notification path, and setup complexity with concrete code.&lt;/p&gt;

&lt;h3&gt;
  
  
  What you'll learn in this article
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;How to express AWS Budgets threshold notifications as a data-driven &lt;code&gt;{ type, thresholdPercent }[]&lt;/code&gt; array instead of hardcoding them&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;frequency: IMMEDIATE&lt;/code&gt; constraint required for SNS delivery in AWS Cost Anomaly Detection, and how to hand-assemble &lt;code&gt;thresholdExpression&lt;/code&gt; as a JSON string&lt;/li&gt;
&lt;li&gt;How AWS Budgets, Cost Anomaly Detection, and CloudWatch alarms each require SNS publish permissions in their own distinct way (different &lt;code&gt;SourceAccount&lt;/code&gt;/&lt;code&gt;SourceArn&lt;/code&gt; conditions per service principal)&lt;/li&gt;
&lt;li&gt;The hard limit that AWS-managed Cost Anomaly Detection monitors are &lt;strong&gt;effectively capped at one or two per account&lt;/strong&gt; — a constraint I only discovered by actually deploying, and the redesign needed to share one monitor across multiple stacks&lt;/li&gt;
&lt;li&gt;The trap where AWS Chatbot's &lt;code&gt;guardrailPolicies&lt;/code&gt; defaults to &lt;code&gt;AdministratorAccess&lt;/code&gt; when left unset — and why an empty array &lt;code&gt;[]&lt;/code&gt; doesn't prevent this (CDK synth strips empty arrays out of the template)&lt;/li&gt;
&lt;li&gt;How to use Step Functions' new query language &lt;strong&gt;JSONata&lt;/strong&gt; to format Cost Explorer API results straight into an SNS message with zero Lambda functions&lt;/li&gt;
&lt;li&gt;A string-escaping trap that spans three layers — JavaScript → JSON → JSONata (&lt;code&gt;\n&lt;/code&gt; happens to work, &lt;code&gt;\s&lt;/code&gt; silently breaks)&lt;/li&gt;
&lt;li&gt;Six reusable constructs factored out into &lt;code&gt;common/constructs/cost/&lt;/code&gt; for reuse across the whole repository&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;📁 &lt;strong&gt;Code repository&lt;/strong&gt;: &lt;a href="https://github.com/ishiharatma/aws-cdk-reference-architectures/tree/main/infrastructure/workspaces/budgets-cost-anomaly-detection" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&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%2Fbxdlgqqqogxrl33dm0m1.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%2Fbxdlgqqqogxrl33dm0m1.png" alt="Architecture Overview" width="800" height="642"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Stack&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A: Budgets&lt;/td&gt;
&lt;td&gt;Threshold-based budget alert → SNS → Email&lt;/td&gt;
&lt;td&gt;Budget (1 stack)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B: Anomaly Detection&lt;/td&gt;
&lt;td&gt;ML-based anomaly detection → SNS → Email&lt;/td&gt;
&lt;td&gt;Anomaly (1 stack)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C: Unified&lt;/td&gt;
&lt;td&gt;Budgets + Anomaly Detection consolidated into one SNS topic, optionally Slack&lt;/td&gt;
&lt;td&gt;Unified (1 stack)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D: Billing Alarm&lt;/td&gt;
&lt;td&gt;Classic CloudWatch &lt;code&gt;EstimatedCharges&lt;/code&gt; alarm → SNS → Email&lt;/td&gt;
&lt;td&gt;BillingAlarm (1 stack, pinned to us-east-1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E: Cost Digest&lt;/td&gt;
&lt;td&gt;Scheduled Step Functions → SNS → Slack/Teams&lt;/td&gt;
&lt;td&gt;CostDigest (1 stack)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Why five patterns
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trait&lt;/th&gt;
&lt;th&gt;A (Budgets)&lt;/th&gt;
&lt;th&gt;B (Anomaly Detection)&lt;/th&gt;
&lt;th&gt;C (Unified)&lt;/th&gt;
&lt;th&gt;D (Billing Alarm)&lt;/th&gt;
&lt;th&gt;E (Cost Digest)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Trigger&lt;/td&gt;
&lt;td&gt;Reactive — known threshold&lt;/td&gt;
&lt;td&gt;Reactive — deviation from historical trend (ML)&lt;/td&gt;
&lt;td&gt;Both, reactive&lt;/td&gt;
&lt;td&gt;Reactive — cumulative spend threshold&lt;/td&gt;
&lt;td&gt;Proactive — scheduled (no threshold needed)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best suited for&lt;/td&gt;
&lt;td&gt;"Tell me when I cross $X/month"&lt;/td&gt;
&lt;td&gt;"I want to notice unusual activity even within budget"&lt;/td&gt;
&lt;td&gt;Consolidating everything into one channel&lt;/td&gt;
&lt;td&gt;The oldest safety net, independent of Cost Explorer&lt;/td&gt;
&lt;td&gt;"Share weekly spend with the team"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Notification channel&lt;/td&gt;
&lt;td&gt;SNS + Email&lt;/td&gt;
&lt;td&gt;SNS + Email&lt;/td&gt;
&lt;td&gt;SNS + Email + optional Slack&lt;/td&gt;
&lt;td&gt;SNS + Email&lt;/td&gt;
&lt;td&gt;SNS + Email + optional Slack/Teams&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In short: &lt;strong&gt;A&lt;/strong&gt; to stop at a known ceiling, &lt;strong&gt;B&lt;/strong&gt; to catch unexpected movement, &lt;strong&gt;C&lt;/strong&gt; to consolidate both into one place, &lt;strong&gt;D&lt;/strong&gt; as the last-resort insurance for when you can't even trust Cost Explorer, &lt;strong&gt;E&lt;/strong&gt; for a proactive recurring report.&lt;/p&gt;

&lt;h3&gt;
  
  
  Data Flow
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Pattern A (Stack 1)
CfnBudget (account-wide + service filter)
  → SNS topic (budgets.amazonaws.com allowed to Publish)
  → Email

Pattern B (Stack 2)
CfnAnomalyMonitor (DIMENSIONAL / SERVICE)
  → CfnAnomalySubscription (frequency: IMMEDIATE)
  → SNS topic (costalerts.amazonaws.com allowed to Publish)
  → Email

Pattern C (Stack 3)
CfnBudget + CfnAnomalySubscription (attaches to Stack 2's monitor, creates no new monitor)
  → SNS topic (both service principals allowed)
  → Email + AWS Chatbot → Slack (optional)

Pattern D (Stack 4, pinned to us-east-1)
CloudWatch alarm (AWS/Billing EstimatedCharges)
  → SNS topic (cloudwatch.amazonaws.com allowed to Publish)
  → Email

Pattern E (Stack 5)
EventBridge Scheduler (cron)
  → Step Functions (JSONata): GetCostAndUsage → PublishCostDigest
  → SNS topic (Publish from its own IAM role, no service policy needed)
  → Email + AWS Chatbot → Slack / Microsoft Teams (either or both)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Key Components
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Design point&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;CostAlertTopic&lt;/code&gt; (shared construct)&lt;/td&gt;
&lt;td&gt;SNS topic + resource policy driven by &lt;code&gt;allowBudgetsPublish&lt;/code&gt;/&lt;code&gt;allowCostAnomalyDetectionPublish&lt;/code&gt;/&lt;code&gt;allowCloudWatchAlarmPublish&lt;/code&gt; flags&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;CostBudget&lt;/code&gt; (shared construct)&lt;/td&gt;
&lt;td&gt;A &lt;code&gt;CfnBudget&lt;/code&gt; wrapper that dynamically builds &lt;code&gt;notificationsWithSubscribers&lt;/code&gt; from &lt;code&gt;BudgetNotificationRule[]&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;CostAnomalyDetection&lt;/code&gt; (shared construct)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CfnAnomalyMonitor&lt;/code&gt; + &lt;code&gt;CfnAnomalySubscription&lt;/code&gt;, encapsulating the &lt;code&gt;thresholdExpression&lt;/code&gt; JSON assembly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;BillingAlarm&lt;/code&gt; (shared construct)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AWS/Billing EstimatedCharges&lt;/code&gt; metric + alarm. Assumes it's pinned to us-east-1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;SafeSlackChannelConfiguration&lt;/code&gt; / &lt;code&gt;SafeMicrosoftTeamsChannelConfiguration&lt;/code&gt; (shared constructs)&lt;/td&gt;
&lt;td&gt;Safe Chatbot wrappers that fall back to &lt;code&gt;ReadOnlyAccess&lt;/code&gt; when &lt;code&gt;guardrailPolicies&lt;/code&gt; isn't specified&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Step Functions (Stack 5)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;QueryLanguage.JSONATA&lt;/code&gt;, &lt;code&gt;tracingEnabled: true&lt;/code&gt;, full execution logging to CloudWatch Logs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Implementation Highlights
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Data-driven budget notification rules
&lt;/h3&gt;

&lt;p&gt;Rather than hardcoding "80%, 100%, forecasted 100%" into the stack, notification rules are stored as a plain array in the environment parameters.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// common/types/cost.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;BudgetNotificationRule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ACTUAL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FORECASTED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;thresholdPercent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// parameters/dev-params.ts&lt;/span&gt;
&lt;span class="nl"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;notifications&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="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FORECASTED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;thresholdPercent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ACTUAL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;thresholdPercent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ACTUAL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;thresholdPercent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ACTUAL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;thresholdPercent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// escalation for runaway costs&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;This lets any environment add or remove thresholds without touching the stack code. &lt;code&gt;common/constructs/cost/budget.ts&lt;/code&gt; just &lt;code&gt;.map()&lt;/code&gt;s over the array.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;notificationRules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notifications&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;defaultBudgetNotifications&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;notificationsWithSubscribers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;notificationRules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;notificationType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;comparisonOperator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GREATER_THAN&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;thresholdPercent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;thresholdType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PERCENTAGE&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="nx"&gt;subscribers&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;h3&gt;
  
  
  2. SNS topic policies aren't optional — and each service does it differently
&lt;/h3&gt;

&lt;p&gt;Both AWS Budgets and AWS Cost Anomaly Detection publish to SNS as &lt;strong&gt;service principals&lt;/strong&gt; rather than by assuming an IAM role. CloudWatch alarm actions likewise need explicit permission. Without it, notifications fail silently — the configuration looks correct, but nothing ever arrives.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// common/constructs/cost/cost-alert-topic.ts (simplified)&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;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowBudgetsPublish&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addToResourcePolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PolicyStatement&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;principals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ServicePrincipal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;budgets.amazonaws.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
        &lt;span class="na"&gt;actions&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;sns:Publish&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;conditions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;StringEquals&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;aws:SourceAccount&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;account&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="na"&gt;ArnLike&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;aws:SourceArn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`arn:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;partition&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:budgets::&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;account&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="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;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowCostAnomalyDetectionPublish&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// costalerts.amazonaws.com, condition is aws:SourceAccount only (no SourceArn scoping needed)&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;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowCloudWatchAlarmPublish&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;grantPublish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ServicePrincipal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cloudwatch.amazonaws.com&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three need "permission to publish to SNS," yet the strictness of the conditions is all over the place. Budgets requires both &lt;code&gt;SourceAccount&lt;/code&gt; and &lt;code&gt;SourceArn&lt;/code&gt;; Cost Anomaly Detection requires only &lt;code&gt;SourceAccount&lt;/code&gt; (even AWS's own official samples don't specify &lt;code&gt;SourceArn&lt;/code&gt;); and CloudWatch's &lt;code&gt;cloudwatch-actions.SnsAction&lt;/code&gt; doesn't even do the kind of automatic grant other alarm actions do — you have to call &lt;code&gt;grantPublish&lt;/code&gt; explicitly.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Don't add a customer-managed KMS key to these topics.&lt;/strong&gt; The troubleshooting documentation for both Budgets and Cost Anomaly Detection explicitly calls out topic encryption as a classic cause of silently-failed notifications, because the service principal would also need &lt;code&gt;kms:GenerateDataKey*&lt;/code&gt;/&lt;code&gt;kms:Decrypt&lt;/code&gt; granted in the key policy. Here we enforce &lt;code&gt;enforceSSL: true&lt;/code&gt; for transport encryption only, leaving at-rest encryption at SNS's default managed encryption.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  3. Cost Anomaly Detection's &lt;code&gt;thresholdExpression&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;CfnAnomalySubscription.thresholdExpression&lt;/code&gt; is typed as a plain &lt;code&gt;string&lt;/code&gt; in &lt;code&gt;aws-cdk-lib&lt;/code&gt;. CDK doesn't have a typed representation of Cost Explorer's &lt;code&gt;Expression&lt;/code&gt; grammar, so you have to assemble it yourself and &lt;code&gt;JSON.stringify()&lt;/code&gt; it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;thresholdExpression&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;And&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="na"&gt;Dimensions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="na"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ANOMALY_TOTAL_IMPACT_PERCENTAGE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="na"&gt;MatchOptions&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;GREATER_THAN_OR_EQUAL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="na"&gt;Values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;thresholdPercentage&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="na"&gt;Dimensions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="na"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ANOMALY_TOTAL_IMPACT_ABSOLUTE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="na"&gt;MatchOptions&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;GREATER_THAN_OR_EQUAL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="na"&gt;Values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;thresholdAbsoluteUsd&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="p"&gt;}),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ANDing "percentage relative to expected spend" with "absolute dollar amount" filters out noise like "10x the usual cost, but the absolute amount is only $3."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;SNS delivery requires &lt;code&gt;frequency: IMMEDIATE&lt;/code&gt;.&lt;/strong&gt; AWS Cost Anomaly Detection only supports SNS subscribers on &lt;code&gt;IMMEDIATE&lt;/code&gt; subscriptions — &lt;code&gt;DAILY&lt;/code&gt;/&lt;code&gt;WEEKLY&lt;/code&gt; are email-only. If you also want a daily email summary, add a second &lt;code&gt;CfnAnomalySubscription&lt;/code&gt; (frequency: &lt;code&gt;DAILY&lt;/code&gt;, &lt;code&gt;EMAIL&lt;/code&gt; subscriber) pointing at the same monitor.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  4. Stack 3 doesn't create its own anomaly monitor — AWS's "one per account" limit
&lt;/h3&gt;

&lt;p&gt;This is a trap I only hit after actually deploying, once the writing was already done. Deploy Stack 3 with Stack 2 already deployed, and it fails reliably with this error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CREATE_FAILED | AWS::CE::AnomalyMonitor | ...
Resource handler returned message: "null" (HandlerErrorCode: AlreadyExists)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At first I thought "the monitor names are different, so why?" — but the cause wasn't the name, it was an &lt;strong&gt;AWS-imposed limit&lt;/strong&gt;. The &lt;code&gt;CreateAnomalyMonitor&lt;/code&gt; API reference itself doesn't mention this constraint; the source is a blog post from the AWS Cloud Financial Management team.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"You can create one AWS services managed monitor plus one additional AWS managed monitor (linked account, cost allocation tag, or cost category) per management account."&lt;/p&gt;

&lt;p&gt;"AWS managed monitors for linked accounts, cost allocation tags, and cost categories can only be created in management accounts."&lt;/p&gt;

&lt;p&gt;— &lt;a href="https://aws.amazon.com/blogs/aws-cloud-financial-management/extending-aws-managed-monitors-in-cost-anomaly-detection/" rel="noopener noreferrer"&gt;Extending AWS managed monitors in AWS Cost Anomaly Detection&lt;/a&gt; (AWS Cloud Financial Management blog)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Since Stack 2 has already created the account's one and only &lt;code&gt;SERVICE&lt;/code&gt;-dimension monitor, Stack 3 trying to create a second one on the same dimension always hits &lt;code&gt;AlreadyExists&lt;/code&gt;. Switching &lt;code&gt;monitorDimension&lt;/code&gt; to &lt;code&gt;LINKED_ACCOUNT&lt;/code&gt; doesn't help either — that runs into the separate constraint of only being creatable in an AWS Organizations management account, which fails in most test accounts anyway.&lt;/p&gt;

&lt;p&gt;The fix is simple: &lt;strong&gt;don't have Stack 3 create a new monitor — attach a second &lt;code&gt;CfnAnomalySubscription&lt;/code&gt; to Stack 2's existing monitor instead.&lt;/strong&gt; Attaching multiple subscriptions to one monitor is a configuration AWS officially supports.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// common/constructs/cost/anomaly-detection.ts&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;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;existingMonitorArn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;monitorArn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;existingMonitorArn&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;monitor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;ce&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CfnAnomalyMonitor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Monitor&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="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;monitorArn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;monitor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attrMonitorArn&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscription&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;ce&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CfnAnomalySubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Subscription&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="na"&gt;monitorArnList&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;monitorArn&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="c1"&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 the Stage side, Stack 2 is instantiated before Stack 3, and &lt;code&gt;anomalyStack.monitorArn&lt;/code&gt; is simply passed into Stack 3's props. CDK converts this into a real CloudFormation cross-stack export/import, so there's no need for a hand-written &lt;code&gt;Fn::ImportValue&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;anomalyStack&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BudgetsCostAnomalyDetectionAnomalyStack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&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="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BudgetsCostAnomalyDetectionUnifiedStack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&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;commonStackProps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;anomalyMonitorArn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;anomalyStack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;monitorArn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I'll admit it stings a bit that Stack 3 ended up depending on Stack 2, despite the whole premise being "five independent patterns." But since this is a hard constraint on AWS's side, I decided it's more honest to make the dependency explicit in both code and documentation rather than hide it.&lt;/p&gt;

&lt;p&gt;Once the deploy went through, I noticed something else. Asking myself "what's actually different between &lt;code&gt;drillexercises-dev-service-anomaly-subscription&lt;/code&gt; (Stack 2) and &lt;code&gt;drillexercises-dev-unified-anomaly-subscription&lt;/code&gt; (Stack 3)," the answer turned out to be: &lt;strong&gt;they're watching exactly the same thing&lt;/strong&gt;. Both are attached to the same monitor, and both read &lt;code&gt;thresholdPercentage&lt;/code&gt;/&lt;code&gt;thresholdAbsoluteUsd&lt;/code&gt; from the same &lt;code&gt;params.anomalyDetection&lt;/code&gt;, so by default the thresholds are identical too. That means deploying both Stack 2 and Stack 3 results in &lt;strong&gt;two notifications for the same single anomaly&lt;/strong&gt; — an email via Stack 2, plus an email (and optionally Slack) via Stack 3.&lt;/p&gt;

&lt;p&gt;To address this, I made it possible to set a &lt;strong&gt;stricter threshold&lt;/strong&gt; on Stack 3 specifically.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/types/anomaly-params.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;AnomalyDetectionParams&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;AnomalyThreshold&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;monitorDimension&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SERVICE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;LINKED_ACCOUNT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// Escalation threshold for Stack 3. Falls back to the base value if unset&lt;/span&gt;
    &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;unifiedEscalation&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;AnomalyThreshold&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// lib/stacks/budgets-cost-anomaly-detection-unified-stack.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;anomalyThreshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;anomalyParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unifiedEscalation&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;anomalyParams&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;CostAnomalyDetection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;UnifiedAnomalyDetection&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="na"&gt;existingMonitorArn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;anomalyMonitorArn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;thresholdPercentage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;anomalyThreshold&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;thresholdPercentage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;thresholdAbsoluteUsd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;anomalyThreshold&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;thresholdAbsoluteUsd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;code&gt;dev-params.ts&lt;/code&gt;, the base threshold is "20%+ actual cost AND $5+ absolute," while Stack 3's escalation threshold is "50%+ actual cost AND $20+ absolute." This demonstrates a severity split: Stack 2 casts a wide net via email, while Stack 3 (the unified/Slack channel) only receives the more serious anomalies.&lt;/p&gt;

&lt;p&gt;To be honest, though, this &lt;strong&gt;doesn't eliminate the duplication itself&lt;/strong&gt;. An anomaly large enough to satisfy both thresholds still triggers two notifications. In real-world usage, the straightforward choice is to use &lt;strong&gt;only one of&lt;/strong&gt; Stack 2 or Stack 3 for anomaly detection. Think of &lt;code&gt;unifiedEscalation&lt;/code&gt; purely as a demonstration that "a single monitor can feed multiple severity-tiered subscriptions."&lt;/p&gt;

&lt;h3&gt;
  
  
  5. AWS Chatbot's guardrail policy default is &lt;code&gt;AdministratorAccess&lt;/code&gt; (for both Slack and Teams)
&lt;/h3&gt;

&lt;p&gt;This is the biggest "hurts if you don't know it" gotcha of this whole exercise. Both &lt;code&gt;SlackChannelConfigurationProps.guardrailPolicies&lt;/code&gt; and &lt;code&gt;CfnMicrosoftTeamsChannelConfigurationProps.guardrailPolicies&lt;/code&gt; apply the AWS-managed &lt;code&gt;AdministratorAccess&lt;/code&gt; policy by default when left unspecified.&lt;/p&gt;

&lt;p&gt;"So can I disable it by passing an empty array &lt;code&gt;[]&lt;/code&gt;?" — that's the trap. &lt;strong&gt;CDK's synth step strips empty list properties out of the synthesized CloudFormation template entirely&lt;/strong&gt;, so from the API's perspective the property is simply unset, and the &lt;code&gt;AdministratorAccess&lt;/code&gt; default kicks in anyway. I only noticed this by actually inspecting &lt;code&gt;cdk synth&lt;/code&gt; output.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// common/constructs/cost/safe-slack-channel.ts&lt;/span&gt;
&lt;span class="nx"&gt;guardrailPolicies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;guardrailPolicies&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;guardrailPolicies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;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;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;guardrailPolicies&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ManagedPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromAwsManagedPolicyName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ReadOnlyAccess&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since Microsoft Teams has no CDK L2 construct at all, &lt;code&gt;SafeMicrosoftTeamsChannelConfiguration&lt;/code&gt; also auto-generates the minimal "notification only" IAM role (&lt;code&gt;cloudwatch:Describe*&lt;/code&gt;/&lt;code&gt;Get*&lt;/code&gt;/&lt;code&gt;List*&lt;/code&gt; only) used by AWS's own sample Chatbot policy.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Reusable cost constructs (&lt;code&gt;common/constructs/cost/&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;The pieces above aren't specific to this workspace, so they're factored out into &lt;code&gt;infrastructure/common/constructs/cost/&lt;/code&gt; for reuse across the whole repository.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Construct&lt;/th&gt;
&lt;th&gt;Wraps&lt;/th&gt;
&lt;th&gt;Used by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CostAlertTopic&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SNS Topic + conditional resource policy + email subscription&lt;/td&gt;
&lt;td&gt;Stack 1, 2, 3, 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CostBudget&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CfnBudget&lt;/code&gt; driven by &lt;code&gt;BudgetNotificationRule[]&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Stack 1 (x2), Stack 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CostAnomalyDetection&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CfnAnomalyMonitor&lt;/code&gt; + &lt;code&gt;CfnAnomalySubscription&lt;/code&gt; (skips monitor creation when &lt;code&gt;existingMonitorArn&lt;/code&gt; is given)&lt;/td&gt;
&lt;td&gt;Stack 2, Stack 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BillingAlarm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AWS/Billing EstimatedCharges&lt;/code&gt; alarm&lt;/td&gt;
&lt;td&gt;Stack 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SafeSlackChannelConfiguration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Slack configuration with safe guardrail defaults&lt;/td&gt;
&lt;td&gt;Stack 3, Stack 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SafeMicrosoftTeamsChannelConfiguration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Teams configuration with a least-privilege role plus safe guardrails&lt;/td&gt;
&lt;td&gt;Stack 5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Stack 5's Step Functions/JSONata logic itself isn't factored out, since the message formatting is tightly coupled to this workspace's specific content; only the Chatbot delivery portion uses the safe wrappers above.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. The cost digest's JSONata state machine (Stack 5)
&lt;/h3&gt;

&lt;p&gt;Stack 5 generalizes a "post a weekly cost summary to Teams" CloudFormation template originally hand-written for Microsoft Teams so it also supports Slack — the biggest challenge of this exercise. The state machine consists of exactly two states, both written as raw ASL via &lt;code&gt;sfn.CustomState&lt;/code&gt; (since there's no typed CDK task for the Cost Explorer or SNS AWS-SDK integrations).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getCostAndUsage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;sfn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CustomState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GetCostAndUsage&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="na"&gt;stateJson&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Task&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;Resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;arn:aws:states:::aws-sdk:costexplorer:getCostAndUsage&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;Arguments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* Granularity, Metrics, TimePeriod (JSONata), GroupBy, Filter */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;Assign&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;AngryThreshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;angryThresholdUsd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;AccountId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Aws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ACCOUNT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;Output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Start&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="na"&gt;CostSum&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="na"&gt;CostSorted&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="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;publishCostDigest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;sfn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CustomState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PublishCostDigest&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="na"&gt;stateJson&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Task&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;Resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;arn:aws:states:::sns:publish&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;Arguments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* title/description, JSONata templates */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;TopicArn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;topicArn&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;getCostAndUsage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;publishCostDigest&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;sfn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;StateMachine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CostDigestStateMachine&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="na"&gt;definitionBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sfn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DefinitionBody&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromChainable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;getCostAndUsage&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;queryLanguage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sfn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;QueryLanguage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;JSONATA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;tracingEnabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;logGroup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sfn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ALL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;includeExecutionData&lt;/span&gt;&lt;span class="p"&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;&lt;code&gt;Next&lt;/code&gt;/&lt;code&gt;End&lt;/code&gt; aren't hand-written anywhere. Just chaining with &lt;code&gt;.next()&lt;/code&gt; is enough — CDK's state graph composition attaches them automatically (&lt;code&gt;Next: "PublishCostDigest"&lt;/code&gt; on the first state, &lt;code&gt;End: true&lt;/code&gt; on the last).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A JS string-escaping trap worth knowing about.&lt;/strong&gt; The description message concatenates JSONata string literals for line breaks (&lt;code&gt;"\n"&lt;/code&gt;) and a regex for stripping the &lt;code&gt;AWS&lt;/code&gt;/&lt;code&gt;Amazon&lt;/code&gt; prefix off service names (&lt;code&gt;$replace(/^(AWS|Amazon)\s*/, "")&lt;/code&gt;). Write either escape as a &lt;em&gt;single&lt;/em&gt; backslash inside a TypeScript template literal and JavaScript resolves it immediately at the string-literal level — &lt;code&gt;\n&lt;/code&gt; silently becomes a real newline character, and &lt;code&gt;\s&lt;/code&gt; (not a recognized JS escape) silently loses its backslash entirely, leaving a bare &lt;code&gt;s&lt;/code&gt; that turns the regex into &lt;code&gt;s*&lt;/code&gt; and quietly breaks the service-name cleanup. The fix for both is the same: write &lt;code&gt;\\n&lt;/code&gt; and &lt;code&gt;\\s&lt;/code&gt; (double backslash) in the TypeScript source, so what actually reaches JSONata — after this string round-trips through JSON as part of the synthesized ASL — is the two-character escape sequence &lt;code&gt;\n&lt;/code&gt;/&lt;code&gt;\s&lt;/code&gt;, not a literal control character or a mangled regex. I found this not just by reading the JSONata spec, but by actually inspecting the synthesized &lt;code&gt;cdk synth&lt;/code&gt; output before writing tests.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The &lt;code&gt;EventBridge Scheduler&lt;/code&gt;'s &lt;code&gt;StepFunctionsStartExecution&lt;/code&gt; target (&lt;code&gt;aws-scheduler-targets&lt;/code&gt;) automatically generates and attaches the scheduler's own execution role — unlike the original CloudFormation template this was ported from, there's no need to hand-write the &lt;code&gt;states:StartExecution&lt;/code&gt; IAM statement.&lt;/p&gt;

&lt;p&gt;Also worth noting: the title/description JSONata expressions aren't one-off inline strings — they come from two locale-specific builder functions, &lt;code&gt;buildCostDigestTitleExpression&lt;/code&gt;/&lt;code&gt;buildCostDigestDescriptionExpression&lt;/code&gt;. Flipping &lt;code&gt;params.costDigest.locale&lt;/code&gt; (&lt;code&gt;'ja' | 'en'&lt;/code&gt;, default &lt;code&gt;'en'&lt;/code&gt;) is all it takes to switch to the Japanese digest message (e.g. "😱 Costs are spiking" / "😊 Costs are steady" becomes "😱 コストが跳ね上がっています" / "😊 コストは落ち着いています").&lt;/p&gt;




&lt;h2&gt;
  
  
  Deploy &amp;amp; Verify
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PROJECT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-project
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ENV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev

npm run bootstrap &lt;span class="nt"&gt;-w&lt;/span&gt; workspaces/budgets-cost-anomaly-detection   &lt;span class="c"&gt;# first time only&lt;/span&gt;
npm run synth &lt;span class="nt"&gt;-w&lt;/span&gt; workspaces/budgets-cost-anomaly-detection
npm run deploy:all &lt;span class="nt"&gt;-w&lt;/span&gt; workspaces/budgets-cost-anomaly-detection
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before deploying, replace the placeholder email address in &lt;code&gt;parameters/dev-params.ts&lt;/code&gt; with a real one (SNS/Budgets email subscriptions require confirmation). To use Slack/Teams delivery, uncomment &lt;code&gt;notification.slack&lt;/code&gt;/&lt;code&gt;notification.teams&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;&lt;span class="c"&gt;# Pattern A: check the budget configuration (real billing data updates only a few times a day, so no instant trigger)&lt;/span&gt;
aws budgets describe-budgets &lt;span class="nt"&gt;--account-id&lt;/span&gt; &amp;lt;account-id&amp;gt;

&lt;span class="c"&gt;# Pattern B: check the anomaly monitor/subscription (baseline establishment takes 24h+)&lt;/span&gt;
aws ce get-anomaly-monitors
aws ce get-anomaly-subscriptions

&lt;span class="c"&gt;# Pattern C: check the topic's subscriptions&lt;/span&gt;
aws sns list-subscriptions-by-topic &lt;span class="nt"&gt;--topic-arn&lt;/span&gt; &amp;lt;finops-alert-topic-arn&amp;gt;

&lt;span class="c"&gt;# Pattern D: check the alarm and its data (this stack only exists in us-east-1)&lt;/span&gt;
aws cloudwatch describe-alarms &lt;span class="nt"&gt;--alarm-names&lt;/span&gt; &amp;lt;project&amp;gt;-&amp;lt;&lt;span class="nb"&gt;env&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;-estimated-charges&lt;/span&gt; &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1

&lt;span class="c"&gt;# Pattern E: run on demand without waiting for the schedule&lt;/span&gt;
aws stepfunctions start-execution &lt;span class="nt"&gt;--state-machine-arn&lt;/span&gt; &amp;lt;cost-digest-state-machine-arn&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Cost Estimate
&lt;/h2&gt;

&lt;p&gt;💰 Rough monthly estimate (Tokyo region, Stack 4 only in us-east-1)&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Applies to&lt;/th&gt;
&lt;th&gt;Rough monthly cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AWS Budgets&lt;/td&gt;
&lt;td&gt;A, C&lt;/td&gt;
&lt;td&gt;First 2 budgets free; ~$0.02/day per budget after that&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS Cost Anomaly Detection&lt;/td&gt;
&lt;td&gt;B, C&lt;/td&gt;
&lt;td&gt;No additional charge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon SNS&lt;/td&gt;
&lt;td&gt;All&lt;/td&gt;
&lt;td&gt;First 1M requests within the free tier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS Chatbot&lt;/td&gt;
&lt;td&gt;C, E (optional)&lt;/td&gt;
&lt;td&gt;No additional charge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudWatch alarm (billing)&lt;/td&gt;
&lt;td&gt;D&lt;/td&gt;
&lt;td&gt;~$0.10/alarm/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Step Functions (Standard)&lt;/td&gt;
&lt;td&gt;E&lt;/td&gt;
&lt;td&gt;First 4,000 state transitions/month free; negligible at once daily&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EventBridge Scheduler&lt;/td&gt;
&lt;td&gt;E&lt;/td&gt;
&lt;td&gt;First 14M invocations/month free&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Rough total: effectively &lt;strong&gt;$0-2/month&lt;/strong&gt; at typical alert volumes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;What we learned from this pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pattern A (Budgets)&lt;/strong&gt;: Best for a known, fixed cost ceiling. Notifications fail silently if the SNS topic policy isn't set explicitly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pattern B (Anomaly Detection)&lt;/strong&gt;: Best for catching unexpected spend even within budget. SNS delivery requires &lt;code&gt;IMMEDIATE&lt;/code&gt; frequency, and &lt;code&gt;thresholdExpression&lt;/code&gt; is a hand-assembled JSON string&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pattern C (Unified)&lt;/strong&gt;: Consolidating into one alert channel is more operationally realistic than managing a separate topic per signal — but since AWS caps AWS-managed Cost Anomaly Detection monitors at effectively one or two per account, Stack 3 doesn't create its own monitor and instead attaches an extra subscription to Stack 2's monitor via a real cross-stack reference. &lt;code&gt;unifiedEscalation&lt;/code&gt; lets you split by severity by raising Stack 3's threshold, but as long as both stacks are deployed, the duplicate notification itself never goes away — so in real usage, pick either Stack 2 or Stack 3 for anomaly detection, not both&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pattern D (Billing Alarm)&lt;/strong&gt;: The simplest safety net, but has two gotchas CDK can't express: the manual "Receive Billing Alerts" opt-in, and being pinned to &lt;code&gt;us-east-1&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pattern E (Cost Digest)&lt;/strong&gt;: A proactive, scheduled digest complements the other four patterns well. &lt;code&gt;sfn.CustomState&lt;/code&gt; lets you write raw ASL/JSONata and skip Lambda entirely, but watch the string escaping at the JS → JSON → JSONata boundaries&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS Chatbot's &lt;code&gt;AdministratorAccess&lt;/code&gt; guardrail default&lt;/strong&gt; applies to both Slack and Teams, and an empty array doesn't disable it — always pin an explicit, least-privilege guardrail&lt;/li&gt;
&lt;li&gt;Wiring that repeats across stacks is worth factoring out into a construct like &lt;code&gt;common/constructs/cost/&lt;/code&gt; once, rather than copy-pasting&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/cost-management/latest/userguide/budgets-managing-costs.html" rel="noopener noreferrer"&gt;AWS Budgets User Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/cost-management/latest/userguide/budgets-sns-policy.html" rel="noopener noreferrer"&gt;Creating an Amazon SNS topic for budget notifications&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/cost-management/latest/userguide/manage-ad.html" rel="noopener noreferrer"&gt;AWS Cost Anomaly Detection&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://aws.amazon.com/blogs/aws-cloud-financial-management/extending-aws-managed-monitors-in-cost-anomaly-detection/" rel="noopener noreferrer"&gt;Extending AWS managed monitors in AWS Cost Anomaly Detection&lt;/a&gt; (the source for the "one SERVICE monitor per account" limit behind Stack 3's design)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/monitor_estimated_charges_with_cloudwatch.html" rel="noopener noreferrer"&gt;Create a billing alarm to monitor your estimated AWS charges&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/chatbot/latest/adminguide/teams-setup.html" rel="noopener noreferrer"&gt;Setting up AWS Chatbot with Microsoft Teams&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/step-functions/latest/dg/transforming-data.html" rel="noopener noreferrer"&gt;Variables and JSONata in AWS Step Functions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/scheduler/latest/UserGuide/what-is-scheduler.html" rel="noopener noreferrer"&gt;Amazon EventBridge Scheduler&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Let's keep learning practical AWS CDK patterns through the 100 drill exercises!&lt;br&gt;
If you found this helpful, please ⭐ the repository!&lt;/p&gt;

&lt;p&gt;📌 You can see the entire code in &lt;a href="https://github.com/ishiharatma/aws-cdk-reference-architectures" rel="noopener noreferrer"&gt;my GitHub repository&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cdk</category>
      <category>100drillexercises</category>
      <category>finops</category>
    </item>
    <item>
      <title>AWS CDK 100 Drill Exercises #009: CloudWatch Logs S3 Archive — Comparing 3 Log Archive Patterns in One Repository</title>
      <dc:creator>TOMOAKI ishihara</dc:creator>
      <pubDate>Mon, 17 Aug 2026 15:24:21 +0000</pubDate>
      <link>https://dev.to/aws-builders/aws-cdk-100-drill-exercises-009-cloudwatch-logs-s3-archive-comparing-3-log-archive-patterns-42g0</link>
      <guid>https://dev.to/aws-builders/aws-cdk-100-drill-exercises-009-cloudwatch-logs-s3-archive-comparing-3-log-archive-patterns-42g0</guid>
      <description>&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.amazonaws.com%2Fuploads%2Farticles%2Fvkpovqovupuz0fqscdot.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.amazonaws.com%2Fuploads%2Farticles%2Fvkpovqovupuz0fqscdot.png" alt="Level 200" width="70" height="20"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;This is the 9th installment of "AWS CDK 100 Drill Exercises." See &lt;a href="https://dev.to/aws-builders/introducing-aws-cdk-100-drill-exercises-learn-by-building-5949"&gt;here&lt;/a&gt; for an overview of the series.&lt;/p&gt;

&lt;p&gt;Leave CloudWatch Logs alone and anything past its retention period simply disappears. When you need to keep logs around for audits or incident investigation, most teams eventually land on "archive it to S3" — but there's more than one way to get there.&lt;/p&gt;

&lt;p&gt;This time we implement &lt;strong&gt;three different patterns&lt;/strong&gt; for archiving CloudWatch Logs to S3 as &lt;strong&gt;five independent CDK stacks&lt;/strong&gt;, and compare the latency, cost, and operational-simplicity trade-offs with concrete code.&lt;/p&gt;

&lt;h3&gt;
  
  
  What you'll learn in this article
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Near-real-time log archiving with Kinesis Data Firehose (via a subscription filter)&lt;/li&gt;
&lt;li&gt;Tiered storage-class transitions with S3 lifecycle rules (Standard → IA → Glacier IR → Deep Archive)&lt;/li&gt;
&lt;li&gt;How to bolt archiving onto an &lt;em&gt;existing&lt;/em&gt; CloudWatch Logs log group after the fact&lt;/li&gt;
&lt;li&gt;Scheduled batch export with the CloudWatch Logs Export Task API (and how to work around the one-concurrent-execution limit)&lt;/li&gt;
&lt;li&gt;Decoding the CWL payload in a Lambda subscription filter and writing it to S3 in a custom format&lt;/li&gt;
&lt;li&gt;How the required IAM trust relationships and bucket policies differ between patterns for the CloudWatch Logs service&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;📁 &lt;strong&gt;Code repository&lt;/strong&gt;: &lt;a href="https://github.com/ishiharatma/aws-cdk-reference-architectures/tree/main/infrastructure/workspaces/cloudwatch-logs-s3-archive" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&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%2Fs2s1qqqkilb9gx2sqke4.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%2Fs2s1qqqkilb9gx2sqke4.png" alt="Architecture Overview" width="800" height="574"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Stacks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A: Firehose&lt;/td&gt;
&lt;td&gt;Subscription filter → Kinesis Data Firehose → S3&lt;/td&gt;
&lt;td&gt;Basic / Lifecycle / Existing (3 stacks)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B: Export Task&lt;/td&gt;
&lt;td&gt;EventBridge Scheduler → Lambda → Export Task API → S3&lt;/td&gt;
&lt;td&gt;Export (1 stack)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C: Direct Lambda write&lt;/td&gt;
&lt;td&gt;Subscription filter → Lambda → S3&lt;/td&gt;
&lt;td&gt;Lambda (1 stack)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Why three patterns
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trait&lt;/th&gt;
&lt;th&gt;Pattern A (Firehose)&lt;/th&gt;
&lt;th&gt;Pattern B (Export Task)&lt;/th&gt;
&lt;th&gt;Pattern C (Lambda)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Delivery latency&lt;/td&gt;
&lt;td&gt;Near real-time (seconds to minutes)&lt;/td&gt;
&lt;td&gt;Scheduled (tolerates up to ~12h of delay)&lt;/td&gt;
&lt;td&gt;Near real-time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost at high volume&lt;/td&gt;
&lt;td&gt;Moderate (Firehose + S3)&lt;/td&gt;
&lt;td&gt;Low (Lambda + S3 only)&lt;/td&gt;
&lt;td&gt;Moderate (scales with Lambda invocation count)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output format&lt;/td&gt;
&lt;td&gt;Raw CWL JSON lines, GZIP-compressed&lt;/td&gt;
&lt;td&gt;Raw CWL format&lt;/td&gt;
&lt;td&gt;Custom JSON (fully controllable)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operational simplicity&lt;/td&gt;
&lt;td&gt;High (managed Firehose)&lt;/td&gt;
&lt;td&gt;High (managed Export API)&lt;/td&gt;
&lt;td&gt;Low (Lambda code needs maintenance)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom transformation&lt;/td&gt;
&lt;td&gt;Limited (depends on Firehose's data transformation feature)&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Fully controllable inside Lambda&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In short: &lt;strong&gt;A&lt;/strong&gt; if you need near-real-time delivery, &lt;strong&gt;B&lt;/strong&gt; if a cost-optimized batch job is good enough, &lt;strong&gt;C&lt;/strong&gt; if you want to define the output format yourself.&lt;/p&gt;

&lt;h3&gt;
  
  
  Data Flow
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Pattern A (Stacks 1-3)
CloudWatch Log Group
  → Subscription filter (SubscriptionFilter + FirehoseDestination, role = CwlToFirehoseRole)
  → Kinesis Data Firehose (GZIP compression)
  → S3 archive bucket
  * Stack 1 (Basic) shares 5 log groups and uses dynamic partitioning on owner/logGroup
    to route each log group to its own S3 prefix. Stack 2 (Lifecycle) uses a single log
    group with a plain date prefix and no dynamic partitioning.

Pattern B (Stack 4)
EventBridge Scheduler
  → Lambda (calls logs:CreateExportTask for the previous day's logs)
  → CloudWatch Logs Export Task API
  → S3 archive bucket (bucket policy allows writes from logs.amazonaws.com)

Pattern C (Stack 5)
CloudWatch Log Group
  → Subscription filter (LambdaDestination, CDK manages invoke permissions automatically)
  → Lambda (decodes the gzip+base64 CWL payload and writes JSON to S3)
  → S3 archive bucket
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Key Components
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Design point&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;S3 archive bucket&lt;/td&gt;
&lt;td&gt;SSE-S3 encryption, versioning enabled, public access blocked, SSL enforced. Provisioned independently per stack (5 buckets total)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CwlToFirehoseRole&lt;/td&gt;
&lt;td&gt;Assumed by &lt;code&gt;logs.amazonaws.com&lt;/code&gt;. Scoped to log groups via an &lt;code&gt;aws:SourceArn&lt;/code&gt; condition&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FirehoseRole&lt;/td&gt;
&lt;td&gt;Assumed by &lt;code&gt;firehose.amazonaws.com&lt;/code&gt;. Grants the standard Firehose→S3 permission set (&lt;code&gt;PutObject&lt;/code&gt;, &lt;code&gt;GetObject&lt;/code&gt;, &lt;code&gt;ListBucket&lt;/code&gt;, multipart upload actions) scoped to the archive bucket&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynamic partitioning (Stack 1)&lt;/td&gt;
&lt;td&gt;jq extracts &lt;code&gt;owner&lt;/code&gt;/&lt;code&gt;logGroup&lt;/code&gt; to route 5 shared log groups into separate S3 prefixes. &lt;code&gt;CloudWatchLogProcessor&lt;/code&gt; can't be used because it strips those fields; a jq fallback is required to handle &lt;code&gt;CONTROL_MESSAGE&lt;/code&gt; records&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tiered lifecycle rules (Stack 2)&lt;/td&gt;
&lt;td&gt;Standard → IA (30 days) → Glacier IR (90 days) → Deep Archive (365 days) → expiration (7 years)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Importing an existing log group (Stack 3)&lt;/td&gt;
&lt;td&gt;Referenced via &lt;code&gt;LogGroup.fromLogGroupName()&lt;/code&gt;. No new &lt;code&gt;AWS::Logs::LogGroup&lt;/code&gt; resource is created&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Export Task Lambda (Stack 4)&lt;/td&gt;
&lt;td&gt;A concurrency guard that checks for running/pending tasks before calling &lt;code&gt;CreateExportTask&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Direct Lambda write (Stack 5)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;LambdaDestination&lt;/code&gt; automatically manages invoke permissions. The payload is decoded and saved as custom JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Implementation Highlights
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Pattern A — Kinesis Data Firehose subscription
&lt;/h3&gt;

&lt;p&gt;For CloudWatch Logs to deliver to Firehose, it needs an explicit IAM role. We split responsibilities across two roles.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// CwlToFirehoseRole: assumed by logs.amazonaws.com. Only the trust policy is defined here&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cwlToFirehoseRole&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CwlToFirehoseRole&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="na"&gt;assumedBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ServicePrincipal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;logs.amazonaws.com&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="na"&gt;conditions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;StringLike&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;aws:SourceArn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`arn:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;partition&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:logs:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;region&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:log-group:*`&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="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// L2 SubscriptionFilter + FirehoseDestination(role: cwlToFirehoseRole)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;filterPattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;logGroupParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;filterPattern&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;defaultLogGroupArchiveConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;filterPattern&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SubscriptionFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CwlSubscriptionFilter&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="na"&gt;logGroup&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logGroup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;logs_destinations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FirehoseDestination&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deliveryStream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cwlToFirehoseRole&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="na"&gt;filterPattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;filterPattern&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FilterPattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;literal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filterPattern&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FilterPattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allEvents&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;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why not attach &lt;code&gt;firehose:PutRecord&lt;/code&gt;/&lt;code&gt;PutRecordBatch&lt;/code&gt; permissions directly on the role?&lt;/strong&gt;&lt;br&gt;
Inside &lt;code&gt;bind()&lt;/code&gt;, &lt;code&gt;aws-logs-destinations&lt;/code&gt;'s &lt;code&gt;FirehoseDestination&lt;/code&gt; calls &lt;code&gt;deliveryStream.grantPutRecords(role)&lt;/code&gt; on the role you pass in (or one it generates), automatically granting the necessary permissions. If you also add your own inline policy to the role, you end up with two &lt;code&gt;AWS::IAM::Policy&lt;/code&gt; resources granting the same permission. The trick is to define only the trust policy yourself (scoped by &lt;code&gt;SourceArn&lt;/code&gt;) and let the L2 construct handle the permission grant.&lt;/p&gt;

&lt;p&gt;I used to write this with the L1 &lt;code&gt;CfnSubscriptionFilter&lt;/code&gt; plus a hand-rolled inline policy, on the assumption that "the L2 &lt;code&gt;SubscriptionFilter&lt;/code&gt; can't accept a &lt;code&gt;roleArn&lt;/code&gt; for Firehose." That assumption was wrong. In the current &lt;code&gt;aws-cdk-lib&lt;/code&gt; (2.261.0 in this repository), &lt;code&gt;FirehoseDestination&lt;/code&gt; wires the role's ARN all the way through to &lt;code&gt;CfnSubscriptionFilter&lt;/code&gt; for you, so the L2 construct alone is sufficient.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The &lt;code&gt;aws:SourceArn&lt;/code&gt; condition scopes who can assume this role down to "log groups within this account/region." Combined with scoping the access policy side to just this specific Firehose delivery stream, permissions are minimized in two independent layers.&lt;/p&gt;

&lt;h3&gt;
  
  
  1a. Per-log-group dynamic partitioning (Stack 1 – Basic)
&lt;/h3&gt;

&lt;p&gt;Stack 1 creates &lt;strong&gt;5 log groups&lt;/strong&gt; that share the same Firehose delivery stream. Without partitioning, events from all five would end up mixed together in the same S3 object, so dynamic partitioning routes each log group to its own prefix.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s3Destination&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;firehose&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;S3Bucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;archiveBucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;dataOutputPrefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AWSLogs/!{partitionKeyFromQuery:owner}/CWLogGroup/!{partitionKeyFromQuery:logGroup}/!{timestamp:yyyy/MM/dd/HH}/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;dynamicPartitioning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;bufferingInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// dynamic partitioning requires &amp;gt;= 60s&lt;/span&gt;
    &lt;span class="na"&gt;bufferingSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Size&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mebibytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;        &lt;span class="c1"&gt;// and &amp;gt;= 64MiB&lt;/span&gt;
    &lt;span class="na"&gt;processors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;firehose&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DecompressionProcessor&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="na"&gt;compressionFormat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;firehose&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DecompressionProcessorCompressionFormat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GZIP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="nx"&gt;firehose&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MetadataExtractionProcessor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;jq16&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="na"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;(if (.owner // "") == "" then "controlmessages" else .owner end)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;logGroup&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;(if (.logGroup // "") == "" then "controlmessages" else (.logGroup | ltrimstr("/")) end)&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;new&lt;/span&gt; &lt;span class="nx"&gt;firehose&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AppendDelimiterToRecordProcessor&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I hit two snags here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First: don't add the &lt;code&gt;CloudWatchLogProcessor&lt;/code&gt; (message extraction).&lt;/strong&gt; Firehose has a "message extraction" feature that pulls just the contents of &lt;code&gt;message&lt;/code&gt; out of a decompressed CWL record — but enabling it wipes out &lt;code&gt;owner&lt;/code&gt;/&lt;code&gt;logGroup&lt;/code&gt;/&lt;code&gt;logStream&lt;/code&gt; entirely, leaving only the raw contents of &lt;code&gt;message&lt;/code&gt;. With those gone, the downstream &lt;code&gt;MetadataExtractionProcessor&lt;/code&gt; fails with &lt;code&gt;DynamicPartitioning.MetadataExtractionFailed&lt;/code&gt; when it tries to reference &lt;code&gt;.owner&lt;/code&gt;/&lt;code&gt;.logGroup&lt;/code&gt; via jq. Right after &lt;code&gt;DecompressionProcessor&lt;/code&gt;, the record still has &lt;code&gt;owner&lt;/code&gt;/&lt;code&gt;logGroup&lt;/code&gt; at the top level (see &lt;a href="https://docs.aws.amazon.com/firehose/latest/dev/Message_extraction.html" rel="noopener noreferrer"&gt;Fig. 1 of Firehose's "message extraction" documentation&lt;/a&gt;), so jq can read them directly without a message extraction processor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second: handling the &lt;code&gt;CONTROL_MESSAGE&lt;/code&gt; records CloudWatch Logs periodically sends as a health check.&lt;/strong&gt; These records have &lt;code&gt;owner&lt;/code&gt;/&lt;code&gt;logGroup&lt;/code&gt; set to the empty string &lt;code&gt;""&lt;/code&gt;, so a naive &lt;code&gt;.logGroup | ltrimstr("/")&lt;/code&gt; returns the empty string as-is, which fails with &lt;code&gt;partitionKeys values must not be null or empty&lt;/code&gt;. You need a jq &lt;code&gt;if/then/else&lt;/code&gt; to supply a fallback value (&lt;code&gt;"controlmessages"&lt;/code&gt;). It's tempting to reach for &lt;code&gt;//&lt;/code&gt; (the alternative operator) here, but &lt;code&gt;//&lt;/code&gt; only substitutes on &lt;code&gt;null&lt;/code&gt;/&lt;code&gt;false&lt;/code&gt;, not on an empty string.&lt;/p&gt;

&lt;p&gt;The trade-off is that each record written to S3 is the &lt;strong&gt;entire CWL envelope&lt;/strong&gt; (with a &lt;code&gt;logEvents&lt;/code&gt; array that may contain multiple events), not a flat one-row-per-log-event line. Flattening (&lt;code&gt;CloudWatchLogProcessor&lt;/code&gt;) is incompatible with metadata-based partitioning. If you need individual messages, &lt;code&gt;UNNEST&lt;/code&gt;/&lt;code&gt;json_extract&lt;/code&gt; the &lt;code&gt;logEvents&lt;/code&gt; array in something like Athena.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Tiered S3 lifecycle (Stack 2 – Lifecycle)
&lt;/h3&gt;

&lt;p&gt;For long-term archives, you can optimize cost by moving objects to progressively cheaper storage classes as access frequency drops.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;archiveBucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addLifecycleRule&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`MoveToIAAfter&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;moveToIaAfterDays&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;Days`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;transitions&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="na"&gt;storageClass&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="nx"&gt;StorageClass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;INFREQUENT_ACCESS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;transitionAfter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;days&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;moveToIaAfterDays&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;archiveBucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addLifecycleRule&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`MoveToGlacierAfter&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;moveToGlacierAfterDays&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;Days`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;transitions&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="na"&gt;storageClass&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="nx"&gt;StorageClass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GLACIER_INSTANT_RETRIEVAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;transitionAfter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;days&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;moveToGlacierAfterDays&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;archiveBucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addLifecycleRule&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`MoveToDeepArchiveAfter&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;moveToDeepArchiveAfterDays&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;Days`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;transitions&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="na"&gt;storageClass&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="nx"&gt;StorageClass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DEEP_ARCHIVE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;transitionAfter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;days&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;moveToDeepArchiveAfterDays&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;archiveBucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addLifecycleRule&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`ExpireCurrentObjectsAfter&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;expireAfterDays&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;Days`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;expiration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;days&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expireAfterDays&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;Non-current versions (older objects overwritten while versioning is enabled) get their own IA transition and expiration rule, and incomplete multipart uploads are aborted after 7 days. Rather than bundling everything into one giant lifecycle configuration, each transition gets its own &lt;code&gt;addLifecycleRule&lt;/code&gt; so parameters like &lt;code&gt;moveToIaAfterDays&lt;/code&gt; can be tuned per environment independently.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Attaching to an existing log group (Stack 3 – Existing)
&lt;/h3&gt;

&lt;p&gt;Sometimes you don't want to create a new log group — you want to bolt archiving onto a log group an application is already writing to.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existingLogGroup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LogGroup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromLogGroupName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ExistingLogGroup&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;existingLogGroupParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logGroupName&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;&lt;code&gt;fromLogGroupName()&lt;/code&gt; only references the log group in CDK — it doesn't create an &lt;code&gt;AWS::Logs::LogGroup&lt;/code&gt; resource. Since you can pass this existing log group's ARN (&lt;code&gt;existingLogGroup.logGroupArn&lt;/code&gt;) straight into the trust policy's &lt;code&gt;aws:SourceArn&lt;/code&gt; condition, the subscription filter and IAM role wiring can reuse the exact same code as Stacks 1 and 2.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Pattern B — Scheduled Export Task
&lt;/h3&gt;

&lt;p&gt;The CloudWatch Logs Export Task API has a &lt;strong&gt;one concurrent execution per account&lt;/strong&gt; limit. Before submitting a new task, the Lambda checks for running or pending tasks to avoid errors.&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;# export-task/index.py (core logic)
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lambda_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="n"&gt;logs_client&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;logs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Skip if a task is already running or pending
&lt;/span&gt;    &lt;span class="n"&gt;running&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logs_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe_export_tasks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RUNNING&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logs_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe_export_tasks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PENDING&lt;/span&gt;&lt;span class="sh"&gt;"&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;running&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exportTasks&lt;/span&gt;&lt;span class="sh"&gt;"&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;pending&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exportTasks&lt;/span&gt;&lt;span class="sh"&gt;"&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;An export task is already running or pending. Skipping.&lt;/span&gt;&lt;span class="sh"&gt;"&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;status&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;skipped&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;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;task_in_progress&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# Export the previous day's (UTC) logs
&lt;/span&gt;    &lt;span class="n"&gt;yesterday&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utcnow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&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="n"&gt;from_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;yesterday&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hour&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="n"&gt;minute&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="n"&gt;second&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="n"&gt;microsecond&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="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;to_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;yesterday&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;23&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minute&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;59&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;second&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;59&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;microsecond&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;999999&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logs_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_export_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;taskName&lt;/span&gt;&lt;span class="o"&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;export-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;yesterday&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%Y-%m-%d&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;logGroupName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;LOG_GROUP_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;fromTime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;from_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;to_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;S3_BUCKET_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;destinationPrefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;S3_PREFIX&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;yesterday&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%Y/%m/%d&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&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;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;status&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;created&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;taskId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;taskId&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;The key detail is checking &lt;code&gt;PENDING&lt;/code&gt; in addition to &lt;code&gt;RUNNING&lt;/code&gt;. If a scheduled task is merely sitting in the queue and you submit the next one anyway, you get a &lt;code&gt;LimitExceededException&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The S3 bucket side needs two resource policies allowing writes from the CloudWatch Logs service principal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 1. Permission to check the bucket ACL before exporting&lt;/span&gt;
&lt;span class="nx"&gt;archiveBucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addToResourcePolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PolicyStatement&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;effect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ALLOW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;principals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ServicePrincipal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;logs.amazonaws.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
        &lt;span class="na"&gt;actions&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;s3:GetBucketAcl&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;archiveBucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bucketArn&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;conditions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;ArnLike&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;aws:SourceArn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`arn:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;partition&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:logs:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;region&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:log-group:*`&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="c1"&gt;// 2. Permission to write the exported objects&lt;/span&gt;
&lt;span class="nx"&gt;archiveBucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addToResourcePolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PolicyStatement&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;effect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ALLOW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;principals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ServicePrincipal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;logs.amazonaws.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
        &lt;span class="na"&gt;actions&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;s3:PutObject&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;archiveBucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arnForObjects&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="na"&gt;conditions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;StringEquals&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;s3:x-amz-acl&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;bucket-owner-full-control&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="na"&gt;ArnLike&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;aws:SourceArn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`arn:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;partition&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:logs:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;region&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:log-group:*`&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The condition pinning &lt;code&gt;s3:x-amz-acl&lt;/code&gt; to &lt;code&gt;bucket-owner-full-control&lt;/code&gt; is explicitly required by &lt;a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/S3ExportTasksConsole.html" rel="noopener noreferrer"&gt;CloudWatch Logs' official documentation&lt;/a&gt;. Omit it and the export task fails with a permissions error.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Pattern C — Subscription filter → Lambda → S3
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;LambdaDestination&lt;/code&gt;, CDK automatically manages the resource policy (&lt;code&gt;Lambda::Permission&lt;/code&gt;) that lets &lt;code&gt;logs.amazonaws.com&lt;/code&gt; invoke your Lambda.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SubscriptionFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CwlSubscriptionFilter&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="na"&gt;logGroup&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logGroup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;logDestinations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;LambdaDestination&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;archiveFunction&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;filterPattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;filterPattern&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FilterPattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;literal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filterPattern&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FilterPattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allEvents&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;On the Lambda side, the code decodes the CWL compressed payload and writes it to S3 in a JSON format it defines itself, per invocation.&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;# cwl-to-s3/index.py (core logic)
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lambda_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="n"&gt;compressed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64decode&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;awslogs&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;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&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;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gzip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decompress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;compressed&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;payload&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;messageType&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CONTROL_MESSAGE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Health-check message CloudWatch Logs sends, e.g. when a subscription starts
&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;status&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;skipped&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;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;control_message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utcnow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;safe_stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;logStream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&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;_&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$&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="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[&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="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;]&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="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;s3_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;S3_PREFIX&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%Y/%m/%d/%H&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;safe_stream&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nb"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;record&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;logGroup&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;logGroup&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;logStream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;logStream&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;owner&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;payload&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;owner&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;exportedAt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isoformat&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="s"&gt;Z&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;events&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;logEvents&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;s3_client&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;S3_BUCKET_NAME&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;s3_key&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;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;record&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ensure_ascii&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&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="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;status&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;ok&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="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;logEvents&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;s3Key&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_key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the early skip for payloads with &lt;code&gt;messageType == "CONTROL_MESSAGE"&lt;/code&gt;. CloudWatch Logs sends these health-check control messages right after a subscription starts (among other times), and they have no &lt;code&gt;logEvents&lt;/code&gt; key at all — without this check, the invocation fails with a &lt;code&gt;KeyError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Both patterns A and C deliver via a subscription filter for near-immediate delivery, but with A you can let Firehose handle the transformation, while with C you write the Lambda code yourself and get complete control over the output format in exchange.&lt;/p&gt;




&lt;h2&gt;
  
  
  Deploy &amp;amp; Verify
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PROJECT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-project
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ENV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev

npm run bootstrap &lt;span class="nt"&gt;-w&lt;/span&gt; workspaces/cloudwatch-logs-s3-archive   &lt;span class="c"&gt;# first time only&lt;/span&gt;
npm run stage:deploy:all &lt;span class="nt"&gt;-w&lt;/span&gt; workspaces/cloudwatch-logs-s3-archive
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To deploy individual stacks, use the per-stack &lt;code&gt;stage:deploy:*&lt;/code&gt; scripts (each scoped to a &lt;code&gt;cdk deploy&lt;/code&gt; stack selector like &lt;code&gt;**/*Basic&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;npm run stage:deploy:basic &lt;span class="nt"&gt;-w&lt;/span&gt; workspaces/cloudwatch-logs-s3-archive
npm run stage:deploy:lifecycle &lt;span class="nt"&gt;-w&lt;/span&gt; workspaces/cloudwatch-logs-s3-archive
npm run stage:deploy:export &lt;span class="nt"&gt;-w&lt;/span&gt; workspaces/cloudwatch-logs-s3-archive
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Pattern A Stack 1 (Basic): write test data into 5 log groups (/&amp;lt;project&amp;gt;/&amp;lt;env&amp;gt;/basic-*)&lt;/span&gt;
&lt;span class="c"&gt;# (given the dynamic-partitioning buffering requirements of &amp;gt;= 60s / &amp;gt;= 64MiB, use the&lt;/span&gt;
&lt;span class="c"&gt;#  repo's helper script rather than a one-off put-log-events call)&lt;/span&gt;
./write-test-logs.sh &lt;span class="nt"&gt;--project&lt;/span&gt; &amp;lt;project&amp;gt; &lt;span class="nt"&gt;--env&lt;/span&gt; &amp;lt;&lt;span class="nb"&gt;env&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;# After ~60s, output appears partitioned under AWSLogs/&amp;lt;owner&amp;gt;/CWLogGroup/&amp;lt;logGroup&amp;gt;/...&lt;/span&gt;
aws s3 &lt;span class="nb"&gt;ls &lt;/span&gt;s3://&amp;lt;archive-bucket&amp;gt;/AWSLogs/ &lt;span class="nt"&gt;--recursive&lt;/span&gt;

&lt;span class="c"&gt;# Pattern A Stack 2 (Lifecycle): single log group, no dynamic partitioning&lt;/span&gt;
./write-test-logs-lifecycle.sh &lt;span class="nt"&gt;--project&lt;/span&gt; &amp;lt;project&amp;gt; &lt;span class="nt"&gt;--env&lt;/span&gt; &amp;lt;&lt;span class="nb"&gt;env&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
aws s3 &lt;span class="nb"&gt;ls &lt;/span&gt;s3://&amp;lt;archive-bucket&amp;gt;/ &lt;span class="nt"&gt;--recursive&lt;/span&gt;

&lt;span class="c"&gt;# Pattern B: manually invoke the export Lambda&lt;/span&gt;
aws lambda invoke &lt;span class="nt"&gt;--function-name&lt;/span&gt; &amp;lt;project&amp;gt;-&amp;lt;&lt;span class="nb"&gt;env&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;-cwl-export-task&lt;/span&gt; &lt;span class="nt"&gt;--payload&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt; response.json
&lt;span class="nb"&gt;cat &lt;/span&gt;response.json

&lt;span class="c"&gt;# Pattern C: write a test log entry (the subscription filter invokes the Lambda immediately)&lt;/span&gt;
aws logs put-log-events &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--log-group-name&lt;/span&gt; /&amp;lt;project&amp;gt;/&amp;lt;&lt;span class="nb"&gt;env&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/app-lambda &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--log-stream-name&lt;/span&gt; test-stream &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--log-events&lt;/span&gt; &lt;span class="nv"&gt;timestamp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s000&lt;span class="si"&gt;)&lt;/span&gt;,message&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"hello lambda"&lt;/span&gt;
aws s3 &lt;span class="nb"&gt;ls &lt;/span&gt;s3://&amp;lt;archive-bucket&amp;gt;/subscriptions/ &lt;span class="nt"&gt;--recursive&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Cost Estimate
&lt;/h2&gt;

&lt;p&gt;💰 Rough monthly estimate (Tokyo region, 1 GB of log data per day)&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Applies to&lt;/th&gt;
&lt;th&gt;Rough monthly cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Kinesis Data Firehose&lt;/td&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;~$0.90 (at $0.03/GB)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lambda&lt;/td&gt;
&lt;td&gt;B, C&lt;/td&gt;
&lt;td&gt;Effectively negligible at low invocation counts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 (Standard)&lt;/td&gt;
&lt;td&gt;All&lt;/td&gt;
&lt;td&gt;~$0.75 (at $0.025/GB-month)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudWatch Logs (ingestion)&lt;/td&gt;
&lt;td&gt;All&lt;/td&gt;
&lt;td&gt;~$0.76/GB (charged regardless of which archive pattern you use, and not included in the total below)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EventBridge&lt;/td&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;$1.00 per million events (negligible at minimal frequency)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Rough total for Pattern A's &lt;em&gt;archiving&lt;/em&gt; cost alone: about $2/month at 1 GB/day of log volume. CloudWatch Logs ingestion itself (~$23/month at 1 GB/day) is a separate cost you pay upfront, before any archive pattern runs&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Running all five stacks simultaneously at production-scale traffic for comparison testing will cost more than the above. Don't forget to clean up afterward with &lt;code&gt;npm run stage:destroy:all -w workspaces/cloudwatch-logs-s3-archive&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;What we learned from this pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pattern A (Firehose)&lt;/strong&gt;: Minimal code, well-suited for near-real-time archiving. Delivery from CloudWatch Logs requires &lt;code&gt;FirehoseDestination&lt;/code&gt; (L2) plus two IAM roles with distinct responsibilities&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pattern B (Export Task)&lt;/strong&gt;: The lowest cost for batch use cases, but limited to one concurrent execution per account. A guard checking for running/pending tasks on the Lambda side is essential&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pattern C (Lambda)&lt;/strong&gt;: Full control over the output format, at the cost of ongoing Lambda code maintenance, including handling control messages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IAM trust relationships differ by service&lt;/strong&gt;: For Firehose, &lt;code&gt;FirehoseDestination&lt;/code&gt; grants permission to the trusted role via &lt;code&gt;grantPutRecords&lt;/code&gt;; for Export Task, an S3 bucket policy with an &lt;code&gt;s3:x-amz-acl&lt;/code&gt; condition is required; for &lt;code&gt;LambdaDestination&lt;/code&gt;, CDK auto-generates the invoke permission — the same "delivery from CloudWatch Logs" concept wires up differently every time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split tiered lifecycle rules by transition&lt;/strong&gt;: rather than one giant rule, separate rules make the &lt;code&gt;transitionAfter&lt;/code&gt; thresholds easy to tune independently as environment parameters&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fromLogGroupName()&lt;/code&gt; is enough for bolting archiving onto an existing log group&lt;/strong&gt;: pass the existing ARN into the trust policy directly and reuse the same wiring, with no new resource created&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firehose dynamic partitioning is incompatible with message extraction&lt;/strong&gt;: &lt;code&gt;CloudWatchLogProcessor&lt;/code&gt; strips &lt;code&gt;owner&lt;/code&gt;/&lt;code&gt;logGroup&lt;/code&gt;, so drop it if you're using jq to derive partition keys from those fields. You also need to account for CloudWatch Logs' &lt;code&gt;CONTROL_MESSAGE&lt;/code&gt; records (where &lt;code&gt;owner&lt;/code&gt;/&lt;code&gt;logGroup&lt;/code&gt; are empty strings) with a jq &lt;code&gt;if/then/else&lt;/code&gt; fallback (&lt;code&gt;//&lt;/code&gt; doesn't catch empty strings)&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/Subscriptions.html" rel="noopener noreferrer"&gt;Subscription filters for CloudWatch Logs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/S3Export.html" rel="noopener noreferrer"&gt;Exporting CloudWatch Logs to S3&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/firehose/latest/dev/what-is-this-service.html" rel="noopener noreferrer"&gt;Kinesis Data Firehose Developer Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lifecycle-mgmt.html" rel="noopener noreferrer"&gt;S3 lifecycle configuration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/cdklabs/cdk-nag/blob/main/RULES.md" rel="noopener noreferrer"&gt;CDK Nag AwsSolutions rules&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Let's keep learning practical AWS CDK patterns through the 100 drill exercises!&lt;br&gt;
If you found this helpful, please ⭐ the repository!&lt;/p&gt;

&lt;p&gt;📌 You can see the entire code in &lt;a href="https://github.com/ishiharatma/aws-cdk-reference-architectures" rel="noopener noreferrer"&gt;my GitHub repository&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cdk</category>
      <category>100drillexercises</category>
      <category>cloudwatchlogs</category>
    </item>
    <item>
      <title>AWS CDK 100 Drill Exercises #008: CloudFront VPC Origin — Reaching an Internal ALB Directly, and Designing an Incident-Response Escape Hatch</title>
      <dc:creator>TOMOAKI ishihara</dc:creator>
      <pubDate>Tue, 28 Jul 2026 15:19:43 +0000</pubDate>
      <link>https://dev.to/aws-builders/aws-cdk-100-drill-exercises-008-cloudfront-vpc-origin-reaching-an-internal-alb-directly-and-1cd1</link>
      <guid>https://dev.to/aws-builders/aws-cdk-100-drill-exercises-008-cloudfront-vpc-origin-reaching-an-internal-alb-directly-and-1cd1</guid>
      <description>&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.amazonaws.com%2Fuploads%2Farticles%2Fr1wyy0xk7f46wb5kdjqb.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.amazonaws.com%2Fuploads%2Farticles%2Fr1wyy0xk7f46wb5kdjqb.png" alt="Level 300" width="70" height="20"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;This is the 8th installment of "AWS CDK 100 Drill Exercises." See &lt;a href="https://dev.to/aws-builders/introducing-aws-cdk-100-drill-exercises-learn-by-building-5949"&gt;here&lt;/a&gt; for an overview of the series.&lt;/p&gt;

&lt;p&gt;This time, we place two very different origins behind a single Amazon CloudFront distribution: a private S3 bucket serving a static site, and a private, internal ALB that CloudFront can reach only through a &lt;strong&gt;VPC Origin&lt;/strong&gt;. No public IP, no NAT Gateway — CloudFront reaches the ALB directly in a private subnet.&lt;/p&gt;

&lt;p&gt;And we don't stop at a "working architecture" — we also build in &lt;strong&gt;an incident-response mechanism based on a real AWS outage&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;To keep this sample simple, the CloudFront⇔ALB communication uses HTTP (port 80) only. In production, you'd want to attach an ACM certificate to the ALB, add an HTTPS listener, and switch CloudFront's origin protocol policy to &lt;code&gt;HTTPS_ONLY&lt;/code&gt; (or &lt;code&gt;match-viewer&lt;/code&gt;).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  What you'll learn in this article
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;How to serve content without ever making an origin public, using VPC Origin + Origin Access Control (OAC)&lt;/li&gt;
&lt;li&gt;Automatic failover from a dynamic origin to a static one via Origin Group&lt;/li&gt;
&lt;li&gt;How to allow-list IP addresses with AWS WAF&lt;/li&gt;
&lt;li&gt;Recording denied requests to CloudWatch Logs via CloudFront Function + standard logging v2&lt;/li&gt;
&lt;li&gt;How to deal with the constraint that &lt;strong&gt;some CloudFront APIs and metrics are pinned to us-east-1&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;A way of thinking about designing "what to do when it breaks" as a CDK parameter, assuming failure up front&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;📁 &lt;strong&gt;Code repository&lt;/strong&gt;: &lt;a href="https://github.com/ishiharatma/aws-cdk-reference-architectures/tree/main/infrastructure/workspaces/cloudfront-vpc-origin" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&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%2Fk11kz5uzo54pbal9rt3j.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%2Fk11kz5uzo54pbal9rt3j.png" alt="Architecture Overview" width="799" height="507"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Benefit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VPC Origin&lt;/td&gt;
&lt;td&gt;CloudFront reaches a private ALB directly, with no public IP and no NAT Gateway&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Origin Group failover&lt;/td&gt;
&lt;td&gt;Shows a static fallback page instead of a raw 502/503 when the ALB is unhealthy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edge IP allow-listing&lt;/td&gt;
&lt;td&gt;Disallowed IPs are rejected at the edge before ever reaching the ALB or S3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Geo restriction&lt;/td&gt;
&lt;td&gt;Restricts delivery to only the explicitly allowed countries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Incident-response escape hatch&lt;/td&gt;
&lt;td&gt;Routes around a VPC Origin-side outage with just a parameter switch — no code changes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Data Flow
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Viewer
  │  HTTPS (min TLS 1.3, geo-restricted to JP/US/GB/CA/AU/NZ/IE)
  ▼
CloudFront distribution
  ├─ WAF (Web ACL, always active): managed rules + rejects IPs not on the allow list
  ├─ CloudFront Function (optional, disabled by default): rejects IPs not on the allow list
  │
  ├─ Default behavior "/*"
  │     └─ S3 origin (OAC) ────────► WebsiteBucket (private)
  │
  └─ Behavior "/alb/*"
      └─ Origin Group
          ├─ Primary: VPC Origin ──► Internal ALB (private subnet)
          └─ Fallback (403/404/5xx): S3 origin (OAC) ─► ErrorBucket (private)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ALB's security group allows inbound traffic only on port 80 from the CloudFront-managed prefix list. CloudFront itself is reachable from the internet, but the ALB is not directly accessible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Key Components
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Design Points&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ALB (internal)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;internetFacing: false&lt;/code&gt;, placed in a private-isolated subnet. The VPC Origin's target&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ALB (Public, created only when &lt;code&gt;publicAlbFailover&lt;/code&gt; is enabled)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;internetFacing: true&lt;/code&gt;, public subnet. A dedicated security group also allows only the CloudFront-managed prefix list&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebsiteBucket / ErrorBucket&lt;/td&gt;
&lt;td&gt;Private S3 buckets, readable only by CloudFront via OAC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudFront distribution&lt;/td&gt;
&lt;td&gt;Min TLS 1.3, geo restriction, dedicated access log bucket&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudfrontWafStack (pinned to us-east-1)&lt;/td&gt;
&lt;td&gt;Managed rules (Core/KnownBadInputs) + IP allow list (before/after rules selectable), defaultAction is block, logs delivered directly to S3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudFront Function&lt;/td&gt;
&lt;td&gt;Viewer IP allow list (optional, created only when &lt;code&gt;allowedCloudFunctionIps&lt;/code&gt; is set), evaluated on &lt;code&gt;viewer-request&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudfrontLogDeliveryStack (pinned to us-east-1, created only when the CloudFront Function is used)&lt;/td&gt;
&lt;td&gt;Delivers denied-request logs to CloudWatch Logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudfrontMonitoringStack (pinned to us-east-1)&lt;/td&gt;
&lt;td&gt;5xx error rate alarm + SNS notification&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Implementation Highlights
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. An internal ALB reachable only from CloudFront
&lt;/h3&gt;

&lt;p&gt;The ALB's security group opens port 80 only to the CloudFront-managed prefix list.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;albSecurityGroup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addIngressRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;ec2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prefixList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cloudfrontManagedPrefixList&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nx"&gt;ec2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Port&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tcp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Allow inbound HTTP traffic from CloudFront managed prefix list&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;alb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;elbv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ApplicationLoadBalancer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Alb&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="na"&gt;vpc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;internetFacing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;securityGroup&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;albSecurityGroup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;vpcSubnets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;subnetType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ec2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SubnetType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PRIVATE_ISOLATED&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;blockquote&gt;
&lt;p&gt;The prefix list ID is region-specific. Check it beforehand.&lt;/p&gt;


&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ec2 describe-managed-prefix-lists &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--filters&lt;/span&gt; &lt;span class="s2"&gt;"Name=prefix-list-name,Values=com.amazonaws.global.cloudfront.origin-facing"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  2. Automatic failover with VPC Origin and Origin Group
&lt;/h3&gt;

&lt;p&gt;CloudFront reaches the internal ALB directly via a &lt;strong&gt;VPC Origin&lt;/strong&gt;. If the ALB stops responding, CloudFront automatically fails over to the static page in S3.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;originGroup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;cloudfront_origins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;OriginGroup&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;primaryOrigin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cloudfront_origins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VpcOrigin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withApplicationLoadBalancer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;httpPort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;fallbackOrigin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cloudfront_origins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;S3BucketOrigin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withOriginAccessControl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;errorBucket&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;fallbackStatusCodes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;504&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;distribution&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addBehavior&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/alb/*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;originGroup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;viewerProtocolPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cloudfront&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ViewerProtocolPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;REDIRECT_TO_HTTPS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// Without this, the default CACHING_OPTIMIZED policy applies (1-day default TTL). The ALB's&lt;/span&gt;
  &lt;span class="c1"&gt;// fixed responses carry no Cache-Control header at all, so once a successful response gets&lt;/span&gt;
  &lt;span class="c1"&gt;// cached at the edge, it keeps being served for up to a day regardless of the origin's or&lt;/span&gt;
  &lt;span class="c1"&gt;// Origin Group's actual state.&lt;/span&gt;
  &lt;span class="na"&gt;cachePolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cloudfront&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CachePolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CACHING_DISABLED&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;blockquote&gt;
&lt;p&gt;Without this explicit cache policy, you'll run into a confusing situation while testing failover: "the origin has switched, but the browser/edge keeps serving a stale cached success response, so nothing looks different." &lt;code&gt;/alb/*&lt;/code&gt; exists to show you the ALB's live response, so disabling caching outright is the straightforward choice here.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  3. IP allow-listing and managed rules with WAF
&lt;/h3&gt;

&lt;p&gt;Edge access control is handled first by a &lt;strong&gt;WAFv2 Web ACL&lt;/strong&gt; attached to the whole distribution (&lt;code&gt;CloudfrontWafStack&lt;/code&gt;). Since &lt;code&gt;defaultAction&lt;/code&gt; is &lt;code&gt;block&lt;/code&gt;, the effective behavior is that everything except allow-listed IPs is rejected.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wafAcl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;wafv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CfnWebACL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WafAcl&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="na"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CLOUDFRONT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;defaultAction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;block&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="na"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="c1"&gt;// Allow before managed rules (only created if IPs are actually specified. priority 1)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AllowSpecificIPsBeforeRules&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;priority&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;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;allow&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="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CoreRuleSet&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;priority&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="na"&gt;overrideAction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;none&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="cm"&gt;/* AWSManagedRulesCommonRuleSet */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;KnownBadInputsRuleSet&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;overrideAction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;none&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="cm"&gt;/* AWSManagedRulesKnownBadInputsRuleSet */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c1"&gt;// Allow after managed rules (enabled by default. priority 100)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AllowSpecificIPsAfterRules&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;allow&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="cm"&gt;/* IPSetReferenceStatement */&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the current wiring, the deploying operator's IP (&lt;code&gt;getMyGlobalIp()&lt;/code&gt;) is passed as &lt;code&gt;allowedIpsAfterRules&lt;/code&gt;, and is allowed &lt;strong&gt;after the managed rules are evaluated&lt;/strong&gt; (priority 100). In other words, even an allow-listed IP is first checked against the Core/KnownBadInputs managed rules for known attack patterns before being allowed through. The &lt;code&gt;allowedIpsBeforeRules&lt;/code&gt; option (allow before rule evaluation) is there for cases where you want to verify behavior before the WAF rules take effect.&lt;/p&gt;

&lt;p&gt;WAF logs are delivered directly to an S3 bucket whose name must carry the &lt;code&gt;aws-waf-logs-&lt;/code&gt; prefix (&lt;code&gt;CfnLoggingConfiguration&lt;/code&gt;). The &lt;code&gt;authorization&lt;/code&gt; and &lt;code&gt;cookie&lt;/code&gt; headers are masked via &lt;code&gt;redactedFields&lt;/code&gt; before being recorded.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Edge control via CloudFront Function, and incident investigation via standard logging v2
&lt;/h3&gt;

&lt;p&gt;Separately from WAF, a CloudFront Function-based allow list is also implemented for cases where you want finer, per-behavior control. When &lt;code&gt;allowedCloudFunctionIps&lt;/code&gt; is set, every viewer request is evaluated and IPs not on the list are rejected. Denied requests are recorded as custom data via &lt;code&gt;cf.logCustomData()&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&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;var&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;var&lt;/span&gt; &lt;span class="nx"&gt;allowedIps&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;203.0.113.10&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// baked in at synth time&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;clientIp&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;viewer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// viewer IP accessor for the JS_2_0 runtime&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;allowedIps&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="nx"&gt;clientIp&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;cf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;logCustomData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;clientIp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;clientIp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;allowedIps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;allowedIps&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="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;statusDescription&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Forbidden&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Access denied&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;The CloudFront Function is positioned as an optional feature you enable as needed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  5. The incident-response escape hatch (&lt;code&gt;publicAlbFailover&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;This is the point I most want to get across in this article.&lt;/p&gt;

&lt;p&gt;On July 16, 2026, AWS CloudFront experienced a multi-hour outage in which &lt;strong&gt;5xx errors spiked for customers using VPC Origins&lt;/strong&gt;. The root cause was not in customers' own stacks — it was in the VPC Origin connectivity layer itself.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;We identified the root cause of the issue as an internal constraint on the fleet that manages connections to private VPC origins. When this constraint was reached, the system responsible for distributing routing configuration to our network processors failed to load the updated configuration data correctly, affecting routing of VPC Origin connections.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;AWS's guidance at the time was: "customers who are able to should temporarily switch away from the VPC Origin connection, and switch back once it's resolved."&lt;/p&gt;

&lt;p&gt;The problem is that trying to do this by &lt;strong&gt;manually rewriting CloudFormation while the incident is ongoing&lt;/strong&gt; makes both the decision-making and the work slower. So this stack prepares the switch ahead of time, as a single &lt;code&gt;publicAlbFailover&lt;/code&gt; parameter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The internal ALB (for VPC Origin) is always created. A separate public ALB is created only&lt;/span&gt;
&lt;span class="c1"&gt;// when publicAlbFailover is enabled.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;publicAlb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;publicAlbFailoverEnabled&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;elbv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ApplicationLoadBalancer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PublicAlb&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="na"&gt;internetFacing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;securityGroup&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;publicAlbSecurityGroup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;vpcSubnets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;subnetType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ec2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SubnetType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PUBLIC&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="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// The VPC Origin used as the primaryOrigin in normal mode&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;vpcOriginAlb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cloudfront_origins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VpcOrigin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withApplicationLoadBalancer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;httpPort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;protocolPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cloudfront&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OriginProtocolPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HTTP_ONLY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Escape hatch enabled: reach the separate public ALB as a plain public HTTP origin&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;publicAlbOrigin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;publicAlbFailoverEnabled&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;cloudfront_origins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HttpOrigin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;publicAlb&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;loadBalancerDnsName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;httpPort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;protocolPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cloudfront&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OriginProtocolPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HTTP_ONLY&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="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// The fallback is always the S3 error page; only the primary swaps to the public ALB when enabled&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;originGroup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;cloudfront_origins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;OriginGroup&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;primaryOrigin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;publicAlbFailoverEnabled&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;publicAlbOrigin&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;vpcOriginAlb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;fallbackOrigin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cloudfront_origins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;S3BucketOrigin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withOriginAccessControl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;errorBucket&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;fallbackStatusCodes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;504&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;There are 4 design points here.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Traffic still only ever flows through CloudFront.&lt;/strong&gt; Enabling this makes the newly-created public ALB internet-facing, but its security group — just like the internal ALB's — still only allows the CloudFront-managed prefix list, so it's never directly reachable by clients. The client-facing URL doesn't change either.&lt;/li&gt;
&lt;li&gt;Setting &lt;code&gt;publicAlbFailover.enabled: true&lt;/code&gt; makes &lt;code&gt;cloudfrontManagedPrefixList&lt;/code&gt; a &lt;strong&gt;required field&lt;/strong&gt;. Without it, there'd be no way to restrict the public ALB to CloudFront only, so it throws at synth time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The prefix list alone isn't enough.&lt;/strong&gt; The CloudFront managed prefix list is a shared IP range used by every CloudFront customer's distribution, so if someone else's CloudFront distribution pointed at this public ALB's DNS name as an origin, it would still pass the security group check. So, following &lt;a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/restrict-access-to-load-balancer.html" rel="noopener noreferrer"&gt;AWS's official recommended procedure&lt;/a&gt;, we generate a secret value at synth time and send it as a CloudFront origin custom header, with the ALB listener rejecting (via its default action, 403) any request that doesn't carry that header.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It switches over in "minutes, not instantly."&lt;/strong&gt; We originally tried a design where keeping the VPC Origin bound as the Origin Group's fallback would avoid recreating it. But CDK auto-derives an origin's logical ID from its "position" — which includes whether it's the OriginGroup's primary or fallback role — so simply swapping that role changes the VPC Origin's own logical ID, and CloudFormation ends up deleting and recreating it anyway (up to ~15 minutes). (I cover this CDK logical-ID auto-numbering mechanism in depth in my own article, "&lt;a href="https://dev.to/aws-builders/aws-cdk-logical-id-deep-dive-how-adding-one-cloudfront-origin-broke-my-entire-aws-cdk-deployment-5n"&gt;AWS CDK Logical ID Deep Dive: How Adding One CloudFront Origin Broke My Entire AWS CDK Deployment&lt;/a&gt;".) You can pin the logical ID with &lt;code&gt;overrideLogicalId&lt;/code&gt; to avoid that, but then you trade it for a different constraint: CloudFront's &lt;code&gt;UpdateVpcOrigin&lt;/code&gt; API rejects any in-place property change to the origin while it's still associated with a distribution. This sample favors simplicity and &lt;strong&gt;accepts that the VPC Origin gets recreated&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;If you need genuinely zero-downtime, instant switching in production&lt;/strong&gt;, consider the CloudFront continuous deployment approach (a staging distribution with header/weight-based gradual traffic shifting) or CloudFront Functions + KeyValueStore-based weighted routing, both covered in &lt;a href="https://aws.amazon.com/jp/blogs/news/migrate-amazon-cloudfront-public-origins-to-private-vpc-origins/" rel="noopener noreferrer"&gt;Migrate Amazon CloudFront public origins to private VPC origins&lt;/a&gt;. Both keep both origins alive at all times and shift traffic gradually by ratio — a step up in complexity from this article's Origin Group swap, but the right choice when you truly need zero-downtime switching.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Using it is simple.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// parameters/dev-params.ts&lt;/span&gt;
&lt;span class="nx"&gt;publicAlbFailover&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// false → true&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run deploy:all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once AWS resolves the issue, just set &lt;code&gt;enabled: false&lt;/code&gt; and redeploy.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. 5xx error rate alarm (also pinned to us-east-1)
&lt;/h3&gt;

&lt;p&gt;CloudFront's request/error metrics (the &lt;code&gt;AWS/CloudFront&lt;/code&gt; namespace) are &lt;strong&gt;only published to us-east-1&lt;/strong&gt;, regardless of the distribution's own region. A CloudWatch Alarm can only evaluate a metric in its own region, so this alarm is also broken out into its own stack, &lt;code&gt;CloudfrontMonitoringStack&lt;/code&gt;, deployed to us-east-1.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alarm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;cloudwatch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Alarm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CloudFront5xxErrorRateAlarm&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="na"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;cloudwatch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Metric&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AWS/CloudFront&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;metricName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;5xxErrorRate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;dimensionsMap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;DistributionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;distributionId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;period&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;statistic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cloudwatch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AVERAGE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 5%&lt;/span&gt;
  &lt;span class="na"&gt;evaluationPeriods&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;datapointsToAlarm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// sustained for 15 minutes before treating it as a real incident&lt;/span&gt;
  &lt;span class="na"&gt;comparisonOperator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cloudwatch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ComparisonOperator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GREATER_THAN_OR_EQUAL_TO_THRESHOLD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;treatMissingData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cloudwatch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TreatMissingData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NOT_BREACHING&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;This alarm watches exactly the metric AWS itself called out during the 2026-07-16 outage (the distribution's overall 5xx error rate). In other words, it's designed to plug directly into an operational flow: "alarm fires" → "check the AWS Health Dashboard" → "if it's a VPC Origin-side problem, enable &lt;code&gt;publicAlbFailover&lt;/code&gt;."&lt;/p&gt;




&lt;h2&gt;
  
  
  Deploy &amp;amp; Verify
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PROJECT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-project
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ENV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev

npm run bootstrap   &lt;span class="c"&gt;# first time only&lt;/span&gt;
npm run deploy:all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Served from S3&lt;/span&gt;
curl https://&amp;lt;distribution-domain&amp;gt;/

&lt;span class="c"&gt;# Served from the ALB via the Origin Group&lt;/span&gt;
curl https://&amp;lt;distribution-domain&amp;gt;/alb/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Cost Estimate
&lt;/h2&gt;

&lt;p&gt;💰 Rough monthly estimate (Tokyo region, low traffic)&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Usage&lt;/th&gt;
&lt;th&gt;Rough monthly cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Application Load Balancer&lt;/td&gt;
&lt;td&gt;Always on&lt;/td&gt;
&lt;td&gt;~$25.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudFront&lt;/td&gt;
&lt;td&gt;Low request volume&lt;/td&gt;
&lt;td&gt;~$1–2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS WAF&lt;/td&gt;
&lt;td&gt;Web ACL + 3 rules + low request volume&lt;/td&gt;
&lt;td&gt;~$8.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 (3 buckets)&lt;/td&gt;
&lt;td&gt;A few MB of static content + logs&lt;/td&gt;
&lt;td&gt;&amp;lt; $0.10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudWatch Alarm + SNS&lt;/td&gt;
&lt;td&gt;5xx error rate alarm notifications&lt;/td&gt;
&lt;td&gt;&amp;lt; $0.10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Total: roughly $23–25/month&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The ALB is billed hourly regardless of traffic. Don't forget to run &lt;code&gt;npm run destroy:all&lt;/code&gt; to clean up after you're done experimenting.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;cdk destroy&lt;/code&gt; / &lt;code&gt;npm run destroy:all&lt;/code&gt; gets stuck — a non-empty S3 bucket, a leftover ENI tied to the VPC Origin, or some other resource CloudFormation refuses to delete on its own — &lt;a href="https://github.com/go-to-k/delstack" rel="noopener noreferrer"&gt;delstack&lt;/a&gt;, an OSS tool by AWS DevTools Hero k.goto (&lt;a href="https://twitter.com/365_step_tech" rel="noopener noreferrer"&gt;@365_step_tech&lt;/a&gt;), is worth having on hand. It forcibly cleans up the kind of resources that commonly block stack deletion.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;What we learned from this pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;VPC Origin&lt;/strong&gt;: CloudFront can reach a fully private ALB directly, with no public IP and no NAT Gateway&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Origin Group&lt;/strong&gt;: Automatic failover from a dynamic origin to a static one when a server error occurs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Defense in depth at the edge&lt;/strong&gt;: Geo restriction, WAF, and CloudFront Function give you access control before traffic ever reaches the origin&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CloudFront's us-east-1 constraint&lt;/strong&gt;: Both the log delivery API and metrics are pinned to us-east-1, regardless of the distribution's own region&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bake incident response into code&lt;/strong&gt;: Given a real outage, prepare "how to route around it" as a parameter ahead of time rather than as manual work&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The prefix list isn't a silver bullet&lt;/strong&gt;: Since it's a shared IP range across every customer, an internet-facing origin should also verify a secret custom header&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be explicit about cache policy on dynamic paths&lt;/strong&gt;: Leaving the default CACHING_OPTIMIZED in place means a successful response from an origin that never sends Cache-Control gets cached for up to a day, masking your failover test results&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CDK's origin logical-ID auto-numbering also depends on "role"&lt;/strong&gt;: Just swapping which origin is primary vs. fallback changes that origin's logical ID, so it can be recreated on every switch. Genuinely zero-downtime switching needs continuous deployment or CloudFront Functions + KeyValueStore-based weighted routing&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-vpc-origins.html" rel="noopener noreferrer"&gt;Amazon CloudFront VPC origins&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/high_availability_origin_failover.html" rel="noopener noreferrer"&gt;Using origin groups for failover&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/aws-builders/aws-cdk-logical-id-deep-dive-how-adding-one-cloudfront-origin-broke-my-entire-aws-cdk-deployment-5n"&gt;AWS CDK Logical ID Deep Dive: How Adding One CloudFront Origin Broke My Entire AWS CDK Deployment&lt;/a&gt; — my own deep dive into how CDK's origin logical-ID auto-numbering works&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/jp/blogs/news/migrate-amazon-cloudfront-public-origins-to-private-vpc-origins/" rel="noopener noreferrer"&gt;Migrate Amazon CloudFront public origins to private VPC origins&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;Using the managed cache policies (CachingOptimized / CachingDisabled)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/restrict-access-to-load-balancer.html" rel="noopener noreferrer"&gt;Restrict access to Application Load Balancers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/waf/latest/developerguide/waf-chapter.html" rel="noopener noreferrer"&gt;AWS WAF developer guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/standard-logging.html" rel="noopener noreferrer"&gt;CloudFront standard logging v2&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/monitoring-using-cloudwatch.html" rel="noopener noreferrer"&gt;Monitoring CloudFront distributions with CloudWatch metrics&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://health.aws.amazon.com/health/status" rel="noopener noreferrer"&gt;AWS Health Dashboard&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://x.com/AWSSupport/status/2077733500732522882" rel="noopener noreferrer"&gt;X: Amazon CloudFront Issue Resolved&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/go-to-k/delstack" rel="noopener noreferrer"&gt;delstack&lt;/a&gt; — an OSS tool by AWS DevTools Hero k.goto (&lt;a href="https://twitter.com/365_step_tech" rel="noopener noreferrer"&gt;@365_step_tech&lt;/a&gt;) for force-deleting CloudFormation stacks stuck on resources like non-empty S3 buckets or leftover ENIs&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Let's keep learning practical AWS CDK patterns through the 100 drill exercises!&lt;br&gt;
If you found this helpful, please ⭐ the repository!&lt;/p&gt;

&lt;p&gt;📌 You can see the entire code in &lt;a href="https://github.com/ishiharatma/aws-cdk-reference-architectures" rel="noopener noreferrer"&gt;my GitHub repository&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cdk</category>
      <category>100drillexercises</category>
      <category>cloudfront</category>
    </item>
    <item>
      <title>AWS or Amazon Quiz — How Well Do You Know AWS Service Prefixes?</title>
      <dc:creator>TOMOAKI ishihara</dc:creator>
      <pubDate>Thu, 23 Jul 2026 15:16:57 +0000</pubDate>
      <link>https://dev.to/aws-builders/aws-or-amazon-quiz-how-well-do-you-know-aws-service-prefixes-7n1</link>
      <guid>https://dev.to/aws-builders/aws-or-amazon-quiz-how-well-do-you-know-aws-service-prefixes-7n1</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;AWS (Amazon Web Services) offers 200+ services these days. That's a lot of names to keep straight.&lt;/p&gt;

&lt;p&gt;And there's one thing that trips up everyone, beginners included. You get used to just saying "Route 53" out loud, and then when you're writing documentation you suddenly blank on the official name: is it "AWS Route 53" or "Amazon Route 53"?&lt;/p&gt;

&lt;p&gt;Right?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AWS or Amazon&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I figured turning that everyday confusion into a game could be fun, so I looked around for something similar. There are quizzes that show a service icon and ask you to name it, and quizzes that describe a service and ask you to pick the name — but nothing focused purely on "is it AWS or Amazon?"&lt;br&gt;
Naming the full service correctly is hard for beginners, but just picking the prefix felt like something anyone could enjoy.&lt;/p&gt;

&lt;p&gt;These days, if you have an idea, AI makes it quick to build. So I sketched out the rules for the game right away.&lt;/p&gt;

&lt;p&gt;By the way, the distinction between the two prefixes is explained in plenty of places, but roughly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Amazon: standalone services that work on their own&lt;/li&gt;
&lt;li&gt;AWS: utility services typically used by other services&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;Here's what I ended up with. It's playable entirely in the browser.&lt;br&gt;
It's hosted on &lt;a href="https://ishiharatma.github.io/aws-or-amazon-quiz/" rel="noopener noreferrer"&gt;GitHub Pages&lt;/a&gt;, and the source code is &lt;a href="https://github.com/ishiharatma/aws-or-amazon-quiz" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&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%2F46v597iqzce05rxhbsda.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%2F46v597iqzce05rxhbsda.png" alt="screenshot-1" width="420" height="780"&gt;&lt;/a&gt;&lt;br&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%2Fhr7p7mch7g5ekvidxehj.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%2Fhr7p7mch7g5ekvidxehj.png" alt="screenshot-2" width="420" height="780"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Designing the game
&lt;/h2&gt;

&lt;p&gt;I wanted something simple: a two-choice format you can breeze through. So I built it to pull 20 random questions from a dataset of AWS service info.&lt;br&gt;
Beyond just picking an answer, each question shows a short explanation of the service and a link to the official docs, so you can actually learn something along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing where to host it
&lt;/h2&gt;

&lt;p&gt;Since the game is just plain JavaScript and a single HTML file, GitHub Pages was the obvious choice for hosting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Improving the UI
&lt;/h2&gt;

&lt;p&gt;In the first version, the "Next" button appeared at the bottom of the screen after you answered. That worked, but it was too far from the answer choices, and on mobile it made moving to the next question clunky.&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%2Fjezgs5v3eyb5nh5byzga.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%2Fjezgs5v3eyb5nh5byzga.png" alt="result-before" width="510" height="664"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So I compared a few design options.&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%2Fbzefynr7ij33bivwn2ka.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%2Fbzefynr7ij33bivwn2ka.png" alt="result-design-review" width="800" height="363"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I also had a rank displayed based on your score, but it wasn't obvious whether a given rank was good or bad, so I simplified it to a plain evaluation instead.&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%2F0msr37rb9c5bsp384gqn.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%2F0msr37rb9c5bsp384gqn.png" alt="result-design-review-2" width="668" height="124"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;That's the story behind building "AWS or Amazon Quiz" and what I paid attention to along the way.&lt;br&gt;
If you play it and catch yourself thinking "wait, I thought that one was Amazon" — that's a sign the naming conventions are starting to stick.&lt;/p&gt;

&lt;p&gt;If you enjoyed it, a star on &lt;a href="https://github.com/ishiharatma/aws-or-amazon-quiz" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; would mean a lot.&lt;br&gt;
Bug reports and corrections (like "this service's description is wrong") are welcome via Issues or comments — the service descriptions were generated by AI since there are so many of them, so a few may not be entirely accurate.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>quiz</category>
      <category>game</category>
      <category>devchallenge</category>
    </item>
    <item>
      <title>The problem of putting Transit Gateway subnets at the "end" of a CIDR — existing tools couldn't solve it, so I built one myself</title>
      <dc:creator>TOMOAKI ishihara</dc:creator>
      <pubDate>Sun, 19 Jul 2026 13:01:50 +0000</pubDate>
      <link>https://dev.to/aws-builders/the-problem-of-putting-transit-gateway-subnets-at-the-end-of-a-cidr-existing-tools-couldnt-5a97</link>
      <guid>https://dev.to/aws-builders/the-problem-of-putting-transit-gateway-subnets-at-the-end-of-a-cidr-existing-tools-couldnt-5a97</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;How do you calculate CIDRs when designing VPC subnets? Do you work through them one by one with an online subnet calculator?&lt;/p&gt;

&lt;p&gt;Or maybe you reach for the &lt;a href="https://vpcsubnetcalculator.com/" rel="noopener noreferrer"&gt;AWS Subnet Calculator&lt;/a&gt;, or the Excel-based &lt;a href="https://github.com/aws/aws-eks-best-practices/blob/master/latest/bpg/networking/subnet-calc/subnet-calc.xlsx" rel="noopener noreferrer"&gt;subnet calculator spreadsheet&lt;/a&gt; referenced in &lt;code&gt;AWS docs &amp;gt; Amazon EKS &amp;gt; Amazon VPC CNI&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;Both are handy tools, but I ran into a wall: I needed the subnet reserved for Transit Gateway to sit at the very end of the VPC's CIDR block, and neither tool made that easy.&lt;/p&gt;

&lt;p&gt;Here's the background. At one organization I worked with, on-premises connectivity was built using AWS Direct Connect and Transit Gateway. Whenever a new AWS account was issued, the VPC and its Transit Gateway subnet came pre-provisioned as part of the standard setup. On top of that, the Transit Gateway subnet was always carved out from the tail end of the VPC's CIDR block by design.&lt;/p&gt;

&lt;p&gt;So when I needed to lay out subnets for a business application, I needed a tool that could compute the Transit Gateway subnet from the end of the CIDR range. None of the existing tools did that, so I built my own.&lt;/p&gt;

&lt;p&gt;The tool is live on &lt;a href="https://ishiharatma.github.io/aws-vpc-subnet-calculator/" rel="noopener noreferrer"&gt;GitHub Pages&lt;/a&gt;, and the source is on &lt;a href="https://github.com/ishiharatma/aws-vpc-subnet-calculator" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&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%2F173qqcyrnmn0rwmtmxy9.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%2F173qqcyrnmn0rwmtmxy9.png" alt="screenshot" width="800" height="551"&gt;&lt;/a&gt;&lt;br&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%2F1ysmjickpu51vrl14gs1.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%2F1ysmjickpu51vrl14gs1.png" alt="screenshot-result" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Features
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Variable VPC CIDR&lt;/strong&gt; — enter any valid CIDR block (e.g. &lt;code&gt;10.0.0.0/22&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-tier subnet design&lt;/strong&gt; — add, remove, and configure tiers with custom subnet masks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drag-and-drop reordering&lt;/strong&gt; — reorder tiers by dragging them into place&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dual AZ support&lt;/strong&gt; — automatically allocates subnets across &lt;code&gt;ap-northeast-1a&lt;/code&gt; and &lt;code&gt;ap-northeast-1c&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TGW tail allocation&lt;/strong&gt; — tiers named "TGW" get carved out from the end of the VPC address space (toggle on/off)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free address space detection&lt;/strong&gt; — surfaces every leftover range: alignment gaps, the space between forward-allocated and TGW subnets, and trailing space&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS reserved address aware&lt;/strong&gt; — shows the usable host count after subtracting the 5 AWS-reserved addresses per subnet&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Japanese/English language switching&lt;/strong&gt; — toggle the UI language with a button; auto-detects the browser's language on load&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dark mode support&lt;/strong&gt; — automatically adapts to the system color scheme&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  How the CIDR calculation works
&lt;/h2&gt;

&lt;p&gt;There's no server and no database behind this tool — the entire calculation runs as bitwise arithmetic in the browser. The process breaks down into four steps.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Turn the CIDR string into a 32-bit integer
&lt;/h3&gt;

&lt;p&gt;First, an input string like &lt;code&gt;10.0.0.0/22&lt;/code&gt; gets converted into a 32-bit integer — a network address plus a total address count — so it can be manipulated with bitwise operations.&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseVPC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cidr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cidr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\.\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\.\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\.\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)\/(\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;m&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;span class="nf"&gt;split&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="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&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;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&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;p&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;255&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="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;24&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="nx"&gt;parts&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;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;16&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="nx"&gt;parts&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="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&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;32&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;network&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&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="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;prefix&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="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&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="na"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;network&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;network&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;total&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things are worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;parts[0]&amp;lt;&amp;lt;24 | parts[1]&amp;lt;&amp;lt;16 | parts[2]&amp;lt;&amp;lt;8 | parts[3]&lt;/code&gt; packs the four octets into a single 32-bit integer.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;&amp;gt;&amp;gt;&amp;gt; 0&lt;/code&gt; matters. JavaScript's bitwise operators treat numbers as signed 32-bit integers, so without it, any first octet of 128 or higher would come out negative.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;~((1 &amp;lt;&amp;lt; (32 - prefix)) - 1)&lt;/code&gt; then builds the subnet mask, and &lt;code&gt;base &amp;amp; mask&lt;/code&gt; rounds down to the network address. So even if you type &lt;code&gt;10.0.0.5/22&lt;/code&gt;, with a non-zero host part, it automatically snaps to &lt;code&gt;10.0.0.0/22&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Forward tiers: round up to the alignment boundary
&lt;/h3&gt;

&lt;p&gt;Regular tiers like Private-App and Public get packed from the front of the VPC, AZ by AZ. The key constraint here is alignment: a &lt;code&gt;/26&lt;/code&gt; subnet can only start on a 64-address boundary (&lt;code&gt;x.x.x.0&lt;/code&gt;, &lt;code&gt;x.x.x.64&lt;/code&gt;, &lt;code&gt;x.x.x.128&lt;/code&gt;, and so on).&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="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;fwdCursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;base&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;var&lt;/span&gt; &lt;span class="nx"&gt;ni&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;ni&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;normalTiers&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;ni&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="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;ntier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;normalTiers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;ni&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;sz&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&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;32&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;ntier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mask&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;var&lt;/span&gt; &lt;span class="nx"&gt;az&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;az&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;az&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="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;aligned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fwdCursor&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;sz&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;sz&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aligned&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;sz&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;end&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;overflow&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="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;allocRows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subnet&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ntier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;az&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AZS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;az&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;azIdx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;az&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;cidr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;ipToStr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;aligned&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;ntier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;first&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;ipToStr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;aligned&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;last&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;ipToStr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;usable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sz&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;startNum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;aligned&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;endNum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;fwdCursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;end&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="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;overflow&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&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 line that does the real work is &lt;code&gt;Math.ceil(fwdCursor / sz) * sz&lt;/code&gt;: it rounds the current cursor position up to the nearest multiple of the subnet size. That means even when one tier is packed right up against the end of the previous one, the result always snaps to a valid CIDR boundary. If the allocation would run past the end of the VPC, an &lt;code&gt;overflow&lt;/code&gt; flag stops the calculation.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. TGW tiers: round down to the alignment boundary, from the end
&lt;/h3&gt;

&lt;p&gt;Any tier whose name contains "TGW" gets allocated in reverse, starting from the end of the VPC. This is the feature that made me build the tool in the first place.&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;floorAligned&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;size&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;addr&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;bwdCursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;tgwBuilt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;tgwRev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tgwTiers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;reverse&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;var&lt;/span&gt; &lt;span class="nx"&gt;gi&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;gi&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;tgwRev&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;gi&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="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;gtier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tgwRev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;gi&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;gsz&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&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;32&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;gtier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mask&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;var&lt;/span&gt; &lt;span class="nx"&gt;gaz&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="nx"&gt;gaz&lt;/span&gt; &lt;span class="o"&gt;&amp;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;gaz&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="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;galigned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;floorAligned&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bwdCursor&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;gsz&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="nx"&gt;gsz&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;gend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;galigned&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;gsz&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;galigned&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;overflow&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="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;tgwBuilt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unshift&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subnet&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;gtier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;az&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AZS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;gaz&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;azIdx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;gaz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;cidr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;ipToStr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;galigned&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;gtier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;first&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;ipToStr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;galigned&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;last&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;ipToStr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gend&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;gsz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;usable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;gsz&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;startNum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;galigned&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;endNum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;gend&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;bwdCursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;galigned&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="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;overflow&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&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;This mirrors the forward-allocation logic. &lt;code&gt;bwdCursor - gsz + 1&lt;/code&gt; gives a candidate starting address, and &lt;code&gt;floorAligned&lt;/code&gt; rounds it &lt;em&gt;down&lt;/em&gt; to a valid boundary — the mirror image of rounding up. The TGW tier list is reversed before processing, and each result is pushed onto the front of the array with &lt;code&gt;unshift&lt;/code&gt;, so on screen the tiers still appear in their original order even though they're being filled in from the tail.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Detecting free address space
&lt;/h3&gt;

&lt;p&gt;Once forward and tail allocations are done, all subnets get sorted by starting address, and any gap between two adjacent subnets gets recorded as free space.&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="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;allSubnets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;allocRows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tgwBuilt&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;b&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;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startNum&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startNum&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;gaps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;base&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;var&lt;/span&gt; &lt;span class="nx"&gt;si&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;si&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;allSubnets&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;si&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="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;allSubnets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;si&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startNum&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;gaps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;free&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;startNum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;endNum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startNum&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="nx"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endNum&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="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;cursor&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;gaps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;free&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;startNum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;endNum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the classic "find the gaps between intervals" algorithm. A cursor walks forward from the start of the VPC; whenever there's a gap between the cursor and the next subnet, it gets recorded, and if the cursor never reaches the end of the VPC, the leftover range at the end is recorded too. The odd leftover addresses caused by alignment — the space between the last forward-packed subnet and the TGW subnets, for example — get caught by this same mechanism, with nothing falling through the cracks.&lt;/p&gt;

&lt;p&gt;Put together, two simple algorithms — rounding up or down to an alignment boundary, and finding gaps between intervals — are enough to handle a fairly involved requirement (multiple tiers, multiple AZs, tail allocation) in a few dozen lines of serverless code.&lt;/p&gt;




&lt;p&gt;If you found this helpful, please ⭐ the repository!&lt;/p&gt;

&lt;p&gt;📌 You can see the entire code in &lt;a href="https://github.com/ishiharatma/aws-vpc-subnet-calculator/" rel="noopener noreferrer"&gt;My GitHub Repository&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>networking</category>
      <category>javascript</category>
      <category>tools</category>
    </item>
    <item>
      <title>CTF Event Report: Security-JAWS 10th Anniversary Day 2 — All 27 AWS Security Challenges Solved</title>
      <dc:creator>TOMOAKI ishihara</dc:creator>
      <pubDate>Mon, 18 May 2026 13:13:37 +0000</pubDate>
      <link>https://dev.to/aws-builders/ctf-event-report-security-jaws-10th-anniversary-day-2-all-27-aws-security-challenges-solved-51c3</link>
      <guid>https://dev.to/aws-builders/ctf-event-report-security-jaws-10th-anniversary-day-2-all-27-aws-security-challenges-solved-51c3</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;I participated in the CTF held on &lt;a href="https://s-jaws.connpass.com/event/383752/" rel="noopener noreferrer"&gt;Day 2 of "Security-JAWS DAYS ~10th Anniversary Event~"&lt;/a&gt;, organized by &lt;a href="https://s-jaws.connpass.com/" rel="noopener noreferrer"&gt;Security-JAWS&lt;/a&gt;, a Japanese AWS user community focused on cloud security.&lt;/p&gt;

&lt;p&gt;The CTF was themed around a fictional SaaS company called "TechVault", and the scenario had us conducting a penetration investigation — starting from their employee portal and ultimately uncovering evidence of fraudulent transactions. It was an exceptionally well-crafted CTF with a cohesive narrative running through all challenges.&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.amazonaws.com%2Fuploads%2Farticles%2F7ey14azbl3tt4yy3jojv.jpg" 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.amazonaws.com%2Fuploads%2Farticles%2F7ey14azbl3tt4yy3jojv.jpg" alt="secjaws10th-000.jpg" width="649" height="344"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Event Overview
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Duration:&lt;/strong&gt; 13:00–17:00 / 4 hours&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Total challenges:&lt;/strong&gt; 27

&lt;ul&gt;
&lt;li&gt;Tutorial: 6 / 290 pt&lt;/li&gt;
&lt;li&gt;Mainline: 12 / 2,300 pt&lt;/li&gt;
&lt;li&gt;Bonus: 5 / 1,400 pt&lt;/li&gt;
&lt;li&gt;Advanced: 2 / 700 pt&lt;/li&gt;
&lt;li&gt;Blue Team: 1 / 300 pt&lt;/li&gt;
&lt;li&gt;Finale: 1 / 600 pt&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;Setting:&lt;/strong&gt; AWS environment of a fictional SaaS company "TechVault"&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;The story begins with an intrusion investigation of TechVault's portal service and culminates in gathering evidence of someone's fraudulent transactions. The level of polish in the scenario design was remarkable.&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.amazonaws.com%2Fuploads%2Farticles%2F2ercmszg4ulm5xgsnv39.jpg" 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.amazonaws.com%2Fuploads%2Farticles%2F2ercmszg4ulm5xgsnv39.jpg" alt="secjaws10th-001.jpg" width="800" height="535"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Challenges solved:&lt;/strong&gt; 27 out of 27&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Score:&lt;/strong&gt; 5,310 pt (max: ~5,590–5,650 pt)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time to complete:&lt;/strong&gt; 2 hours 48 minutes 36 seconds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Final ranking:&lt;/strong&gt; 12th out of 125 participants&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What went well:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;Using knowledge and CLI tools I don't normally touch in daily work, and working through them hands-on gave me a much deeper understanding of each technique.&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;What I'd improve:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;I should have set up my environment beforehand. I normally use devContainers, so my host machine only had the minimum: AWS CLI and Python. Docker, OpenSSL, Boto3, and similar tools were missing, which cost me more time than necessary.&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&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.amazonaws.com%2Fuploads%2Farticles%2F9nmnlrn9ihulnxzactw4.jpg" 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.amazonaws.com%2Fuploads%2Farticles%2F9nmnlrn9ihulnxzactw4.jpg" alt="secjaws10th-003.jpg" width="800" height="560"&gt;&lt;/a&gt;&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.amazonaws.com%2Fuploads%2Farticles%2Fpo24r1bsny8w2ujb3cn9.jpg" 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.amazonaws.com%2Fuploads%2Farticles%2Fpo24r1bsny8w2ujb3cn9.jpg" alt="secjaws10th-002.jpg" width="800" height="460"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Challenge Structure
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Tutorial (290 pt)
&lt;/h3&gt;

&lt;p&gt;A step-by-step introduction to web reconnaissance and AWS CLI basics.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;ID&lt;/th&gt;
&lt;th&gt;Title&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;T1&lt;/td&gt;
&lt;td&gt;Web Recon · robots.txt&lt;/td&gt;
&lt;td&gt;Discover hidden paths from &lt;code&gt;robots.txt&lt;/code&gt; Disallow directives&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T2&lt;/td&gt;
&lt;td&gt;The Unlocked Warehouse · Public S3&lt;/td&gt;
&lt;td&gt;Retrieve files directly from a publicly exposed S3 bucket&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T3&lt;/td&gt;
&lt;td&gt;Behind the Page · HTML Source&lt;/td&gt;
&lt;td&gt;Investigate credentials buried in HTML comments&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T4&lt;/td&gt;
&lt;td&gt;First Steps with curl&lt;/td&gt;
&lt;td&gt;Check information embedded in HTTP response headers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T5&lt;/td&gt;
&lt;td&gt;Leaked Config · .env File&lt;/td&gt;
&lt;td&gt;Find a &lt;code&gt;.env&lt;/code&gt; file mistakenly placed in the web root&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T6&lt;/td&gt;
&lt;td&gt;First Steps with AWS CLI&lt;/td&gt;
&lt;td&gt;Use the key found in &lt;code&gt;.env&lt;/code&gt; to run &lt;code&gt;sts get-caller-identity&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The T5→T6 flow was clever. You grab a key from &lt;code&gt;.env&lt;/code&gt; and immediately use it with the AWS CLI — a hands-on demonstration of how a web vulnerability becomes an AWS entry point.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mainline (2,300 pt)
&lt;/h3&gt;

&lt;p&gt;The core of the CTF: an attack chain that follows the path of intrusion → privilege escalation → evidence collection, starting from Stage 0.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;ID&lt;/th&gt;
&lt;th&gt;Title&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stage 0&lt;/td&gt;
&lt;td&gt;The Forgotten Debug Mode&lt;/td&gt;
&lt;td&gt;Extract AWS keys from debug output left in an auth API error response&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage 1A&lt;/td&gt;
&lt;td&gt;Flip the Bucket&lt;/td&gt;
&lt;td&gt;Find a file hidden under a &lt;code&gt;.hidden/&lt;/code&gt; prefix in an S3 bucket&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage 1B&lt;/td&gt;
&lt;td&gt;Who Am I?&lt;/td&gt;
&lt;td&gt;Read IAM policy metadata to understand the compromised user's permissions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage 1C&lt;/td&gt;
&lt;td&gt;The Past Never Disappears&lt;/td&gt;
&lt;td&gt;Recover AWS keys left in a Git repository's commit history&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage 1D&lt;/td&gt;
&lt;td&gt;Ask the AI&lt;/td&gt;
&lt;td&gt;Prompt injection against an AI assistant embedded in the employee portal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage 2A&lt;/td&gt;
&lt;td&gt;The Deleted File&lt;/td&gt;
&lt;td&gt;Recover a deleted file using S3 object versioning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage 2B&lt;/td&gt;
&lt;td&gt;The Permission Map&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;sts:AssumeRole&lt;/code&gt; to pivot laterally into the DataAnalystRole&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage 2D&lt;/td&gt;
&lt;td&gt;The Function's Secret&lt;/td&gt;
&lt;td&gt;Retrieve sensitive data stored in Lambda environment variables&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage 2E&lt;/td&gt;
&lt;td&gt;The Parameter Labyrinth&lt;/td&gt;
&lt;td&gt;Navigate SSM Parameter Store paths to collect secrets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage 2G&lt;/td&gt;
&lt;td&gt;The AI's Permissions&lt;/td&gt;
&lt;td&gt;Extract S3 data via an over-privileged Bedrock agent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage 3A&lt;/td&gt;
&lt;td&gt;The Vault Key&lt;/td&gt;
&lt;td&gt;Retrieve the ZIP decryption password from Secrets Manager&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Final&lt;/td&gt;
&lt;td&gt;Consolidate the Evidence&lt;/td&gt;
&lt;td&gt;Decrypt the evidence file using all collected information to expose the CEO's fraud&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Bedrock agent challenge (Stage 2G) was fresh. The agent was configured with direct S3 access, so data from a bucket I couldn't read directly could be pulled out simply by asking the agent "show me the project metadata." It drove home how important permission design is when integrating AI into your stack.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bonus / Advanced / Blue Team / Finale (3,000 pt)
&lt;/h3&gt;

&lt;p&gt;Additional challenges branching off the mainline, each requiring deeper technical knowledge.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;ID&lt;/th&gt;
&lt;th&gt;Title&lt;/th&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stage 2C&lt;/td&gt;
&lt;td&gt;The Server's Shadow&lt;/td&gt;
&lt;td&gt;bonus&lt;/td&gt;
&lt;td&gt;Flag stored in EC2 instance tags&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage 2F&lt;/td&gt;
&lt;td&gt;Find It Automatically&lt;/td&gt;
&lt;td&gt;bonus&lt;/td&gt;
&lt;td&gt;Scan all branches for secrets using &lt;code&gt;gitleaks&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage 3B&lt;/td&gt;
&lt;td&gt;The Invisible Voice&lt;/td&gt;
&lt;td&gt;bonus&lt;/td&gt;
&lt;td&gt;SSRF to IMDSv1 to steal EC2 role temporary credentials&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage 3C&lt;/td&gt;
&lt;td&gt;The False Face&lt;/td&gt;
&lt;td&gt;advanced&lt;/td&gt;
&lt;td&gt;Self-declare &lt;code&gt;custom:role=admin&lt;/code&gt; during Cognito sign-up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage 3D&lt;/td&gt;
&lt;td&gt;The Truth Inside the Image&lt;/td&gt;
&lt;td&gt;bonus&lt;/td&gt;
&lt;td&gt;Recover files deleted by &lt;code&gt;RUN rm&lt;/code&gt; from Docker image layers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage 3E&lt;/td&gt;
&lt;td&gt;The Neighbor's Vault&lt;/td&gt;
&lt;td&gt;advanced&lt;/td&gt;
&lt;td&gt;Read another tenant's data via wildcard permissions on S3 Vectors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage 4&lt;/td&gt;
&lt;td&gt;Follow the Trail&lt;/td&gt;
&lt;td&gt;blueteam&lt;/td&gt;
&lt;td&gt;Identify attacker operation timestamps from CloudTrail logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage 5&lt;/td&gt;
&lt;td&gt;Suspicious Activity&lt;/td&gt;
&lt;td&gt;finale&lt;/td&gt;
&lt;td&gt;Decrypt CTO complicity evidence by tracing late-night activity in CloudTrail&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage 5B&lt;/td&gt;
&lt;td&gt;Combined Attack Surface&lt;/td&gt;
&lt;td&gt;bonus&lt;/td&gt;
&lt;td&gt;Call an internal API by combining intelligence from Stage 3D and Stage 3E&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Stage 3D was by far the most time-consuming for me — because Docker wasn't installed in my CTF environment. Instead of using &lt;code&gt;docker history&lt;/code&gt; (which would have shown it in seconds), I had to query the ECR API directly to fetch the image manifest, download each layer, and extract the tarballs manually. Painful on the clock, but I ended up with a much deeper understanding of how Docker image layers actually work.&lt;/p&gt;

&lt;p&gt;Stage 4 and Stage 5 involved parsing large CloudTrail log files with &lt;code&gt;jq&lt;/code&gt; to reconstruct the attacker's footsteps — a great taste of what SOC/incident response work feels like. Stage 5 in particular required chaining multiple steps: find the suspicious late-night (JST) operations in the logs, track down the Secrets Manager path they pointed to, and decrypt the encrypted evidence file with OpenSSL. OpenSSL wasn't available either, so I ended up implementing the decryption in Python.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Full Attack Chain
&lt;/h2&gt;

&lt;p&gt;Each challenge looks independent, but they're all connected as a single story.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Obtain AWS keys from debug API response (Stage 0)
        ↓
IAM recon reveals an AssumeRole-able role (Stage 1B)
        ↓
Pivot laterally into DataAnalystRole (Stage 2B)
        ↓
┌─────────────────────────────────────────────────────┐
│  Collect intelligence across multiple parallel paths │
│  · EC2 tags (Stage 2C)                              │
│  · S3 versioning — recover deleted files (Stage 2A) │
│  · Lambda environment variables (Stage 2D)          │
│  · SSM Parameter Store (Stage 2E)                   │
│  · SSRF → IMDSv1 (Stage 3B)                        │
│  · ECR Docker layer analysis (Stage 3D)             │
│  · S3 Vectors cross-tenant leak (Stage 3E)          │
└─────────────────────────────────────────────────────┘
        ↓
Retrieve password from Secrets Manager (Stage 3A)
        ↓
Decrypt ZIP to obtain CEO fraud evidence (Final)
        ↓
Trace CTO complicity via CloudTrail (Stage 4 → 5)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each individual vulnerability might look limited in isolation, but chaining them together produces a critical breach. Stage 5B is the perfect example: an internal API only reachable by combining intelligence gathered from two separate advanced stages.&lt;/p&gt;




&lt;h2&gt;
  
  
  Key Takeaways and Mitigations
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Information Leakage (Debug, Headers, etc.)
&lt;/h3&gt;

&lt;p&gt;Debug output that's convenient during development can leak AWS keys if left enabled in production. HTML comments, response headers, and &lt;code&gt;robots.txt&lt;/code&gt; are all reconnaissance vectors attackers regularly check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mitigations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Disable debug mode in production environments.&lt;/li&gt;
&lt;li&gt;Remove unnecessary response headers like &lt;code&gt;X-Powered-By&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Never place secrets in front-end source code.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  S3 Misconfiguration
&lt;/h3&gt;

&lt;p&gt;Three distinct S3 issues appeared: public access enabled, a &lt;code&gt;.hidden/&lt;/code&gt; prefix used as security-by-obscurity, and deleted files recoverable via versioning. All three stem from treating S3 like a traditional filesystem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mitigations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enable Block Public Access on all buckets.&lt;/li&gt;
&lt;li&gt;Prefixes are not access controls.&lt;/li&gt;
&lt;li&gt;If versioning is enabled, also design lifecycle policies to expire delete markers and old versions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Secrets in Git History
&lt;/h3&gt;

&lt;p&gt;Even after deleting a &lt;code&gt;.env&lt;/code&gt; file and committing the removal, &lt;code&gt;git log -p&lt;/code&gt; surfaces it instantly. Tools like &lt;code&gt;gitleaks&lt;/code&gt; can scan every branch and every commit in seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mitigations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Integrate &lt;code&gt;git-secrets&lt;/code&gt; or &lt;code&gt;gitleaks&lt;/code&gt; as a pre-commit hook.&lt;/li&gt;
&lt;li&gt;If a secret was already committed, rewrite history with &lt;code&gt;git filter-repo&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; rotate the key immediately.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Prompt Injection
&lt;/h3&gt;

&lt;p&gt;A single sentence — "ignore previous instructions" — was enough to extract the contents of the system prompt. Using the system prompt as a "hidden" information store is not a security boundary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mitigations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Never put sensitive information in system prompts.&lt;/li&gt;
&lt;li&gt;Validate both inputs and outputs. Make the boundary between user input and system instructions explicit.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Overly Broad IAM Permissions and AssumeRole
&lt;/h3&gt;

&lt;p&gt;Having &lt;code&gt;sts:AssumeRole&lt;/code&gt; allows switching to a different role. In this CTF, flags were embedded in IAM policy descriptions and EC2 tags for challenge purposes — but in the real world, metadata fields are an underappreciated place for sensitive data to accumulate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mitigations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Apply the principle of least privilege rigorously.&lt;/li&gt;
&lt;li&gt;When granting &lt;code&gt;sts:AssumeRole&lt;/code&gt;, restrict the target resources.&lt;/li&gt;
&lt;li&gt;Use Condition keys in trust policies to restrict callers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Poor Secret Management
&lt;/h3&gt;

&lt;p&gt;Three storage locations appeared: Lambda environment variables, SSM Parameter Store, and Secrets Manager. Even Secrets Manager provides no protection if the IAM permissions granting &lt;code&gt;GetSecretValue&lt;/code&gt; are too broad.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mitigations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Manage secrets in Secrets Manager.&lt;/li&gt;
&lt;li&gt;Scope the &lt;code&gt;GetSecretValue&lt;/code&gt; resource policy to the specific secret ARN.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  SSRF × IMDSv1
&lt;/h3&gt;

&lt;p&gt;A URL preview feature in the dashboard was fetching external URLs server-side — and there was no filtering to block requests to &lt;code&gt;http://169.254.169.254&lt;/code&gt;. IMDSv1 requires no token, so SSRF access to the link-local address yields EC2 role temporary credentials directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mitigations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enforce IMDSv2 (&lt;code&gt;HttpTokens: required&lt;/code&gt;) on all EC2 instances.&lt;/li&gt;
&lt;li&gt;URL-fetching features should use an allowlist, and must block private IP ranges and link-local addresses.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Secrets Persisted in Docker Image Layers
&lt;/h3&gt;

&lt;p&gt;A Dockerfile pattern like &lt;code&gt;COPY secret.txt .&lt;/code&gt; → &lt;code&gt;RUN python setup.py&lt;/code&gt; → &lt;code&gt;RUN rm secret.txt&lt;/code&gt; produces a final image where &lt;code&gt;secret.txt&lt;/code&gt; is not visible at runtime. However, downloading the image layers directly from the ECR API reveals &lt;code&gt;secret.txt&lt;/code&gt; intact in a prior layer's tarball.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mitigations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use multi-stage builds; never copy secrets into build contexts.&lt;/li&gt;
&lt;li&gt;Retrieve secrets from Secrets Manager at runtime instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Broken Multi-Tenant Permission Design
&lt;/h3&gt;

&lt;p&gt;The S3 Vectors resource policy was set to &lt;code&gt;Resource: "*"&lt;/code&gt;, allowing a role scoped to one tenant to query another tenant's vector data. Tenant isolation in SaaS demands rigorous permission separation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mitigations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Constrain the &lt;code&gt;Resource&lt;/code&gt; and &lt;code&gt;Condition&lt;/code&gt; in resource policies to tenant-specific identifiers.&lt;/li&gt;
&lt;li&gt;If sharing a vector bucket, scope queries and metadata access by tenant at the API level.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Cognito Authorization Design Flaw
&lt;/h3&gt;

&lt;p&gt;Passing &lt;code&gt;custom:role=admin&lt;/code&gt; in &lt;code&gt;--user-attributes&lt;/code&gt; during &lt;code&gt;aws cognito-idp sign-up&lt;/code&gt; was enough to self-declare administrator status, which the application then trusted for authorization decisions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mitigations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Control role assignment server-side (e.g., in a Pre Sign-up Lambda Trigger).&lt;/li&gt;
&lt;li&gt;Never use attributes that external parties can set as the basis for authorization.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  AI Agent Over-Privilege
&lt;/h3&gt;

&lt;p&gt;The Bedrock agent's IAM role had access to S3 buckets that the DataAnalystRole itself could not read. By asking the agent a natural-language question, data from otherwise-inaccessible buckets was pulled out indirectly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mitigations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Apply the principle of least privilege to AI agent roles as well.&lt;/li&gt;
&lt;li&gt;Explicitly enumerate and restrict the resources an agent is permitted to access.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  CloudTrail for Evidence Preservation
&lt;/h3&gt;

&lt;p&gt;With CloudTrail logs in place, "what happened, when, and by whom" can be reconstructed almost completely. A handful of &lt;code&gt;jq&lt;/code&gt; filters were enough to trace the attacker's full activity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mitigations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enable CloudTrail in all regions and ship logs to S3.&lt;/li&gt;
&lt;li&gt;Pair with GuardDuty for real-time detection.&lt;/li&gt;
&lt;li&gt;Apply Object Lock to the log bucket to prevent tampering.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Closing Thoughts
&lt;/h2&gt;

&lt;p&gt;All 27 challenges together gave me a visceral sense of how AWS misconfigurations cascade. Each individual problem represented a realistic "seen-in-the-wild" vulnerability — but what made this CTF special was that they were all woven into a single coherent story.&lt;/p&gt;

&lt;p&gt;AWS certifications don't teach you &lt;em&gt;why&lt;/em&gt; something is dangerous. Solving these challenges hands-on — making mistakes, working around missing tools, figuring out the low-level APIs when Docker wasn't available — built an intuition that studying documentation alone never could.&lt;/p&gt;

&lt;p&gt;Highly recommend participating if a similar opportunity comes around. And if you're building on AWS, I hope this report serves as a useful checklist of things worth double-checking in your own environment.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>security</category>
      <category>ctf</category>
      <category>cloudnative</category>
    </item>
  </channel>
</rss>
