<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged aws</title>
<link>https://coderlegion.com/tag/aws</link>
<description>Powered by Question2Answer</description>
<item>
<title>From Writing the Spec to Approving It: What AI-DLC Looks Like on a Real Build</title>
<link>https://coderlegion.com/33078/from-writing-the-spec-to-approving-it-what-ai-dlc-looks-like-on-a-real-build</link>
<description>&lt;h3 id=&quot;-from-writing-the-spec-to-approving-it-what-ai-dlc-looks-like-on-a-real-build&quot;&gt;🤖 From Writing the Spec to Approving It: What AI-DLC Looks Like on a Real Build&lt;/h3&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;AI-DLC is public and open source: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/awslabs/aidlc-workflows&quot;&gt;awslabs/aidlc-workflows&lt;/a&gt;. The method comes from this &lt;a rel=&quot;nofollow&quot; href=&quot;https://aws.amazon.com/blogs/devops/ai-driven-development-life-cycle/&quot;&gt;AWS DevOps blog post&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;My only written input was one sentence. Everything else I did was pick options and approve gates.&lt;/li&gt;
&lt;li&gt;The run produced requirements, four architecture decisions, a unit plan, a traceability file, and 12 tests that passed when I ran them, in about 44 minutes from start to the code approval.&lt;/li&gt;
&lt;li&gt;The human role moves from author to decision-maker. The decisions are still yours, and the record shows where they changed the outcome.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Most AI-assisted development still follows the old split. A human writes the spec, a human writes the code, and the AI helps with autocomplete or generates tests at the end. AI-DLC (the AI-Driven Development Life Cycle) reverses that. The AI drafts the requirements, the design, and the code. The human answers short questions and approves each stage before the next one starts.&lt;/p&gt;
&lt;p&gt;To see what that looks like in practice, I built a small command-line tool with it and then read the record the workflow left behind. This post walks through that record, folder by folder.&lt;/p&gt;
&lt;h3 id=&quot;the-app&quot;&gt;The app&lt;/h3&gt;
&lt;p&gt;The tool is a Spooky Date Fact Generator. It picks one of 10 spooky dates (or takes &lt;code&gt;--date MM-DD&lt;/code&gt;) and prints a spooky event for that day from Wikipedia's &quot;On this day&quot; feed. It is small on purpose.&lt;/p&gt;
&lt;p&gt;The whole request was this sentence:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/aidlc build random date generator with fact around that number
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-flip-in-one-table&quot;&gt;The flip, in one table&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Traditional with AI help &lt;/th&gt;&lt;th&gt;AI-DLC&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Requirements &lt;/td&gt;&lt;td&gt;Human writes them &lt;/td&gt;&lt;td&gt;AI drafts them from multiple-choice answers&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Design &lt;/td&gt;&lt;td&gt;Human sketches it &lt;/td&gt;&lt;td&gt;AI proposes, human picks between options&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Code &lt;/td&gt;&lt;td&gt;Human writes, AI assists &lt;/td&gt;&lt;td&gt;AI writes against an approved plan&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Tests &lt;/td&gt;&lt;td&gt;Human or AI at the end &lt;/td&gt;&lt;td&gt;AI writes them to the stated requirements&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Human job &lt;/td&gt;&lt;td&gt;Author &lt;/td&gt;&lt;td&gt;Decide, validate, approve&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Evidence &lt;/td&gt;&lt;td&gt;Pull request diff &lt;/td&gt;&lt;td&gt;Audit log, decisions, traceability file&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;where-everything-was-written&quot;&gt;Where everything was written&lt;/h3&gt;
&lt;p&gt;Two locations matter. The generated app sits in its own folder, and the workflow record sits in an &lt;code&gt;aidlc/&lt;/code&gt; folder.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;app/                                  &amp;lt;- the generated tool
  src/spooky-facts.ts      core: dates, fetch, keyword filter, nearest-date search
  src/cli.ts               command line: options, output, exit codes
  test/                    12 tests, offline, with a fake fetch
  package.json, biome.json, tsconfig.json, README.md

aidlc/
  codekb/                             &amp;lt;- 9 docs about the existing repo
  intents/&amp;lt;date&amp;gt;-date-fact-generator/ &amp;lt;- the record for this build
    aidlc-state.md                    stage checklist and settings
    audit/                            every event, decision, and approval
    inception/
      reverse-engineering/            scan of the existing repo
      requirements-analysis/          questions + requirements.md
      domain-design/                  questions + components.md + decisions.md
      units-generation/               unit-of-work files
    construction/u1-spooky-date-cli/code-generation/
      code-generation-plan.md, code-summary.md,
      unit-test-instructions.md, traceability.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each stage writes a &lt;code&gt;-questions.md&lt;/code&gt; file, and your answers sit in the same file as the AI's questions. That makes the decision trail readable without any tooling.&lt;/p&gt;
&lt;h3 id=&quot;stage-by-stage-what-it-did-and-why-it-helps&quot;&gt;Stage by stage: what it did and why it helps&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Initialization.&lt;/strong&gt; The workflow scaffolded the record folder, scanned the workspace, and classified it as brownfield TypeScript on Bun. This is automatic. It helps because every later stage starts from facts about the repo instead of guesses.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Reverse Engineering.&lt;/strong&gt; A developer agent scanned the existing repo and wrote nine knowledge-base documents (architecture, technology stack, code structure and others). This matters most in a brownfield repo. The later code followed the repo's Bun, TypeScript, and Biome conventions because the AI had read them first.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Requirements Analysis.&lt;/strong&gt; The AI asked six questions, such as where facts come from, what to do when a date has no spooky event, and whether this should be a web page or a command line. Each had lettered options. I answered, and one answer included a free-text note: generate dates that have spooky facts, from 10 such scenarios. The result is &lt;code&gt;requirements.md&lt;/code&gt; with 15 functional and 5 non-functional requirements, each with acceptance criteria and a tag pointing back to the answer that produced it, such as &lt;code&gt;[Q5]&lt;/code&gt;. This stage turns one vague sentence into testable statements before any code exists.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Domain Design.&lt;/strong&gt; Four more questions produced &lt;code&gt;components.md&lt;/code&gt; and four architecture decision records. The AI recommended splitting the tool into four parts. I chose two. The decision record states that the four-part split was rejected as more structure than needed. This is the clearest example of the validator role, because the human overrode the AI's recommendation and the reason is recorded.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Units Generation.&lt;/strong&gt; One question: build as one unit or two. I chose one, approved the plan, and got &lt;code&gt;unit-of-work.md&lt;/code&gt; with boundaries, responsibilities, and constraints. It fixes the blast radius. The unit is limited to &lt;code&gt;apps/date-fact-generator/&lt;/code&gt;, and nothing in the framework folders may change.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Code Generation.&lt;/strong&gt; The AI wrote a plan, then the code, then the tests. It asked one thing here: Bun was not installed, so should it install Bun or switch to Node. I chose Bun. The &lt;code&gt;code-summary.md&lt;/code&gt; lists files created, decisions made, and deviations from the plan. One deviation worth noting: the AI added word forms such as &quot;dies&quot; and &quot;sank&quot; to the keyword list, because whole-word matching would otherwise miss &quot;Houdini dies&quot;. The summary also states that some planned checks were grouped into fewer tests to match the 12-test count.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Build and Test.&lt;/strong&gt; In the record I read, this stage was still marked in progress, so its summary was not written yet. I ran the checks myself from the app folder: 12 tests passed with 93 assertions, and lint and typecheck were clean.&lt;/p&gt;
&lt;h3 id=&quot;what-the-human-actually-did&quot;&gt;What the human actually did&lt;/h3&gt;
&lt;p&gt;Reading the audit log, the human work was this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Typed one sentence to start.&lt;/li&gt;
&lt;li&gt;Approved a recomposed plan. The workflow's planner found the default express plan was missing inputs for later stages, and proposed adding Domain Design and Units Generation while dropping deployment and monitoring.&lt;/li&gt;
&lt;li&gt;Answered 11 multiple-choice questions across three stages, plus two more decisions: build inside the existing repo, and install Bun.&lt;/li&gt;
&lt;li&gt;Approved 5 stage gates: reverse engineering, requirements, domain design, units, and code generation.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That is the flip. Nothing in that list is writing a spec or writing code. All of it is deciding and checking.&lt;/p&gt;
&lt;h3 id=&quot;why-this-holds-up-better-than-a-prompt&quot;&gt;Why this holds up better than a prompt&lt;/h3&gt;
&lt;p&gt;Two details make the validation real instead of a rubber stamp.&lt;/p&gt;
&lt;p&gt;First, traceability. &lt;code&gt;traceability.json&lt;/code&gt; maps every requirement ID (FR1.1 through NFR5, 20 in total) to the file that satisfies it. If a requirement has no target, it shows up.&lt;/p&gt;
&lt;p&gt;Second, the record is complete. The audit log holds the timestamp of each question, answer, and approval. A reviewer six months later can see why the design has two components and not four.&lt;/p&gt;
&lt;h3 id=&quot;what-this-run-does-not-show&quot;&gt;What this run does not show&lt;/h3&gt;
&lt;p&gt;This was an express-scope build of a 658-line app, with plan approval, sensors, and learnings switched off by the scope. It shows the shape of the method, not how it behaves on a large system with many units and teams. The Build and Test stage had not completed in the record, and the tests are offline, so only one manual live smoke run, recorded in the code summary, exercised the real Wikipedia feed.&lt;/p&gt;
&lt;p&gt;The questions are only as good as the options offered. A weak option list can steer a hurried approver, so reading the alternatives still matters.&lt;/p&gt;
&lt;h3 id=&quot;takeaway&quot;&gt;Takeaway&lt;/h3&gt;
&lt;p&gt;The useful change is not that the AI writes more code. It is that the human's time moves to the points where judgment counts: what the tool should do, which design to accept, and whether the result meets the requirements. If you want to try it, start with a small brownfield repo and read the &lt;code&gt;-questions.md&lt;/code&gt; files first. They show the method faster than any description.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/33078/from-writing-the-spec-to-approving-it-what-ai-dlc-looks-like-on-a-real-build</guid>
<pubDate>Fri, 30 Oct 2026 10:36:49 +0000</pubDate>
</item>
<item>
<title>I automated the hardest parts of Amazon SES, so you can send from your own AWS account in two minute</title>
<link>https://coderlegion.com/32773/automated-the-hardest-parts-amazon-ses-so-you-can-send-from-your-own-aws-account-two-minute</link>
<description>&lt;p&gt;Amazon SES is the most cost-effective way to send transactional email. But a production setup&lt;br&gt;
takes real work: IAM, the sandbox, bounce and complaint handling, logs, and watching your&lt;br&gt;
reputation. I built SendFleet to do that work inside your own AWS account.&lt;/p&gt;
&lt;h4 id=&quot;what-it-does&quot;&gt;What it does&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;One-click connection.&lt;/strong&gt; A CloudFormation template creates a scoped IAM role. Paste its ARN and&lt;br&gt;
you are connected. No AWS keys are ever shared.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Finds your sending region.&lt;/strong&gt; Production access is per region, so SendFleet checks them all and&lt;br&gt;
picks the one that can send. If none can, it requests production access for you.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tracking and webhooks.&lt;/strong&gt; It sets up the configuration set and SNS topic in your account, keeps&lt;br&gt;
them healthy, and turns events into an email log and signed webhooks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reputation protection.&lt;/strong&gt; It pauses sending before your bounce or complaint rate reaches the&lt;br&gt;
levels where AWS starts reviewing accounts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cost controls.&lt;/strong&gt; You choose which SES events your account publishes, and set a monthly cap.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Transparency.&lt;/strong&gt; Every change SendFleet makes in your account is listed in the dashboard.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;how-it-is-built&quot;&gt;How it is built&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;A Rust API for sending (&lt;code&gt;POST /v1/send&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;A Django dashboard.&lt;/li&gt;
&lt;li&gt;Python Lambdas in a separate AWS account: a relay that assumes your role to send, a control&lt;br&gt;
plane for setup and self-healing, and an event worker.&lt;/li&gt;
&lt;li&gt;Postgres on Neon. Message bodies never reach the database.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;pricing&quot;&gt;Pricing&lt;/h4&gt;
&lt;p&gt;Free for 3,000 emails a month. $5 a month with 20,000 emails included, then $0.10 per 1,000. You&lt;br&gt;
pay AWS for SES directly.&lt;/p&gt;
&lt;p&gt;Try it at &lt;a rel=&quot;nofollow&quot; href=&quot;https://sendfleet.net&quot;&gt;https://sendfleet.net&lt;/a&gt;. I'm keen to hear what would make you trust a tool like this with&lt;br&gt;
your AWS account.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32773/automated-the-hardest-parts-amazon-ses-so-you-can-send-from-your-own-aws-account-two-minute</guid>
<pubDate>Wed, 28 Oct 2026 06:00:57 +0000</pubDate>
</item>
<item>
<title>Organization-Wide EC2 DNS Automation with EventBridge &amp; AWS Config Aggregator: One Rule Per Account</title>
<link>https://coderlegion.com/32736/organization-wide-ec2-dns-automation-with-eventbridge-aws-config-aggregator-rule-account</link>
<description>&lt;h3 id=&quot;-organization-wide-ec2-dns-automation-with-eventbridge-and-aws-config-aggregator-one-rule-per-account&quot;&gt;🌐 Organization-Wide EC2 DNS Automation with EventBridge and AWS Config Aggregator: One Rule Per Account&lt;/h3&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Detecting EC2 launches and terminations across a whole AWS Organization for DNS is not the five-minute single-account job it looks like.&lt;/li&gt;
&lt;li&gt;The EC2 state-change event carries only an instance ID and a state, no IP, so it is useless for DNS on its own.&lt;/li&gt;
&lt;li&gt;A hub-and-spoke design keeps the per-account footprint to a single EventBridge rule; all logic lives in one hub account.&lt;/li&gt;
&lt;li&gt;The hub enriches each event by querying the AWS Config Aggregator you already run, which holds every instance's IP, tags, and state org-wide. No new data pipeline.&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;aws:PrincipalOrgID&lt;/code&gt; resource policy on the hub's custom bus gives cross-account fan-in without per-account config.&lt;/li&gt;
&lt;li&gt;SQS between the rule and the Lambda absorbs bulk launches and gives you retries plus a dead-letter queue.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;How to detect every EC2 launch and termination across an entire AWS Organization, and turn it into automated DNS registration, without deploying repeated infrastructure in every account.&lt;/p&gt;
&lt;p&gt;I spent longer than I would like to admit staring at this problem before the answer got simple. So if you are wiring up DNS across an AWS Organization right now, maybe this saves you a weekend.&lt;/p&gt;
&lt;p&gt;Here is the short version of what I landed on. I needed near real-time DNS registration for every EC2 instance across a multi-account, multi-region org, feeding an on-prem enterprise DDI system (think Infoblox). The two obvious ways to do it both annoyed me: one puts infrastructure in every account, the other leans on a fragile cross-account API-scraping job.&lt;/p&gt;
&lt;p&gt;What I used instead is a hub-and-spoke event setup:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Each spoke account gets exactly one EventBridge rule. That is the whole footprint.&lt;/li&gt;
&lt;li&gt;One hub account does everything else: a custom event bus, an SQS queue, one Lambda.&lt;/li&gt;
&lt;li&gt;The Lambda fills in the missing details by querying the AWS Config Aggregator, which, and this is the part I almost missed, already has every instance's IP, tags, and state for the entire org. No new data pipeline required.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Onboarding a new account came down to a one-line deploy. Below is the whole thing: the architecture, why I made the calls I made, and the code.&lt;/p&gt;
&lt;h3 id=&quot;the-problem-why-the-single-account-version-does-not-scale-&quot;&gt;The problem (why the single-account version does not scale)&lt;/h3&gt;
&lt;p&gt;In one account this is a five-minute job. EC2 fires a state-change event, you point an EventBridge rule at a Lambda, done. I have written that Lambda a dozen times.&lt;/p&gt;
&lt;p&gt;Across an organization it falls apart for a few reasons:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Instances come up in dozens of accounts and several regions.&lt;/li&gt;
&lt;li&gt;The people who run DNS are not the people who own the workload accounts, so &quot;just add it to the account&quot; is not really on the table.&lt;/li&gt;
&lt;li&gt;The EC2 state-change event does not include the IP address. You get an instance ID and a state. That is it. Useless for DNS on its own.&lt;/li&gt;
&lt;li&gt;Nobody wants to deploy, version, and babysit a Lambda plus queue plus IAM role in every account just to catch these events.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The usual reason you end up here is a move to an enterprise DDI platform. Every instance that boots needs a DNS record, every instance that dies needs its record cleaned up, org-wide, in near real-time, with as little junk in each account as you can manage.&lt;/p&gt;
&lt;p&gt;So two things really drove the design:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Adding an account should be almost free.&lt;/li&gt;
&lt;li&gt;The IP is not in the event, so I have to look it up, and that lookup cannot be flaky.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;the-approaches-i-threw-out&quot;&gt;The approaches I threw out&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Option A: put the whole stack in every account.&lt;/strong&gt; Rule, queue, Lambda, IAM role, shipped everywhere with StackSets. It works. But now you own N copies of the same code, and every bug fix or IAM tweak is an N-account rollout. I have maintained fleets like that. No thanks.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Option B: a central poller that assumes a role into the source account&lt;/strong&gt; and calls DescribeInstances for the IP. Logic is centralized, which is nice, but you need an assume-role trust in every account and you are paying a cross-account API hop on every single event. More IAM surface, more things to break.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Option C: hub-and-spoke, enriched from the Config Aggregator.&lt;/strong&gt; This is the one. All the logic sits in the hub. Spokes just forward. And instead of building anything to fetch the IP, the hub asks the Config Aggregator, which is already sitting there aggregating EC2 config (IP, tags, state) from every account and region.&lt;/p&gt;
&lt;p&gt;Option C won for a boring but decisive reason: the hard part, the enrichment data, already existed. I did not have to build it. I just had to notice it was there.&lt;/p&gt;
&lt;h3 id=&quot;how-it-fits-together&quot;&gt;How it fits together&lt;/h3&gt;
&lt;p&gt;Spokes forward EC2 state changes to the hub's custom bus; the hub enriches via the org Config Aggregator, then calls the DDI system. Spokes are dumb, the hub is smart, and the data already exists. That is the whole idea in one line.&lt;/p&gt;
&lt;p&gt;That second hub rule is easy to forget, by the way. If you only listen on the custom bus, the hub account's own instances never get DNS records, because they never leave home to reach the custom bus. Ask me how I know.&lt;/p&gt;
&lt;h3 id=&quot;filling-in-the-blanks&quot;&gt;Filling in the blanks&lt;/h3&gt;
&lt;p&gt;Most single-account tutorials skip this bit, which is exactly the bit that matters at scale. Here is what the event actually gives you:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;source&quot;: &quot;aws.ec2&quot;,
  &quot;detail-type&quot;: &quot;EC2 Instance State-change Notification&quot;,
  &quot;detail&quot;: {
    &quot;instance-id&quot;: &quot;i-1234567890&quot;,
    &quot;state&quot;: &quot;running&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;An ID and a state. No IP, no tags, no hostname. So the Lambda goes and gets the rest, from the aggregator, not from the source account.&lt;/p&gt;
&lt;p&gt;The nice thing: the aggregator is org-level, so one query resolves an instance no matter which account or region it lives in. No assume-role, no per-account permissions to manage.&lt;/p&gt;
&lt;h3 id=&quot;the-code&quot;&gt;The code&lt;/h3&gt;
&lt;p&gt;Everything here is vendor-neutral and anonymized. Swap the Infoblox call for whatever you actually talk to: Route 53, a CMDB, an IPAM API.&lt;/p&gt;
&lt;h4 id=&quot;the-spoke-s-one-and-only-rule-terraform-&quot;&gt;The spoke's one and only rule (Terraform)&lt;/h4&gt;
&lt;p&gt;This is the entire per-account footprint. Ship it with StackSets or a Terraform module.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;# --- Deployed in EVERY spoke account/region ---

resource &quot;aws_cloudwatch_event_rule&quot; &quot;forward_ec2_state&quot; {
  name           = &quot;forward-ec2-state-to-hub&quot;
  description    = &quot;Forward EC2 running/terminated events to the hub account&quot;
  event_bus_name = &quot;default&quot;

  event_pattern = jsonencode({
    source        = [&quot;aws.ec2&quot;]
    &quot;detail-type&quot; = [&quot;EC2 Instance State-change Notification&quot;]
    detail = {
      state = [&quot;running&quot;, &quot;terminated&quot;]
    }
  })
}

resource &quot;aws_cloudwatch_event_target&quot; &quot;to_hub_bus&quot; {
  rule           = aws_cloudwatch_event_rule.forward_ec2_state.name
  event_bus_name = &quot;default&quot;
  arn            = &quot;arn:aws:events:${var.region}:${var.hub_account_id}:event-bus/ec2-dns-hub-bus&quot;
  role_arn       = aws_iam_role.eventbridge_cross_account.arn
}

# Minimal role that lets EventBridge PutEvents to the hub bus
resource &quot;aws_iam_role&quot; &quot;eventbridge_cross_account&quot; {
  name = &quot;eventbridge-forward-to-hub&quot;
  assume_role_policy = jsonencode({
    Version = &quot;2012-10-17&quot;
    Statement = [{
      Effect    = &quot;Allow&quot;
      Principal = { Service = &quot;events.amazonaws.com&quot; }
      Action    = &quot;sts:AssumeRole&quot;
    }]
  })
}

resource &quot;aws_iam_role_policy&quot; &quot;put_events&quot; {
  role = aws_iam_role.eventbridge_cross_account.id
  policy = jsonencode({
    Version = &quot;2012-10-17&quot;
    Statement = [{
      Effect   = &quot;Allow&quot;
      Action   = &quot;events:PutEvents&quot;
      Resource = &quot;arn:aws:events:${var.region}:${var.hub_account_id}:event-bus/ec2-dns-hub-bus&quot;
    }]
  })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;the-hub-s-custom-bus-with-an-org-scoped-resource-policy&quot;&gt;The hub's custom bus, with an org-scoped resource policy&lt;/h4&gt;
&lt;p&gt;This policy is the thing that makes &quot;one rule per spoke&quot; actually work: any account in the org can publish, and the hub needs zero per-account config.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;# --- Deployed ONCE in the hub account ---

resource &quot;aws_cloudwatch_event_bus&quot; &quot;hub&quot; {
  name = &quot;ec2-dns-hub-bus&quot;
}

resource &quot;aws_cloudwatch_event_bus_policy&quot; &quot;allow_org&quot; {
  event_bus_name = aws_cloudwatch_event_bus.hub.name
  policy = jsonencode({
    Version = &quot;2012-10-17&quot;
    Statement = [{
      Sid       = &quot;AllowOrgAccountsToPutEvents&quot;
      Effect    = &quot;Allow&quot;
      Principal = &quot;*&quot;
      Action    = &quot;events:PutEvents&quot;
      Resource  = aws_cloudwatch_event_bus.hub.arn
      Condition = {
        StringEquals = {
          &quot;aws:PrincipalOrgID&quot; = var.aws_org_id
        }
      }
    }]
  })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Do not skip the &lt;code&gt;aws:PrincipalOrgID&lt;/code&gt; condition. It scopes that &lt;code&gt;Principal = &quot;*&quot;&lt;/code&gt; down to your organization instead of, you know, the entire internet. It is the one line standing between &quot;elegant pattern&quot; and &quot;open event bus.&quot;&lt;/p&gt;
&lt;h4 id=&quot;rules-queue-and-lambda-wiring-hub-&quot;&gt;Rules, queue, and Lambda wiring (hub)&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;resource &quot;aws_sqs_queue&quot; &quot;events&quot; {
  name                       = &quot;ec2-dns-events&quot;
  visibility_timeout_seconds = 120
  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.dlq.arn
    maxReceiveCount     = 5
  })
}

resource &quot;aws_sqs_queue&quot; &quot;dlq&quot; {
  name = &quot;ec2-dns-events-dlq&quot;
}

# Rule on the custom bus (events from spokes)
resource &quot;aws_cloudwatch_event_rule&quot; &quot;hub_custom&quot; {
  name           = &quot;ec2-state-custom-bus&quot;
  event_bus_name = aws_cloudwatch_event_bus.hub.name
  event_pattern = jsonencode({
    source        = [&quot;aws.ec2&quot;]
    &quot;detail-type&quot; = [&quot;EC2 Instance State-change Notification&quot;]
    detail        = { state = [&quot;running&quot;, &quot;terminated&quot;] }
  })
}

resource &quot;aws_cloudwatch_event_target&quot; &quot;custom_to_sqs&quot; {
  rule           = aws_cloudwatch_event_rule.hub_custom.name
  event_bus_name = aws_cloudwatch_event_bus.hub.name
  arn            = aws_sqs_queue.events.arn
}

# Second rule on the default bus catches the hub account's OWN instances
resource &quot;aws_cloudwatch_event_rule&quot; &quot;hub_default&quot; {
  name           = &quot;ec2-state-default-bus&quot;
  event_bus_name = &quot;default&quot;
  event_pattern = jsonencode({
    source        = [&quot;aws.ec2&quot;]
    &quot;detail-type&quot; = [&quot;EC2 Instance State-change Notification&quot;]
    detail        = { state = [&quot;running&quot;, &quot;terminated&quot;] }
  })
}

resource &quot;aws_cloudwatch_event_target&quot; &quot;default_to_sqs&quot; {
  rule = aws_cloudwatch_event_rule.hub_default.name
  arn  = aws_sqs_queue.events.arn
}

resource &quot;aws_lambda_event_source_mapping&quot; &quot;sqs_to_lambda&quot; {
  event_source_arn = aws_sqs_queue.events.arn
  function_name    = aws_lambda_function.dns_registrar.arn
  batch_size       = 10
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;the-enrichment-lambda-python-&quot;&gt;The enrichment Lambda (Python)&lt;/h4&gt;
&lt;p&gt;This is the heart of it: resolve the instance against the Config Aggregator, then act on the state.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import json
import boto3

config = boto3.client(&quot;config&quot;)

AGGREGATOR_NAME = &quot;OrganizationConfigAggregator&quot;

def get_instance_details(instance_id: str) -&amp;gt; dict | None:
    &quot;&quot;&quot;Resolve IP, tags, account, and region from the org Config Aggregator.&quot;&quot;&quot;
    expression = (
        &quot;SELECT resourceId, accountId, awsRegion, &quot;
        &quot;configuration.privateIpAddress, &quot;
        &quot;configuration.publicIpAddress, tags &quot;
        f&quot;WHERE resourceId = '{instance_id}' &quot;
        &quot;AND resourceType = 'AWS::EC2::Instance'&quot;
    )
    resp = config.select_aggregate_resource_config(
        Expression=expression,
        ConfigurationAggregatorName=AGGREGATOR_NAME,
        Limit=1,
    )
    results = resp.get(&quot;Results&quot;, [])
    return json.loads(results[0]) if results else None


def handler(event, _context):
    for record in event[&quot;Records&quot;]:
        body = json.loads(record[&quot;body&quot;])
        detail = body[&quot;detail&quot;]
        instance_id = detail[&quot;instance-id&quot;]
        state = detail[&quot;state&quot;]

        details = get_instance_details(instance_id)
        if not details:
            # Terminated instances may already be gone from the aggregator;
            # fall back to event data for deregistration.
            if state == &quot;terminated&quot;:
                deregister_dns(instance_id)
            continue

        ip = (
            details.get(&quot;configuration&quot;, {}).get(&quot;privateIpAddress&quot;)
            or details.get(&quot;configuration&quot;, {}).get(&quot;publicIpAddress&quot;)
        )

        if state == &quot;running&quot; and ip:
            register_dns(instance_id, ip, details.get(&quot;tags&quot;, []))
        elif state == &quot;terminated&quot;:
            deregister_dns(instance_id)


def register_dns(instance_id, ip, tags):
    # Replace with your Infoblox / Route 53 / IPAM call.
    # Infoblox example: POST to the WAPI record:a endpoint.
    print(f&quot;REGISTER {instance_id} -&amp;gt; {ip}&quot;)


def deregister_dns(instance_id):
    print(f&quot;DEREGISTER {instance_id}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One thing worth calling out: that &lt;code&gt;SELECT&lt;/code&gt; against the aggregator resolves a resource across every account and region in a single call. That is the capability that collapses the whole cross-account enrichment headache into a one-liner. I keep coming back to it for other problems now.&lt;/p&gt;
&lt;p&gt;And notice the fallback in the handler. A terminated instance has often already dropped out of the aggregator by the time you look, so you cannot rely on the lookup for deregistration. You fall back to the instance ID from the event. Small thing, but it will bite you in testing if you miss it.&lt;/p&gt;
&lt;h3 id=&quot;why-there-is-an-sqs-queue-in-the-middle&quot;&gt;Why there is an SQS queue in the middle&lt;/h3&gt;
&lt;p&gt;You can target the Lambda straight from the rule, and for a demo I would. In production I put SQS in between on purpose:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A bulk launch, a scale-out, a StackSet rollout, can throw hundreds of events at you at once. SQS soaks that up and lets the Lambda drain at its own pace instead of getting hammered.&lt;/li&gt;
&lt;li&gt;When Infoblox or the aggregator has a bad minute, messages retry and eventually land in a DLQ you can actually look at. Nothing quietly disappears.&lt;/li&gt;
&lt;li&gt;Batching. Ten events per invocation, fewer cold starts, lower cost.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Decoupling ingest from processing is most of what separates &quot;worked in the demo&quot; from &quot;still working at 2am during a mass launch.&quot;&lt;/p&gt;
&lt;h3 id=&quot;what-actually-came-out-of-it&quot;&gt;What actually came out of it&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Detection and DNS updates happened within seconds, across the whole org.&lt;/li&gt;
&lt;li&gt;Adding an account or region was a single forwarding rule. No Lambda, no queue, no Config setup per account.&lt;/li&gt;
&lt;li&gt;Registration and cleanup were fully automated across the instance lifecycle, nobody touching DNS by hand.&lt;/li&gt;
&lt;li&gt;Cost tracked instance activity, not the clock, because there is no idle compute.&lt;/li&gt;
&lt;li&gt;The shape is reusable. Same hub-spoke plus aggregator-enrichment works for CMDB sync, security tagging, IPAM updates, anything org-wide and event-driven.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;the-part-worth-stealing&quot;&gt;The part worth stealing&lt;/h3&gt;
&lt;p&gt;Take Infoblox out of the picture and you are left with a pattern I now reach for often:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Fan org-wide events into one hub, then enrich them from a data source the organization already has, instead of building a new one.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Two moves do the work:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A custom event bus with an &lt;code&gt;aws:PrincipalOrgID&lt;/code&gt; resource policy gives you cross-account fan-in and a one-rule spoke footprint.&lt;/li&gt;
&lt;li&gt;The Config Aggregator, used as an enrichment lookup, resolves any resource in any account or region without an assume-role fan-out.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;So the next time you are about to copy the same Lambda into 40 accounts, stop and ask whether a fan-in plus an aggregator you already run could do it with one rule per account instead. Usually it can.&lt;/p&gt;
&lt;h3 id=&quot;wrapping-up&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;p&gt;Honestly, none of the individual pieces here are clever. Cross-account EventBridge is documented. Config aggregators are documented. The win was in composing them so the per-account cost dropped to near zero and the enrichment data was something we already had lying around. That is where most of the good architecture calls seem to live, not in the shiny new component, but in the one you talked yourself out of building.&lt;/p&gt;
&lt;p&gt;If you end up adapting the enrichment step to Route 53, a CMDB, or your own IPAM, I am happy to talk through it in the comments.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32736/organization-wide-ec2-dns-automation-with-eventbridge-aws-config-aggregator-rule-account</guid>
<pubDate>Tue, 27 Oct 2026 19:19:28 +0000</pubDate>
</item>
<item>
<title>Kubernetes Security Is Three Problems, Not One: Admission, Identity, Runtime</title>
<link>https://coderlegion.com/32018/kubernetes-security-is-three-problems-not-one-admission-identity-runtime</link>
<description>&lt;h3 id=&quot;-kubernetes-security-is-three-problems-not-one-admission-identity-runtime&quot;&gt;🛡️ Kubernetes Security Is Three Problems, Not One: Admission, Identity, Runtime&lt;/h3&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Most Kubernetes compromises start inside the perimeter, where the firewall verifies nothing.&lt;/li&gt;
&lt;li&gt;Treat security as three independent problems: admission (what deploys), identity (what a pod can reach), runtime (what a pod actually does).&lt;/li&gt;
&lt;li&gt;Admission: Pod Security Admission as a free floor, Kyverno for per-resource policy, Gatekeeper where you need Rego. Run in audit mode first, then enforce.&lt;/li&gt;
&lt;li&gt;Identity: IRSA kills static AWS keys, ExternalSecrets kills hardcoded app secrets. Scope roles per workload.&lt;/li&gt;
&lt;li&gt;Runtime: Falco watches syscalls via eBPF and catches what the manifest never declared. Budget time to tune per-workload rules.&lt;/li&gt;
&lt;li&gt;Supply chain is the layer most teams skip: Cosign signing plus Kyverno verifyImages closes the mutable-tag gap, and SLSA provenance plus SBOMs answer &quot;which running images have this CVE?&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Kubernetes security failures usually do not look like network intrusions. An attacker who gets code execution inside a container, through an application bug, a poisoned dependency, or a policy gap that let a privileged pod schedule, is already inside the perimeter the firewall was watching. From there the firewall verified nothing: which secrets the process can reach, which syscalls it can make, whether the running image is even the one you signed.&lt;/p&gt;
&lt;p&gt;That is the useful reframe. Kubernetes security is not one problem at the boundary. It is three problems inside it, and each one needs its own control that keeps working when the others fail:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Admission&lt;/strong&gt; decides what is allowed to deploy, and with what configuration.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Identity&lt;/strong&gt; decides what each running pod is allowed to reach.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Runtime&lt;/strong&gt; watches what a pod actually does, which is often not what its manifest claimed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of the individual tools below are new, and none of this is a new technique. What is worth sharing is how the layers fit together, so a failure in any one of them does not open an exploitable gap. The model maps cleanly onto NIST SP 800-207 (zero trust) and NIST SP 800-190 (container security), and you can hold it to the CIS Kubernetes Benchmark as a measurable floor.&lt;/p&gt;
&lt;h3 id=&quot;layer-1-admission-decide-what-gets-in&quot;&gt;Layer 1: admission, decide what gets in&lt;/h3&gt;
&lt;p&gt;Start with what Kubernetes gives you for free. Pod Security Admission (PSA) enforces security profiles per namespace with no external tooling. The Restricted profile blocks host namespace sharing, privilege escalation, root contexts, and unsafe seccomp settings. A practical split is Restricted for externally facing namespaces and Baseline for internal platform namespaces with documented exceptions.&lt;/p&gt;
&lt;p&gt;PSA is a floor, not a scalpel. It enforces at the namespace level, so it cannot say &quot;this specific Deployment must run as UID 1000 with this seccomp profile.&quot; That is where &lt;strong&gt;Kyverno&lt;/strong&gt; comes in. Its policies are plain YAML, they live in the same GitOps repo as your manifests, and they go through the same pull request review as any other change. Four rule types cover most needs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;validate&lt;/code&gt; rejects non-compliant resources (for example, require every image to come from an approved ECR registry).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;mutate&lt;/code&gt; corrects omissions automatically, which turns &quot;you forgot a security context&quot; from an admission failure into a silent fix.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;generate&lt;/code&gt; creates dependent resources on creation.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;verifyImages&lt;/code&gt; enforces signature checks at admission time (more on that below).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For constraint logic that depends on the state of &lt;em&gt;other&lt;/em&gt; cluster resources, or cross-namespace rules, &lt;strong&gt;OPA Gatekeeper&lt;/strong&gt; and Rego express things Kyverno's YAML cannot. Running both is a reasonable choice: Kyverno for the common case, Gatekeeper where you need Rego's relational power.&lt;/p&gt;
&lt;p&gt;One rollout lesson worth stating plainly: both tools have an audit mode, and you should use it first. Measure violations with enforcement off, remediate systematically, then switch to enforce. Flipping straight to enforcement is how a security rollout turns into a production incident.&lt;/p&gt;
&lt;h3 id=&quot;layer-2-identity-kill-the-static-credential&quot;&gt;Layer 2: identity, kill the static credential&lt;/h3&gt;
&lt;p&gt;A static AWS key in a ConfigMap or an environment variable is a persistent risk with a wide blast radius. &lt;strong&gt;IRSA&lt;/strong&gt; (IAM Roles for Service Accounts) removes the category entirely. The AWS SDK in a pod annotated with an IRSA service account exchanges a short-lived, audience-scoped JWT for temporary IAM credentials through STS. Nothing static is created, stored, or rotated. The IAM trust policy validates the token's subject claim against a specific service account and namespace, so a compromised pod in one namespace cannot assume another namespace's role.&lt;/p&gt;
&lt;p&gt;The payoff is blast-radius reduction you can describe concretely. Without IRSA, a compromised pod inherits whatever the node's instance profile can reach. With IRSA, it is limited to that one pod's scoped role.&lt;/p&gt;
&lt;p&gt;Application secrets need a parallel mechanism, and that is the &lt;strong&gt;ExternalSecrets Operator&lt;/strong&gt;. It syncs values from AWS Secrets Manager or Parameter Store into Kubernetes Secrets and refreshes them on the source's rotation schedule. When Secrets Manager rotates a database password, the new value propagates without a deployment. IRSA plus ExternalSecrets together removes every static credential: cloud access through IRSA, application secrets through ESO, nothing hardcoded in a Helm values file.&lt;/p&gt;
&lt;p&gt;Scaling per-workload IAM roles across hundreds of workloads is the part teams tend to defer. Attribute-based access control with IAM permission boundaries makes it tractable: a role that expresses &quot;this service account, in this namespace, for this environment&quot; can be generated from workload metadata instead of hand-authored each time.&lt;/p&gt;
&lt;h3 id=&quot;layer-3-runtime-watch-what-actually-happens&quot;&gt;Layer 3: runtime, watch what actually happens&lt;/h3&gt;
&lt;p&gt;Admission decides what can deploy. Identity decides what it can reach. Neither sees what a pod does after it is running. &lt;strong&gt;Falco&lt;/strong&gt; does, using eBPF probes at the kernel level to observe every syscall from every container on the node. A shell spawned in a container whose image has no shell in its approved process list raises an alert, regardless of how the compromise happened or whether the attacker edited the deployment manifest. Because eBPF sits below the container runtime, it keeps working even if the runtime is tampered with, and it needs no change to the image or the application.&lt;/p&gt;
&lt;p&gt;Two practical notes. First, uniform rules across every workload generate false positives that erode trust in the alerts. A data-processing pod that legitimately reads many files will trip rules tuned for an application server. Per-workload rule sets, stored in Git next to the manifests, let you tighten baselines for payment or secret-handling services and loosen them for CI runners. Second, route Falco alerts into a SIEM so individual detections become correlated events. A shell-spawn alert followed within seconds by an outbound connection to an unknown IP is a pattern a correlation engine can act on, including automated network-policy quarantine of the affected pod.&lt;/p&gt;
&lt;p&gt;Plan for a calibration period. Default Falco rules produce false-positive volume that takes iterative tuning, with both security and application knowledge, before the layer runs at production-grade sensitivity.&lt;/p&gt;
&lt;h3 id=&quot;the-layer-most-teams-skip-supply-chain&quot;&gt;The layer most teams skip: supply chain&lt;/h3&gt;
&lt;p&gt;An image that passes admission is trusted to be what its manifest says, but only if the reference is cryptographically verified. Tags are mutable. Someone can overwrite the content behind a trusted tag and the cluster will deploy it under the same name, which bypasses every admission policy that checks the image's &lt;em&gt;properties&lt;/em&gt; rather than its &lt;em&gt;identity&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cosign&lt;/strong&gt; signs images at the registry, and Kyverno &lt;code&gt;verifyImages&lt;/code&gt; rules reject anything at admission that is not signed by an approved identity. That closes the tag-mutability gap. &lt;strong&gt;SLSA&lt;/strong&gt; provenance is the next step up: signing proves an image came from your build system, provenance proves what went into that build (source commit, build platform, resolved dependencies). A Software Bill of Materials attached as an attestation then lets you answer &quot;which running images contain this newly disclosed CVE?&quot; by querying the SBOM store instead of inspecting containers one by one.&lt;/p&gt;
&lt;h3 id=&quot;putting-it-together&quot;&gt;Putting it together&lt;/h3&gt;
&lt;p&gt;The point of three independent layers is that no single failure is fatal. A policy gap that lets a bad pod through is still caught by scoped identity and runtime monitoring. A runtime compromise still cannot reach credentials the pod's IAM role never granted. A swapped image is rejected before it schedules.&lt;/p&gt;
&lt;p&gt;If you are starting from scratch, a reasonable order is:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Turn on Pod Security Admission at Baseline or Restricted. Free, immediate.&lt;/li&gt;
&lt;li&gt;Add Kyverno in audit mode, learn your violations, then enforce.&lt;/li&gt;
&lt;li&gt;Move workloads to IRSA and ExternalSecrets to delete static credentials.&lt;/li&gt;
&lt;li&gt;Deploy Falco, then budget real time to tune per-workload rules.&lt;/li&gt;
&lt;li&gt;Add Cosign signing and verifyImages, then SLSA provenance and SBOMs.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The honest tradeoff is maintenance. Policy-as-code needs upkeep as Kubernetes APIs and workloads change, and Falco rule tuning is ongoing work. The reason to pay that cost is coverage no single control gives you: admission, identity, and runtime failures are different problems, and defending only one of them leaves the other two open.&lt;/p&gt;
&lt;h4 id=&quot;further-reading&quot;&gt;Further reading&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;NIST SP 800-207, Zero Trust Architecture.&lt;/li&gt;
&lt;li&gt;NIST SP 800-190, Application Container Security Guide.&lt;/li&gt;
&lt;li&gt;CIS Kubernetes Benchmark.&lt;/li&gt;
&lt;li&gt;Kyverno, OPA Gatekeeper, Falco, and Sigstore/Cosign project docs.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;If you run this in production, I would be interested in which layer gave you the most trouble to operationalize. For me it is runtime rule tuning. Drop a comment.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32018/kubernetes-security-is-three-problems-not-one-admission-identity-runtime</guid>
<pubDate>Thu, 22 Oct 2026 03:19:32 +0000</pubDate>
</item>
<item>
<title>How AWS Is Designed: Regions, Availability Zones, and Where a Workload Runs</title>
<link>https://coderlegion.com/31946/how-aws-is-designed-regions-availability-zones-and-where-a-workload-runs</link>
<description>&lt;p&gt;Most AWS learning starts with picking a service. The first decision is actually where the workload runs. Region and Availability Zone choices shape availability, speed, compliance, and cost for everything built on top of them.&lt;/p&gt;
&lt;h3 id=&quot;what&quot;&gt;WHAT&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;AWS is organized into Regions (geographic areas), each containing multiple Availability Zones (isolated data center groups), with edge locations placed close to end users.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;AWS infrastructure has three layers:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Region:&lt;/strong&gt; a geographic area such as Mumbai, Virginia, or Frankfurt. Each Region operates independently of the others.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Availability Zone (AZ):&lt;/strong&gt; one or more physically separate data centers inside a Region, each with its own power, cooling, and networking. AZs in the same Region are connected by fast, low-latency links (very short delay between them).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Edge location:&lt;/strong&gt; a small site close to end users that caches content for faster delivery. Amazon CloudFront, AWS's content delivery service, uses this network.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/9vfz9oojnwex8sz9mx4p.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;A Region decides &lt;em&gt;where&lt;/em&gt; a workload lives. AZs decide &lt;em&gt;how well it survives failures&lt;/em&gt;.&lt;/p&gt;
&lt;h3 id=&quot;why&quot;&gt;WHY&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Any single data center can fail, and Regions exist because distance and law both matter.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Power, network, and natural events can take a data center offline. An application running in only one location goes down with it. Because AZs are physically separated, an application spread across two or more keeps running when one fails.&lt;/p&gt;
&lt;p&gt;Regions solve a different problem. Choosing the Region affects:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Latency&lt;/strong&gt; (response delay): users in Singapore experience real delay reaching a server in Virginia.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data residency:&lt;/strong&gt; some countries and contracts require data to stay within specific borders.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Service availability:&lt;/strong&gt; not every AWS service or feature exists in every Region.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Compliance:&lt;/strong&gt; regulatory frameworks may restrict where data can be stored and processed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cost:&lt;/strong&gt; prices for the same service differ by Region.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These choices are hard to change later, and mistakes in Region choice or redundancy often surface only during an outage or an audit.&lt;/p&gt;
&lt;h3 id=&quot;when&quot;&gt;WHEN&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Choose the Region by user location and compliance, then use multiple AZs whenever downtime has a business cost.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Region and AZ decisions come before most service decisions, because they constrain what follows. Some services, such as IAM and Route 53, are global, but most workloads are tied to the Region they are built in.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Region decision order&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;If a legal or contractual rule fixes the location, that decides it.&lt;/li&gt;
&lt;li&gt;Otherwise, pick the Region closest to most users.&lt;/li&gt;
&lt;li&gt;Then confirm the required services are available there, and compare cost.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Single AZ vs. multiple AZs&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A single-AZ setup is simpler and cheaper but cannot survive a data center failure, so it suits development, testing, or a proof of concept (POC, a small trial build). For anything a customer depends on, multi-AZ is the baseline expectation rather than an upgrade. The distinguishing signal: if an hour of downtime would cost money, hurt customers, or breach an agreement, the workload should span at least two AZs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Multi-AZ vs. multi-Region&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Multi-AZ protects against a data center-level failure. Multi-Region protects against a whole Region becoming unavailable, but adds significant cost and complexity. Most workloads need only multi-AZ.&lt;/p&gt;
&lt;h3 id=&quot;how&quot;&gt;HOW&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Run copies of each layer in two AZs, so a failure in one is absorbed by the other.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Example scenario:&lt;/strong&gt; a production setup in the Mumbai Region.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;EC2 instances (virtual servers rented from AWS) run in two AZs, one in each.&lt;/li&gt;
&lt;li&gt;A load balancer (a traffic distributor that sends requests only to healthy servers) sits in front and receives all user requests.&lt;/li&gt;
&lt;li&gt;The database runs on RDS (AWS's managed database service) in Multi-AZ mode, which keeps an automatic standby copy in the second AZ.&lt;/li&gt;
&lt;li&gt;If one AZ fails, the load balancer stops sending traffic there, and the standby database is promoted to primary.&lt;/li&gt;
&lt;li&gt;The web layer shifts almost instantly, and the database typically recovers within a minute or two.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Flow:&lt;/strong&gt; Users → Load Balancer → EC2 instances in AZ-a and AZ-b → RDS primary (AZ-a) with standby (AZ-b)&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/b5g7hi3wx6z6eah88vct.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;A multi-AZ setup in one Region. If AZ-a fails, the load balancer routes all traffic to AZ-b and the RDS standby is promoted to primary.&lt;/em&gt;&lt;/p&gt;
&lt;h3 id=&quot;where-the-services-fit&quot;&gt;Where the Services Fit&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;AWS services are building blocks in layers, and the Region and AZ layout decides how they are placed.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Memorizing every service matters less than knowing which layer each one covers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Compute:&lt;/strong&gt; EC2, Lambda, ECS, EKS&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Storage:&lt;/strong&gt; S3, EBS, EFS&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Database:&lt;/strong&gt; RDS, Aurora, DynamoDB&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Networking:&lt;/strong&gt; VPC (a private network inside AWS), Route 53, CloudFront&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Security:&lt;/strong&gt; IAM (access control), KMS, WAF, GuardDuty&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Integration:&lt;/strong&gt; SQS, SNS, EventBridge&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Monitoring:&lt;/strong&gt; CloudWatch, CloudTrail&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A sound design answers six questions:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Where should the workload run?&lt;/li&gt;
&lt;li&gt;How will it remain available?&lt;/li&gt;
&lt;li&gt;How will users access it?&lt;/li&gt;
&lt;li&gt;How will it scale?&lt;/li&gt;
&lt;li&gt;How will it be secured?&lt;/li&gt;
&lt;li&gt;What will it cost?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The first question shapes the other five. This is where cloud architecture moves beyond knowing individual services and starts designing solutions.&lt;/p&gt;
&lt;h3 id=&quot;takeaway-1&quot;&gt;Takeaway&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;The one thing to remember:&lt;/strong&gt; Choose the Region for compliance and latency, then spread across Availability Zones for resilience, because that one decision shapes every architecture choice that follows.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31946/how-aws-is-designed-regions-availability-zones-and-where-a-workload-runs</guid>
<pubDate>Wed, 21 Oct 2026 09:20:05 +0000</pubDate>
</item>
<item>
<title>AWS PyTorch: Build, Train, and Deploy Deep Learning Models in the Cloud</title>
<link>https://coderlegion.com/31929/aws-pytorch-build-train-and-deploy-deep-learning-models-in-the-cloud</link>
<description>&lt;hr&gt;
&lt;p&gt;title: &quot;AWS PyTorch: Build, Train, and Deploy Deep Learning Models in the Cloud&quot;&lt;br&gt;
published: true&lt;br&gt;
description: &quot;Learn how to build a PyTorch image classifier, store model artifacts in Amazon S3, and move toward managed machine learning training and deployment with Amazon SageMaker AI.&quot;&lt;/p&gt;
&lt;h3 id=&quot;tags-aws-pytorch-machinelearning-python&quot;&gt;tags: aws, pytorch, machinelearning, python&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/neldd08v6n3oeqcj5r1g.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;PyTorch makes it easier to experiment with neural networks, computer vision, natural language processing, and deep learning.&lt;/p&gt;
&lt;p&gt;But building a model is only one part of a machine learning project.&lt;/p&gt;
&lt;p&gt;When it's time to move from experimentation to production, you need reliable compute, data storage, repeatable training, model artifacts, deployment, monitoring, and cost controls.&lt;/p&gt;
&lt;p&gt;This is where AWS can help.&lt;/p&gt;
&lt;p&gt;By combining &lt;strong&gt;PyTorch, Amazon SageMaker AI, Amazon S3, and Amazon CloudWatch&lt;/strong&gt;, developers can build machine learning workflows that move from local experiments toward scalable cloud deployments.&lt;/p&gt;
&lt;p&gt;In this article, we'll build a simple image classifier and explore how to extend the project into an AWS-based machine learning pipeline.&lt;/p&gt;
&lt;h3 id=&quot;1-what-is-pytorch-on-aws-&quot;&gt;1. What Is PyTorch on AWS?&lt;/h3&gt;
&lt;p&gt;PyTorch is an open-source deep learning framework that provides tensor operations, automatic differentiation, GPU acceleration, and tools for creating neural networks.&lt;/p&gt;
&lt;p&gt;AWS provides several services that can support PyTorch workloads:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Amazon SageMaker AI:&lt;/strong&gt; Run managed training jobs and deploy machine learning models.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Amazon S3:&lt;/strong&gt; Store datasets, model artifacts, and training outputs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Amazon EC2:&lt;/strong&gt; Run PyTorch on virtual machines, including suitable GPU instances.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AWS Deep Learning Containers:&lt;/strong&gt; Use supported, preconfigured machine learning environments.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Amazon CloudWatch:&lt;/strong&gt; Monitor supported metrics, logs, and application behavior.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AWS IAM:&lt;/strong&gt; Control access to training data, infrastructure, and model artifacts.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You don't need all these services for every project. Start with what your workload requires and add complexity when it provides a clear benefit.&lt;/p&gt;
&lt;h3 id=&quot;2-understand-the-architecture&quot;&gt;2. Understand the Architecture&lt;/h3&gt;
&lt;p&gt;A typical workflow looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;        Training Dataset
               |
               v
         Amazon S3
               |
               v
      Amazon SageMaker AI
          Training Job
               |
               v
         PyTorch Model
               |
               v
       Model Artifacts in S3
               |
               v
       Deployment / Inference
               |
               v
       Application Predictions
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Amazon S3 provides durable storage for datasets and artifacts. SageMaker AI can run managed training jobs, while a suitable inference service can expose the trained model to applications.&lt;/p&gt;
&lt;p&gt;CloudWatch and related AWS monitoring capabilities help you investigate failures, latency, and resource usage.&lt;/p&gt;
&lt;p&gt;For small experiments, you can train locally first. For larger workloads, managed training can reduce infrastructure-management overhead.&lt;/p&gt;
&lt;h3 id=&quot;3-hands-on-project-build-a-digit-classifier&quot;&gt;3. Hands-On Project: Build a Digit Classifier&lt;/h3&gt;
&lt;p&gt;We'll train a small neural network on the MNIST handwritten-digit dataset.&lt;/p&gt;
&lt;p&gt;Our model will learn to classify grayscale images into one of ten categories, representing the digits from 0 to 9.&lt;/p&gt;
&lt;h4 id=&quot;prerequisites&quot;&gt;Prerequisites&lt;/h4&gt;
&lt;p&gt;You'll need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Python 3.&lt;/li&gt;
&lt;li&gt;Basic Python knowledge.&lt;/li&gt;
&lt;li&gt;An internet connection to download the dataset.&lt;/li&gt;
&lt;li&gt;An AWS account only when you move to the cloud steps.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The first part runs locally and does not require AWS resources.&lt;/p&gt;
&lt;h4 id=&quot;step-1-create-a-project-environment&quot;&gt;Step 1: Create a project environment&lt;/h4&gt;
&lt;p&gt;Create a project directory and enter it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mkdir aws-pytorch-demo
cd aws-pytorch-demo
python -m venv .venv
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Activate the virtual environment on Windows:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-powershell&quot;&gt;.venv\Scripts\Activate.ps1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On Linux or macOS:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;source .venv/bin/activate
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Install the dependencies:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pip install torch torchvision
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For GPU training, install a PyTorch build compatible with your GPU and CUDA configuration. Follow the official installation instructions: &lt;a rel=&quot;nofollow&quot; href=&quot;https://pytorch.org/get-started/locally/&quot;&gt;https://pytorch.org/get-started/locally/&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id=&quot;step-2-create-the-training-script&quot;&gt;Step 2: Create the training script&lt;/h4&gt;
&lt;p&gt;Create &lt;code&gt;train.py&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torch import nn
from torch.utils.data import DataLoader
from torchvision import datasets, transforms

device = torch.device(
    &quot;cuda&quot; if torch.cuda.is_available() else &quot;cpu&quot;
)

print(f&quot;Training device: {device}&quot;)

transform = transforms.ToTensor()

train_data = datasets.MNIST(
    root=&quot;data&quot;,
    train=True,
    download=True,
    transform=transform,
)

test_data = datasets.MNIST(
    root=&quot;data&quot;,
    train=False,
    download=True,
    transform=transform,
)

train_loader = DataLoader(
    train_data,
    batch_size=64,
    shuffle=True,
)

test_loader = DataLoader(
    test_data,
    batch_size=256,
    shuffle=False,
)


class DigitClassifier(nn.Module):
    def __init__(self):
        super().__init__()

        self.network = nn.Sequential(
            nn.Flatten(),
            nn.Linear(28 * 28, 128),
            nn.ReLU(),
            nn.Linear(128, 10),
        )

    def forward(self, x):
        return self.network(x)


model = DigitClassifier().to(device)

criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(
    model.parameters(),
    lr=0.001,
)

# Training loop
for epoch in range(3):
    model.train()
    total_loss = 0.0

    for images, labels in train_loader:
        images = images.to(device)
        labels = labels.to(device)

        optimizer.zero_grad()

        outputs = model(images)
        loss = criterion(outputs, labels)

        loss.backward()
        optimizer.step()

        total_loss += loss.item()

    average_loss = total_loss / len(train_loader)

    print(
        f&quot;Epoch {epoch + 1}/3, &quot;
        f&quot;Loss: {average_loss:.4f}&quot;
    )

# Evaluation
model.eval()
correct = 0
total = 0

with torch.no_grad():
    for images, labels in test_loader:
        images = images.to(device)
        labels = labels.to(device)

        outputs = model(images)
        predictions = outputs.argmax(dim=1)

        correct += (predictions == labels).sum().item()
        total += labels.size(0)

accuracy = 100 * correct / total
print(f&quot;Test accuracy: {accuracy:.2f}%&quot;)

# Save model parameters
torch.save(
    model.state_dict(),
    &quot;digit_classifier.pth&quot;,
)

print(&quot;Saved digit_classifier.pth&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;step-3-train-the-model&quot;&gt;Step 3: Train the model&lt;/h4&gt;
&lt;p&gt;Run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;python train.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The script downloads MNIST, trains the neural network, evaluates it against the test dataset, and saves the learned parameters.&lt;/p&gt;
&lt;p&gt;Your output should include:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Training device: cpu
Epoch 1/3, Loss: ...
Epoch 2/3, Loss: ...
Epoch 3/3, Loss: ...
Test accuracy: ...
Saved digit_classifier.pth
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The loss and accuracy values depend on the training run. If a compatible GPU is available, the script can use it automatically.&lt;/p&gt;
&lt;h4 id=&quot;step-4-make-a-prediction&quot;&gt;Step 4: Make a prediction&lt;/h4&gt;
&lt;p&gt;Create &lt;code&gt;predict.py&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import torch
from torch import nn
from torchvision import datasets, transforms


class DigitClassifier(nn.Module):
    def __init__(self):
        super().__init__()

        self.network = nn.Sequential(
            nn.Flatten(),
            nn.Linear(28 * 28, 128),
            nn.ReLU(),
            nn.Linear(128, 10),
        )

    def forward(self, x):
        return self.network(x)


device = torch.device(
    &quot;cuda&quot; if torch.cuda.is_available() else &quot;cpu&quot;
)

model = DigitClassifier().to(device)

state = torch.load(
    &quot;digit_classifier.pth&quot;,
    map_location=device,
    weights_only=True,
)

model.load_state_dict(state)
model.eval()

dataset = datasets.MNIST(
    root=&quot;data&quot;,
    train=False,
    download=True,
    transform=transforms.ToTensor(),
)

image, actual_label = dataset[0]

with torch.no_grad():
    logits = model(image.unsqueeze(0).to(device))
    predicted_label = logits.argmax(dim=1).item()

print(f&quot;Predicted digit: {predicted_label}&quot;)
print(f&quot;Actual digit: {actual_label}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run the prediction:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;python predict.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Example output:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Predicted digit: 7
Actual digit: 7
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These values are illustrative. The actual result depends on your trained model.&lt;/p&gt;
&lt;p&gt;You now have a basic end-to-end deep learning workflow: train, evaluate, save, reload, and predict.&lt;/p&gt;
&lt;h3 id=&quot;4-move-the-project-to-aws&quot;&gt;4. Move the Project to AWS&lt;/h3&gt;
&lt;p&gt;Once the model works locally, you can move the workflow to AWS.&lt;/p&gt;
&lt;h4 id=&quot;step-1-create-an-s3-bucket&quot;&gt;Step 1: Create an S3 bucket&lt;/h4&gt;
&lt;p&gt;Choose a globally unique bucket name:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;aws s3 mb s3://YOUR-UNIQUE-PYTORCH-BUCKET \
  --region us-east-1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Replace the placeholder with your actual bucket name.&lt;/p&gt;
&lt;p&gt;Upload the training script:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;aws s3 cp train.py \
  s3://YOUR-UNIQUE-PYTORCH-BUCKET/code/train.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For this example, the script downloads MNIST directly. A production pipeline should manage dataset preparation, storage, and versioning more explicitly.&lt;/p&gt;
&lt;h4 id=&quot;step-2-run-training-with-sagemaker-ai&quot;&gt;Step 2: Run training with SageMaker AI&lt;/h4&gt;
&lt;p&gt;Amazon SageMaker AI supports PyTorch training jobs using supported framework environments and training configurations.&lt;/p&gt;
&lt;p&gt;At a high level, you need to:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Configure an execution role with appropriate permissions.&lt;/li&gt;
&lt;li&gt;Prepare your training script and dependencies.&lt;/li&gt;
&lt;li&gt;Select a supported PyTorch environment.&lt;/li&gt;
&lt;li&gt;Configure the training input and output locations.&lt;/li&gt;
&lt;li&gt;Choose an appropriate instance type.&lt;/li&gt;
&lt;li&gt;Set runtime limits and launch the job.&lt;/li&gt;
&lt;li&gt;Review the training logs and saved artifacts.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Use the current official guide for the SDK version you install:&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/sagemaker/latest/dg/pytorch.html&quot;&gt;https://docs.aws.amazon.com/sagemaker/latest/dg/pytorch.html&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;SageMaker SDK interfaces evolve, so use examples matching your installed SDK version rather than mixing older estimator APIs with newer SDK workflows.&lt;/p&gt;
&lt;h4 id=&quot;step-3-store-model-artifacts-in-s3&quot;&gt;Step 3: Store model artifacts in S3&lt;/h4&gt;
&lt;p&gt;For a locally trained model, you can upload the saved parameters:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;aws s3 cp digit_classifier.pth \
  s3://YOUR-UNIQUE-PYTORCH-BUCKET/models/digit_classifier.pth
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For a managed training job, configure the output location so SageMaker AI saves the model artifacts automatically.&lt;/p&gt;
&lt;p&gt;Keep a record of the dataset version, source-code version, training parameters, framework version, and evaluation metrics. This makes it easier to reproduce and audit experiments.&lt;/p&gt;
&lt;h3 id=&quot;5-deploy-the-model-for-inference&quot;&gt;5. Deploy the Model for Inference&lt;/h3&gt;
&lt;p&gt;Training and inference are separate stages.&lt;/p&gt;
&lt;p&gt;For an application to request predictions, you need a serving interface that loads the model, validates input, executes inference, and returns a response.&lt;/p&gt;
&lt;p&gt;With Amazon SageMaker AI, you can deploy supported PyTorch models to a managed endpoint. The required model packaging and inference code depend on the selected deployment approach.&lt;/p&gt;
&lt;p&gt;A typical inference flow is:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Load the approved model artifact.&lt;/li&gt;
&lt;li&gt;Convert the input into the tensor format expected by the model.&lt;/li&gt;
&lt;li&gt;Run the model in evaluation mode.&lt;/li&gt;
&lt;li&gt;Convert the output into a response your application can consume.&lt;/li&gt;
&lt;li&gt;Log relevant errors and operational metrics.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;For the MNIST model, input should represent a grayscale image with dimensions of 28 × 28 pixels. Validate the request before running inference.&lt;/p&gt;
&lt;p&gt;For workloads that don't need immediate responses, consider batch inference instead of keeping an online endpoint available continuously.&lt;/p&gt;
&lt;h3 id=&quot;6-production-best-practices&quot;&gt;6. Production Best Practices&lt;/h3&gt;
&lt;p&gt;A model that works locally isn't automatically production-ready.&lt;/p&gt;
&lt;h4 id=&quot;cost-optimization&quot;&gt;Cost optimization&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Start with CPU training for small models when practical.&lt;/li&gt;
&lt;li&gt;Use GPU instances when benchmarks show a meaningful benefit.&lt;/li&gt;
&lt;li&gt;Set job time limits.&lt;/li&gt;
&lt;li&gt;Stop endpoints and compute resources when they are no longer required.&lt;/li&gt;
&lt;li&gt;Evaluate batch inference for offline workloads.&lt;/li&gt;
&lt;li&gt;Include storage, logging, data transfer, and supporting services in cost estimates.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;security&quot;&gt;Security&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Apply least-privilege IAM policies.&lt;/li&gt;
&lt;li&gt;Keep S3 buckets private.&lt;/li&gt;
&lt;li&gt;Encrypt sensitive datasets and artifacts.&lt;/li&gt;
&lt;li&gt;Avoid embedding credentials in source code.&lt;/li&gt;
&lt;li&gt;Restrict access to training data and deployed models.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;reproducibility&quot;&gt;Reproducibility&lt;/h4&gt;
&lt;p&gt;Track dataset versions, dependencies, training parameters, random seeds, and model artifacts. Store code in version control and preserve evaluation results for each model version.&lt;/p&gt;
&lt;h4 id=&quot;monitoring&quot;&gt;Monitoring&lt;/h4&gt;
&lt;p&gt;Monitor training failures, compute utilization, inference latency, and application errors using supported AWS monitoring capabilities.&lt;/p&gt;
&lt;p&gt;Also evaluate model quality over time. A service can remain operational while its predictions become less accurate because real-world data has changed.&lt;/p&gt;
&lt;h4 id=&quot;model-evaluation&quot;&gt;Model evaluation&lt;/h4&gt;
&lt;p&gt;Don't rely on training loss alone.&lt;/p&gt;
&lt;p&gt;For classification tasks, consider precision, recall, F1 score, confusion matrices, and performance on representative held-out data. Select metrics based on the real business problem.&lt;/p&gt;
&lt;h3 id=&quot;7-where-can-you-use-pytorch-on-aws-&quot;&gt;7. Where Can You Use PyTorch on AWS?&lt;/h3&gt;
&lt;p&gt;Common applications include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Computer vision:&lt;/strong&gt; Image classification and defect detection.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Natural language processing:&lt;/strong&gt; Text classification and domain-specific model fine-tuning.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Recommendation systems:&lt;/strong&gt; Ranking and personalization.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Forecasting:&lt;/strong&gt; Demand prediction and time-series analysis.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Generative AI:&lt;/strong&gt; Training or fine-tuning compatible neural network models.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For large-scale distributed training, additional planning is needed for GPU memory, network communication, checkpointing, and data loading.&lt;/p&gt;
&lt;h3 id=&quot;final-thoughts&quot;&gt;Final Thoughts&lt;/h3&gt;
&lt;p&gt;PyTorch provides the flexibility to build deep learning models, while AWS offers several ways to run training workloads, store model artifacts, and deploy predictions.&lt;/p&gt;
&lt;p&gt;Start with a small experiment, validate the results, and then introduce cloud infrastructure where it provides measurable value.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The real goal is not just to train a model in the cloud. It's to create a secure, reproducible, observable machine learning workflow that can grow with your application.&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id=&quot;official-resources&quot;&gt;Official Resources&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/sagemaker/latest/dg/pytorch.html&quot;&gt;PyTorch on Amazon SageMaker AI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://aws.amazon.com/blogs/machine-learning/&quot;&gt;AWS machine learning blog&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/s3/&quot;&gt;Amazon S3 documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/cloudwatch/&quot;&gt;Amazon CloudWatch documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://pytorch.org/get-started/locally/&quot;&gt;Install PyTorch&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;aws-pytorch-machinelearning-deeplearning-amazonsagemaker&quot;&gt;AWS #PyTorch #MachineLearning #DeepLearning #AmazonSageMaker&lt;/h2&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31929/aws-pytorch-build-train-and-deploy-deep-learning-models-in-the-cloud</guid>
<pubDate>Wed, 21 Oct 2026 06:46:29 +0000</pubDate>
</item>
<item>
<title>A Field Guide to Beating Amazon Bedrock Throttling</title>
<link>https://coderlegion.com/31889/a-field-guide-to-beating-amazon-bedrock-throttling</link>
<description>&lt;h3 id=&quot;-a-field-guide-to-beating-amazon-bedrock-throttling&quot;&gt;🛡️ A Field Guide to Beating Amazon Bedrock Throttling&lt;/h3&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Know your quota first: check Service Quotas, learn which limits are adjustable (soft) and which are not (hard).&lt;/li&gt;
&lt;li&gt;Retry with exponential backoff and full jitter on throttling errors. This is the floor, not the ceiling.&lt;/li&gt;
&lt;li&gt;Cap concurrency so you stop generating load the quota cannot absorb.&lt;/li&gt;
&lt;li&gt;Add a circuit breaker so a throttle storm backs off instead of hammering.&lt;/li&gt;
&lt;li&gt;Handle timeouts separately: shrink the batch for model timeouts, tune and retry for client read timeouts.&lt;/li&gt;
&lt;li&gt;Use cross-region inference profiles to spread token demand across regions.&lt;/li&gt;
&lt;li&gt;Reserve provisioned throughput only when on-demand plus the above is not enough.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;If you run real LLM traffic through Amazon Bedrock, throttling is not an edge case. It is the normal operating condition at scale. A &lt;code&gt;ThrottlingException&lt;/code&gt; or &lt;code&gt;TooManyRequestsException&lt;/code&gt; is Bedrock telling you that your request rate or token rate crossed a quota for that model in that region. The fix is rarely one setting. It is a layered defense: understand the quota, retry intelligently, cap concurrency, degrade gracefully, handle timeouts, and know what to escalate to AWS.&lt;/p&gt;
&lt;p&gt;This is the set of strategies I run across production LLM workloads, written as a generic guide. No workload specifics, just the patterns and when each one earns its place.&lt;/p&gt;
&lt;h3 id=&quot;step-one-know-the-quota-you-are-hitting&quot;&gt;Step one: know the quota you are hitting&lt;/h3&gt;
&lt;p&gt;Before writing a line of retry code, find the actual limit. Bedrock quotas are per model, per region, and split by type: tokens per minute (TPM), requests per minute (RPM), and for some models separate input/output token quotas.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/bedrock/latest/userguide/quotas.html&quot;&gt;Quotas for Amazon Bedrock&lt;/a&gt; — the overview, including that default quotas vary by account age, usage history, and region.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/bedrock/latest/userguide/quotas-runtime.html&quot;&gt;Quotas for the bedrock-runtime endpoint&lt;/a&gt; — the runtime invoke limits you hit at inference time. View them in the Service Quotas console under Amazon Bedrock.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/bedrock/latest/userguide/scaling-throughput-best-practices.html&quot;&gt;Scaling and throughput best practices&lt;/a&gt; — how per-model TPM and RPM quotas interact.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;soft-limits-vs-hard-limits&quot;&gt;Soft limits vs hard limits&lt;/h4&gt;
&lt;p&gt;This distinction decides your whole strategy.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Soft limits (adjustable).&lt;/strong&gt; In the &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/general/latest/gr/bedrock.html&quot;&gt;Amazon Bedrock endpoints and quotas&lt;/a&gt; table these are marked &lt;code&gt;Adjustable: Yes&lt;/code&gt;. You can raise them. Request an increase through &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/servicequotas/latest/userguide/request-quota-increase.html&quot;&gt;Service Quotas&lt;/a&gt;; smaller increases often auto-approve, larger ones route to AWS Support for review.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hard limits (not adjustable).&lt;/strong&gt; Marked &lt;code&gt;Adjustable: No&lt;/code&gt;. No amount of asking moves these. Your only lever is architectural: reduce demand, spread it across regions, or change the access pattern.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The practical rule: if the limit is soft and you are consistently near it, file the increase and also build the defenses below, because the increase takes time and never makes throttling impossible. If the limit is hard, skip the request entirely and go straight to architecture.&lt;/p&gt;
&lt;p&gt;One often-missed detail: Bedrock reserves TPM quota at the start of a request based on input tokens plus &lt;code&gt;maxTokens&lt;/code&gt;. If you leave &lt;code&gt;maxTokens&lt;/code&gt; unset, it can default to the model maximum and reserve far more quota than your call actually needs, so you throttle earlier than your real token use suggests. Set &lt;code&gt;maxTokens&lt;/code&gt; to a realistic ceiling.&lt;/p&gt;
&lt;h3 id=&quot;layer-one-retry-with-backoff-and-jitter&quot;&gt;Layer one: retry with backoff and jitter&lt;/h3&gt;
&lt;p&gt;The baseline defense. Catch the throttling error codes (&lt;code&gt;ThrottlingException&lt;/code&gt;, &lt;code&gt;TooManyRequestsException&lt;/code&gt;), wait, and try again. Two details separate a good retry from a harmful one:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Exponential backoff.&lt;/strong&gt; Each attempt waits longer: &lt;code&gt;base * 2^attempt&lt;/code&gt;, capped at a ceiling. This gives the quota window time to refill.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Full jitter.&lt;/strong&gt; Randomize the delay inside that window. Without jitter, every throttled caller retries at the same instant and you get a synchronized stampede that throttles again. With jitter, retries spread out.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A compact pattern:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import random, time
from botocore.exceptions import ClientError

THROTTLE = {&quot;ThrottlingException&quot;, &quot;TooManyRequestsException&quot;}

def call_with_backoff(fn, *, base=2.0, cap=15.0, max_retries=5):
    for attempt in range(max_retries + 1):
        try:
            return fn()
        except ClientError as exc:
            code = exc.response.get(&quot;Error&quot;, {}).get(&quot;Code&quot;, &quot;&quot;)
            if code not in THROTTLE or attempt == max_retries:
                raise
            delay = min(base * 2 ** attempt, cap)
            time.sleep(random.uniform(0, delay))  # full jitter
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A note on the boto3 client's own retries. Boto3 has a built-in retry mode (&lt;code&gt;standard&lt;/code&gt; or &lt;code&gt;adaptive&lt;/code&gt;) you set via &lt;code&gt;botocore.config.Config(retries=...)&lt;/code&gt;. You can lean on it, or disable it (&lt;code&gt;max_attempts=0&lt;/code&gt;) and own the retry loop yourself. Owning it is worth the effort when you want the backoff, jitter, and error classification to match the rest of your system rather than two retry layers fighting each other.&lt;/p&gt;
&lt;h4 id=&quot;the-same-retry-in-a-step-functions-workflow&quot;&gt;The same retry in a Step Functions workflow&lt;/h4&gt;
&lt;p&gt;If your Bedrock calls run inside a Step Functions state machine, you do not write the backoff loop by hand. You declare it on the task's &lt;code&gt;Retry&lt;/code&gt; block and the service applies exponential backoff with jitter for you. &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/step-functions/latest/dg/concepts-error-handling.html&quot;&gt;Step Functions error handling&lt;/a&gt; added a &lt;code&gt;JitterStrategy&lt;/code&gt; field so wait times are spread instead of synchronized, the same reason full jitter matters in code.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;&quot;InvokeBedrock&quot;: {
  &quot;Type&quot;: &quot;Task&quot;,
  &quot;Resource&quot;: &quot;arn:aws:states:::bedrock:invokeModel&quot;,
  &quot;Retry&quot;: [
    {
      &quot;ErrorEquals&quot;: [&quot;ThrottlingException&quot;, &quot;TooManyRequestsException&quot;],
      &quot;IntervalSeconds&quot;: 2,
      &quot;MaxAttempts&quot;: 15,
      &quot;BackoffRate&quot;: 2.0,
      &quot;MaxDelaySeconds&quot;: 30,
      &quot;JitterStrategy&quot;: &quot;FULL&quot;
    }
  ],
  &quot;Catch&quot;: [
    { &quot;ErrorEquals&quot;: [&quot;States.ALL&quot;], &quot;Next&quot;: &quot;HumanReview&quot; }
  ],
  &quot;Next&quot;: &quot;NextStep&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The fields map onto the code version: &lt;code&gt;IntervalSeconds&lt;/code&gt; is the base delay, &lt;code&gt;BackoffRate&lt;/code&gt; is the exponential multiplier, &lt;code&gt;MaxDelaySeconds&lt;/code&gt; is the cap, &lt;code&gt;MaxAttempts&lt;/code&gt; is the retry count, and &lt;code&gt;JitterStrategy: FULL&lt;/code&gt; randomizes each wait. The &lt;code&gt;Catch&lt;/code&gt; routes to a fallback once retries are exhausted, for example a human-review step, so an exhausted retry degrades gracefully instead of failing the whole execution.&lt;/p&gt;
&lt;h3 id=&quot;layer-two-cap-concurrency&quot;&gt;Layer two: cap concurrency&lt;/h3&gt;
&lt;p&gt;Retries handle the request that already failed. Concurrency caps stop you from generating more load than the quota can absorb in the first place. If 50 workers all call the same model at once, you will throttle no matter how clever the backoff is.&lt;/p&gt;
&lt;p&gt;Put a ceiling on in-flight Bedrock calls. In async Python a semaphore does it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import asyncio

semaphore = asyncio.Semaphore(10)  # max concurrent Bedrock calls

async def bounded_call(fn):
    async with semaphore:
        return await fn()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Upstream of the application, cap concurrency at the infrastructure layer too. If documents arrive as events, put them on an SQS queue and let the SQS-to-Lambda event source mapping enforce a &lt;code&gt;maximum_concurrency&lt;/code&gt;. Work waits in the queue until a slot frees, with no polling and no custom counters:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;resource &quot;aws_lambda_event_source_mapping&quot; &quot;sqs_to_worker&quot; {
  event_source_arn = aws_sqs_queue.work.arn
  function_name    = aws_lambda_function.worker.arn
  batch_size       = 1
  scaling_config {
    maximum_concurrency = 5
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Match the concurrency ceiling to the quota, not to how many workers you could theoretically run. The right number is the one that keeps steady-state token rate under the TPM limit.&lt;/p&gt;
&lt;h3 id=&quot;layer-three-a-circuit-breaker-for-throttle-storms&quot;&gt;Layer three: a circuit breaker for throttle storms&lt;/h3&gt;
&lt;p&gt;Backoff and concurrency caps handle normal pressure. A sustained throttle storm needs something that steps back harder. A circuit breaker watches the throttle rate and, once it crosses a threshold inside a time window, pauses all calls for a cooldown, then resumes at reduced concurrency before restoring full throughput.&lt;/p&gt;
&lt;p&gt;The state machine:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Closed&lt;/strong&gt; (normal): calls flow, throttles are counted.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Open&lt;/strong&gt; (tripped): after N throttles in the window, pause all calls for a cooldown and halve the concurrency limit.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Half-open&lt;/strong&gt; (recovering): after the cooldown, resume at the reduced limit; if clean for a while, restore full concurrency; if it throttles again, reopen.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This converts a feedback loop that makes throttling worse (retry, throttle, retry, throttle) into one that actively sheds load until Bedrock recovers. It pairs with the concurrency limiter: the breaker is what turns the limit down and back up.&lt;/p&gt;
&lt;h3 id=&quot;layer-four-handle-timeouts-they-are-not-throttling&quot;&gt;Layer four: handle timeouts, they are not throttling&lt;/h3&gt;
&lt;p&gt;Throttling rarely travels alone, and the loudest neighbor is the timeout. There are two distinct kinds, and they want opposite fixes.&lt;/p&gt;
&lt;h4 id=&quot;model-timeout-shrink-the-work&quot;&gt;Model timeout: shrink the work&lt;/h4&gt;
&lt;p&gt;A &lt;code&gt;ModelTimeoutException&lt;/code&gt; means the request itself was too large to finish in the model's time budget. It is not a rate problem, so retrying the same payload will time it out again. The fix is to reduce the work: halve the batch or chunk the input, then retry smaller.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;async def call_shrinking(fn, items, *, max_retries=3):
    batch = list(items)
    for attempt in range(max_retries + 1):
        try:
            return await fn(batch)
        except Exception as exc:
            code = getattr(exc, &quot;response&quot;, {}).get(&quot;Error&quot;, {}).get(&quot;Code&quot;, &quot;&quot;)
            if code != &quot;ModelTimeoutException&quot; or attempt == max_retries:
                raise
            batch = batch[: max(1, len(batch) // 2)]  # halve and retry
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;client-timeout-tune-the-budget-then-retry-the-transient-ones&quot;&gt;Client timeout: tune the budget, then retry the transient ones&lt;/h4&gt;
&lt;p&gt;A client-side &lt;code&gt;ReadTimeoutError&lt;/code&gt; or &lt;code&gt;ConnectTimeoutError&lt;/code&gt; is different again: the SDK gave up waiting before the model answered. Large prompts and long generations routinely exceed the boto3 default read timeout, which produces timeouts that look like failures but are really an impatient client.&lt;/p&gt;
&lt;p&gt;Two moves:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Set a realistic &lt;code&gt;read_timeout&lt;/code&gt;&lt;/strong&gt; on the boto3 client so a slow-but-valid response is not cut off.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Treat the transient connection timeouts as retryable&lt;/strong&gt;, alongside throttling, rather than letting them bubble up as hard failures.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from botocore.config import Config

client = boto3.client(
    &quot;bedrock-runtime&quot;,
    config=Config(read_timeout=300, retries={&quot;max_attempts&quot;: 0}),
)

RETRYABLE = {&quot;ThrottlingException&quot;, &quot;TooManyRequestsException&quot;}
# also retry ReadTimeoutError / ConnectTimeoutError by exception class name
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Classify before reacting. A throttle wants backoff, a model timeout wants a smaller batch, a client timeout wants a longer budget plus a retry, and a &lt;code&gt;ValidationException&lt;/code&gt; wants no retry at all.&lt;/p&gt;
&lt;h3 id=&quot;layer-five-spread-demand-with-cross-region-inference-profiles&quot;&gt;Layer five: spread demand with cross-region inference profiles&lt;/h3&gt;
&lt;p&gt;Quotas are per region. A &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles.html&quot;&gt;cross-region inference profile&lt;/a&gt; lets a single on-demand call be served from one of several regions, so your token demand is spread across each region's quota instead of piling onto one. For on-demand workloads this is often the highest-leverage change: no capacity to buy, and the effective ceiling goes up because you are drawing from multiple regional pools.&lt;/p&gt;
&lt;p&gt;It is not infinite headroom, and it does not change a hard per-model limit in a single region, but for spiky on-demand traffic it buys real room.&lt;/p&gt;
&lt;h3 id=&quot;layer-six-provisioned-throughput-only-when-you-need-it&quot;&gt;Layer six: provisioned throughput, only when you need it&lt;/h3&gt;
&lt;p&gt;If on-demand plus everything above still throttles under predictable heavy load, Bedrock offers &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/bedrock/latest/userguide/prov-throughput.html&quot;&gt;Provisioned Throughput&lt;/a&gt;: reserved capacity (model units) with a guaranteed token rate, bought by commitment. It removes throttling for that reserved slice because the capacity is yours.&lt;/p&gt;
&lt;p&gt;Treat it as the last layer, not the first. It costs whether or not you use it, so it only makes sense for steady, predictable volume. For bursty or unpredictable traffic, retries, concurrency caps, and cross-region profiles on on-demand are usually the better economics.&lt;/p&gt;
&lt;h3 id=&quot;tuning-in-practice&quot;&gt;Tuning in practice&lt;/h3&gt;
&lt;p&gt;The layers above are the toolbox. How hard you turn each knob depends on the workload, and a couple of patterns are worth stating plainly.&lt;/p&gt;
&lt;p&gt;Match the aggressiveness of retries to the cost of a lost call. Heavy, expensive calls justify more retry attempts, a higher backoff ceiling, and tighter concurrency, because losing one is costly. High-volume, cheaper work is better served by an upstream queue that caps concurrency and a workflow-level self-retry that ends in a manual-review fallback, keeping the per-call retries light. There is no single correct number; the right setting is the one that keeps steady-state token rate under the quota for that workload.&lt;/p&gt;
&lt;p&gt;Two caveats that are easy to get wrong:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;A &quot;fallback model&quot; can quietly resolve to the same model.&lt;/strong&gt; If both the primary and fallback point at the same model id through shared config, the fallback functions as extra retries against one model, not as model diversity. Calling it a fallback overstates what it does until the config points at a genuinely different model.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Retry, backoff, and concurrency are not the same as reserved capacity.&lt;/strong&gt; A defense built entirely from retries, backoff, concurrency caps, and on-demand cross-region inference profiles has no reserved-capacity floor under a true spike. That can be a deliberate on-demand posture, but name it as one, because on-demand plus retries still has a ceiling.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;how-to-decide-what-to-reach-for&quot;&gt;How to decide what to reach for&lt;/h3&gt;
&lt;p&gt;A short decision order when you see throttling:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Check the quota.&lt;/strong&gt; Is the limit soft or hard? If soft and you are near it, file the increase now, in parallel with the rest.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Confirm retries have backoff and jitter.&lt;/strong&gt; If not, fix that first. It is the cheapest win.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cap concurrency&lt;/strong&gt; to keep steady-state rate under the TPM limit, at both the app and the infrastructure layer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add a circuit breaker&lt;/strong&gt; if throttle storms are recurring rather than occasional.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Separate timeouts from throttles.&lt;/strong&gt; Shrink the batch for model timeouts, tune the read timeout and retry for client timeouts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Spread across regions&lt;/strong&gt; with a cross-region inference profile for on-demand traffic.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Buy provisioned throughput&lt;/strong&gt; only for steady, predictable, high volume the above cannot cover.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Throttling is not a wall you hit once and clear. It is a pressure you manage continuously. The systems that stay up are the ones that treat the quota as a first-class design constraint, not an exception to catch.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31889/a-field-guide-to-beating-amazon-bedrock-throttling</guid>
<pubDate>Tue, 20 Oct 2026 20:53:15 +0000</pubDate>
</item>
<item>
<title>The Bottleneck Was Not the Model. It Was the Prompt Lookup.</title>
<link>https://coderlegion.com/31651/the-bottleneck-was-not-the-model-it-was-the-prompt-lookup</link>
<description>&lt;h3 id=&quot;-the-bottleneck-was-not-the-model-it-was-the-prompt-lookup-&quot;&gt;🔑 The Bottleneck Was Not the Model. It Was the Prompt Lookup.&lt;/h3&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A managed prompt-management service was throttling prompt retrieval on the hot path, which capped the whole pipeline.&lt;/li&gt;
&lt;li&gt;The model was never the bottleneck. Fetching the prompt was.&lt;/li&gt;
&lt;li&gt;Move prompt serving into DynamoDB: a single keyed read, no shared service quota.&lt;/li&gt;
&lt;li&gt;Keep the managed service as the authoring source of truth, and sync into DynamoDB write-through on a checksum, so only real changes get written.&lt;/li&gt;
&lt;li&gt;DynamoDB can do versioning too: a sort-key version plus a current pointer buys rollback and audit while keeping the hot path a single keyed read.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Prompts feel like configuration, so it is tempting to park them in whatever managed prompt-management service your platform hands you. You get versioning, a console, and a clean API, and for a while that is the right call. The trouble shows up later, under load, when prompt retrieval sits on the hot path of every inference call and the service starts pushing back.&lt;/p&gt;
&lt;p&gt;That is the wall I hit. The prompt-management layer was throttling requests. Because every call had to fetch its prompt before doing any real work, that throttle became a ceiling on the whole pipeline. The model was not the bottleneck. Prompt lookup was.&lt;/p&gt;
&lt;p&gt;I moved prompt serving into DynamoDB and kept the managed service as the authoring source of truth. The throttling went away, and the design has been running well since.&lt;/p&gt;
&lt;h3 id=&quot;why-a-prompt-management-service-becomes-the-bottleneck&quot;&gt;Why a prompt-management service becomes the bottleneck&lt;/h3&gt;
&lt;p&gt;Prompt-management services are built for authoring and governance, not for being read thousands of times a minute on a latency-sensitive path. They come with request quotas, and those quotas are usually shared across the account rather than scaled to one workload's read traffic. When read volume climbs, you hit the quota long before you hit any limit that has to do with the prompts themselves.&lt;/p&gt;
&lt;p&gt;The tell is subtle at first. You start seeing throttling and too-many-requests errors on prompt retrieval, so you add retries with exponential backoff and maybe a fallback for when the service says no. That is reasonable hygiene, but once you are wrapping a plain config lookup in retry logic, the store has quietly become the throughput limit of the application. You can optimize the model, batch the work, and tune everything downstream, and none of it matters, because the request is already stalled waiting for its prompt.&lt;/p&gt;
&lt;h3 id=&quot;why-dynamodb-fits-this-access-pattern&quot;&gt;Why DynamoDB fits this access pattern&lt;/h3&gt;
&lt;p&gt;A prompt lookup is about the simplest access pattern there is. You have a key, and you want the item back. That is a single-item read by primary key, which is exactly what DynamoDB is good at.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Read throughput scales with the table, not a fixed service quota.&lt;/strong&gt; On-demand capacity absorbs spikes and provisioned capacity with auto scaling handles steady load. The shared quota that was throttling me no longer sat on the path.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The access pattern is a key lookup.&lt;/strong&gt; Partition on the prompt identifier and every fetch is a single &lt;code&gt;GetItem&lt;/code&gt;. No scans, no filtering, predictable single-digit-millisecond reads.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reads and writes separate cleanly.&lt;/strong&gt; High-volume reads hit DynamoDB. The rare authoring writes stay in the managed service where review and governance already live, and a sync step pushes changes down into DynamoDB.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;the-part-that-makes-it-safe-checksum-based-sync&quot;&gt;The part that makes it safe: checksum-based sync&lt;/h3&gt;
&lt;p&gt;Caching prompts is easy. Keeping the cache honest is the part worth getting right.&lt;/p&gt;
&lt;p&gt;The managed service stays the source of truth for prompt content. DynamoDB holds the serving copy that the hot path reads. To keep them in agreement without re-uploading everything on every deploy, I sync on a checksum.&lt;/p&gt;
&lt;p&gt;The sync is write-through. When it detects a change at the source, it writes the new prompt into DynamoDB first, and readers pick it up from there afterward. The flow is simple:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;For each prompt in the source of truth, compute a checksum of its content.&lt;/li&gt;
&lt;li&gt;Compare that checksum against what is already stored for that prompt.&lt;/li&gt;
&lt;li&gt;If they match, do nothing. If they differ, the prompt changed, so write the new content into DynamoDB and record the new checksum.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This turns every sync into a cheap compare instead of a blind rewrite. Unchanged prompts cost a comparison and no write. Only a genuine edit at the source triggers a write. Because the write lands before any read needs it, a new prompt propagates cleanly: the serving copy is already updated by the time the hot path asks for it.&lt;/p&gt;
&lt;p&gt;The nice property is that the checksum is the change signal. You do not need a separate &quot;is this dirty&quot; flag or a manual version bump. If the content is identical, the checksum is identical, and the write is skipped. If someone edits a prompt, the checksum moves and the new text is written on the next sync.&lt;/p&gt;
&lt;h3 id=&quot;if-you-need-rollback-or-audit-versioning-in-dynamodb&quot;&gt;If you need rollback or audit: versioning in DynamoDB&lt;/h3&gt;
&lt;p&gt;DynamoDB does not hand you versioning the way a prompt-management service does, but you can model it, and it is worth saying so plainly because &quot;DynamoDB has no versioning&quot; is a common misread.&lt;/p&gt;
&lt;p&gt;If a single current copy per prompt is all you need, the checksum layout above is enough. If you want history, rollback, or an audit trail, add a version to the key:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Partition key:&lt;/strong&gt; the prompt identifier.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sort key:&lt;/strong&gt; the version. A monotonic integer, an ISO timestamp, or the checksum itself all work.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A current pointer:&lt;/strong&gt; one extra item per prompt (for example sort key &lt;code&gt;CURRENT&lt;/code&gt;) that names the active version.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The hot path reads the current pointer by key, so serving stays a single keyed read. History stays queryable with a &lt;code&gt;Query&lt;/code&gt; on the partition key. The checksum still decides when a new version row gets written, so you are not stacking a new version for an unchanged prompt. If concurrent syncs are possible, a conditional write (&lt;code&gt;ConditionExpression&lt;/code&gt; on the stored checksum) keeps a stale sync from overwriting a newer one.&lt;/p&gt;
&lt;p&gt;The tradeoff is simple: the single-copy layout is less to manage, the versioned layout buys rollback and audit. Both are key reads on the hot path, so neither reintroduces the throttling problem.&lt;/p&gt;
&lt;h3 id=&quot;the-shape-of-the-change&quot;&gt;The shape of the change&lt;/h3&gt;
&lt;p&gt;The migration itself was small, which is part of why it was the right call.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Model the table around the lookup.&lt;/strong&gt; Partition key on the prompt identifier. That is the whole schema, because the access pattern was known up front.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Read by key on the hot path.&lt;/strong&gt; Each inference call does a single keyed read. No scan runs against this table in production, so cost stays proportional to the reads you actually make.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sync by checksum, not on a timer or on every deploy.&lt;/strong&gt; The source of truth drives updates, and only real changes cross into DynamoDB.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The result is that prompt retrieval stopped being a shared, rate-limited dependency and became a key read against a store that scales with the traffic hitting it.&lt;/p&gt;
&lt;h3 id=&quot;what-i-would-tell-someone-before-they-copy-this&quot;&gt;What I would tell someone before they copy this&lt;/h3&gt;
&lt;p&gt;This is not an argument against prompt-management services. They earn their place for authoring, review, and governance. The mistake is letting that same service sit on a high-volume read path it was never sized for.&lt;/p&gt;
&lt;p&gt;A few things worth keeping in mind:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The symptom is a throughput ceiling that tracks request volume, not model latency.&lt;/strong&gt; If you are wrapping prompt retrieval in throttling retries, that is the tell. No downstream tuning will move it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Design the table around the access pattern first.&lt;/strong&gt; A key lookup wants a key schema. Pick the partition key from the query you actually run, and keep scans out of production.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make the cache self-correcting.&lt;/strong&gt; A checksum compare against the source of truth means the write-through sync updates the serving copy only when content actually changes, without manual version juggling and without rewriting unchanged data.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Model versioning if you need it, do not assume it is missing.&lt;/strong&gt; A sort-key version plus a current pointer gives you rollback and audit while keeping the hot path a single keyed read.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Moving prompts into DynamoDB was a small change with a disproportionate payoff. The throttling that was capping the pipeline is gone, retrieval is a clean key lookup, and the checksum sync keeps the serving copy honest against the source of truth. It has held up in production since.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31651/the-bottleneck-was-not-the-model-it-was-the-prompt-lookup</guid>
<pubDate>Sun, 18 Oct 2026 23:08:00 +0000</pubDate>
</item>
<item>
<title>Leveraging AWS CodePipeline pipeline and Serverless Framework to deploy a Flask API.</title>
<link>https://coderlegion.com/31107/leveraging-aws-codepipeline-pipeline-and-serverless-framework-to-deploy-a-flask-api</link>
<description>&lt;p&gt;For the past couple of days. I have been working with the Serverless framework. I came to know that you can actually host your flask API that integrates several AWS services such as Lambda, API Gateway, and DynamoDB using this framework. I had to spend a lot of time to understand the process at first since there is not a lot of content regarding this implementation. So, here’s me contributing a little. &lt;/p&gt;
&lt;p&gt;To make this swift, I wanted to integrate the entire process with a pipeline that contains the code and a build environment. Here’s how everything is carried out. Please understand that this is not a beginner level tutorial and the whole process is written in assumption that the readers have knowledge and experience with AWS services and Python Flask.&lt;/p&gt;
&lt;p&gt;First, you need to have a flask api. An API that works. I couldn’t share the actual code that I first used since it is confidential . So, I have decided to go with a template of serverless framework. You can also convert your flask api to be compatible with serverless using the serverless-wsgi plugin. For this, you may need to make some changes to your serverless.yaml configurations. You can check it &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.serverless.com/blog/flask-python-rest-api-serverless-lambda-dynamodb/#converting-an-existing-flask-application&quot;&gt;here&lt;/a&gt; to achieve that&lt;/p&gt;
&lt;p&gt;Now that you have your api , lets upload it again to an aws codecommit repository. You can clone an empty repo, add the files, commit and push. Check it &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/codecommit/latest/userguide/how-to-create-file.html&quot;&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Next, we need to create a build project in AWS CodeBuild.&lt;/p&gt;
&lt;p&gt;In this codebuild project, use the following buildspec.yaml file. You can choose an Ubuntu environment for the build project to run.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;version: 0.2
phases:
  install:
    runtime-versions:
      nodejs: 14
    commands:
        - npm install -g serverless
        - npm install serverless-compose
        - serverless --version
        - npm install serverless-deployment-bucket
        - sls plugin install -n serverless-wsgi
        - sls plugin install -n serverless-python-requirements

  build: 
    commands: 
      - serverless deploy 

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Remember: In this case, Codebuild is only used to run the deploy command. All the resources are built by serverless by automatically running a cloudformation.&lt;/p&gt;
&lt;p&gt;Now, we’re also gonna need another yaml file. This one is the configuration file for the serverless framework which will be deployed once we deploy it using the Aws pipeline.&lt;/p&gt;
&lt;p&gt;Here’s the configuration code.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;service: new12

frameworkVersion: '3'

custom:
  wsgi:
    app: app.hello

provider:
  name: aws
  runtime: python3.9

functions:
  api:
    handler: wsgi_handler.handler
    events:
      - httpApi: '*'

plugins:
  - serverless-wsgi
  - serverless-python-requirements


&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Also keep your flask api file in the same directory. Make sure to configure codebuild to look for the specific branch in the specific repo.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5n51ulma3twzipf83pt6.png&quot; alt=&quot;File structure in the repository&quot;&gt;&lt;/p&gt;
&lt;p&gt;Once everything is created, we need to make sure that our Codebuild service role has enough permission to use cloudformation and all the other services associated with the resources built through the cloudformation stack.&lt;/p&gt;
&lt;p&gt;In my case, I faced a lot of failed builds due to permission issues.Always make sure to check your build logs when your build fails, so that you can get a clear understanding of the underlying reasons and issues.&lt;/p&gt;
&lt;p&gt;So, to make it quick I added some permission to the codebuild service role. I suggest to create your own permission policies based on the least privilege access policy rather than assigning managed policies with Full access. &lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/x3xyr8r64is33y00qjqd.png&quot; alt=&quot;Listed AIM Policies&quot;&gt;&lt;/p&gt;
&lt;p&gt;Once the permissions are set, your pipeline will run swiftly. Here's how my pipeline looks like with the two phases.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nce9vyaltow0y5ewyq2o.png&quot; alt=&quot;CodePipeline success&quot;&gt;&lt;/p&gt;
&lt;p&gt;Here are the outputs from the cloudformation stack.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/oxjs2kp5mb19f4yc8v19.png&quot; alt=&quot;Cloudformation outputs&quot;&gt;&lt;/p&gt;
&lt;p&gt;You invoke URL will be generated in Amazon API Gateway. Here in the build logs, you can see an invoke URL for the api. &lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gxl7i306ordfkeq3uzvb.png&quot; alt=&quot;Codbuild logs&quot;&gt;&lt;/p&gt;
&lt;p&gt;The serverless Framework will also help you monitor your resources and their performance through the console. All you need to do is login to the console and connect to your aws account. The console will run another cloudformation stack to integrate with the resources in our aws account. Here is how the dashboard looks like.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vyw2ug7qovo6nabvfmut.png&quot; alt=&quot;Serverless console&quot;&gt;&lt;/p&gt;
&lt;p&gt;Thank you for making it to the end, hope You found what you were looking for!&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31107/leveraging-aws-codepipeline-pipeline-and-serverless-framework-to-deploy-a-flask-api</guid>
<pubDate>Wed, 14 Oct 2026 18:02:28 +0000</pubDate>
</item>
<item>
<title>AWS Step Functions: Creating a ‘Busy Waiting’ flow to wait for successful lambda executions.</title>
<link>https://coderlegion.com/31106/aws-step-functions-creating-a-busy-waiting-flow-to-wait-for-successful-lambda-executions</link>
<description>&lt;p&gt;Hi everyone, welcome to this article. In the past few days, I had to create a logic in AWS Step functions which involved waiting for a lambda that queries athena before going forward in the flow. &lt;/p&gt;
&lt;p&gt;When I was discussing the logic, it felt like an easy thing to accomplish in minutes but I was surprised upon knowing that Step Functions does not have its native technique/solve to wait for a state to succeed before starting another. This made me search for hours before coming up with a solution.&lt;/p&gt;
&lt;p&gt;Here, I will share with you a technique which will help you in the future.&lt;/p&gt;
&lt;p&gt;First of all, I will create a simple lambda function. All we need to know from the lambda is whether it did its job. So, this simple function will make sure to return its status.&lt;/p&gt;
&lt;p&gt;Lambda function:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import json

def lambda_handler(event, context):
    response = {
        'status': 'success',
        'message': 'Hello, World!'
    }
    
    return response
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can copy both the function and the Step function definition which I will share below to replicate all of this in your own environment. &lt;/p&gt;
&lt;p&gt;Now, lets go ahead and create a state machine in AWS Step Functions.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3vqducgkmq06ude209gj.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;Click on ‘Create state machine’. &lt;/p&gt;
&lt;p&gt;Choose the option to write your own definition and choose ‘standard’.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3mcoo8lc3omc8jbhnh7h.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;Now, in the definition section, copy and paste the following definition.&lt;/p&gt;
&lt;p&gt;Step Functions definition:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;Comment&quot;: &quot;A Hello World example of the Amazon States Language using Pass states&quot;,
  &quot;StartAt&quot;: &quot;Main&quot;,
  &quot;States&quot;: {
    &quot;Main&quot;: {
      &quot;Type&quot;: &quot;Task&quot;,
      &quot;Resource&quot;: &quot;arn:aws:states:::lambda:invoke&quot;,
      &quot;OutputPath&quot;: &quot;$.Payload&quot;,
      &quot;Parameters&quot;: {
        &quot;Payload.$&quot;: &quot;$&quot;,
        &quot;FunctionName&quot;: &quot;arn:aws:lambda:us-east-1:031342435657:function:stepfunctionlambda_test:$LATEST&quot;
      },
      &quot;Retry&quot;: [
        {
          &quot;ErrorEquals&quot;: [
            &quot;Lambda.ServiceException&quot;,
            &quot;Lambda.AWSLambdaException&quot;,
            &quot;Lambda.SdkClientException&quot;,
            &quot;Lambda.TooManyRequestsException&quot;
          ],
          &quot;IntervalSeconds&quot;: 2,
          &quot;MaxAttempts&quot;: 6,
          &quot;BackoffRate&quot;: 2
        }
      ],
      &quot;Next&quot;: &quot;WaitforInvocation&quot;
    },
    &quot;WaitforInvocation&quot;: {
      &quot;Type&quot;: &quot;Wait&quot;,
      &quot;Seconds&quot;: 2,
      &quot;Next&quot;: &quot;CheckInvocationsStatus&quot;
    },
    &quot;CheckInvocationsStatus&quot;: {
      &quot;Type&quot;: &quot;Choice&quot;,
      &quot;Choices&quot;: [
        {
          &quot;Variable&quot;: &quot;$.status&quot;,
          &quot;StringEquals&quot;: &quot;success&quot;,
          &quot;Next&quot;: &quot;Final&quot;
        }
      ],
      &quot;Default&quot;: &quot;WaitforInvocation&quot;
    },
    &quot;Final&quot;: {
      &quot;Type&quot;: &quot;Succeed&quot;
    }
  }
}


&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Save the definition and click next.&lt;/p&gt;
&lt;p&gt;Specify the name of your state machine and then leave everything else as default. A new execution role will be created if you don’t specify any.&lt;/p&gt;
&lt;p&gt;Save it and then you will have created a state machine which looks like this:&lt;/p&gt;
&lt;p&gt;Visual flow:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/h567w7r69dda3w38t36g.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;To explain the definition above, I have pointed out how this state machine works:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;The state machine starts at the &quot;Main&quot; state.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The &quot;Main&quot; state is a Task state that invokes a Lambda function specified by the &quot;FunctionName&quot; parameter. The input to the Lambda function is the entire input passed to the state machine.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The &quot;OutputPath&quot; parameter is  set to &quot;$.Payload&quot; to capture the output of the Lambda function.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If the Lambda function invocation fails with certain exceptions specified in the &quot;Retry&quot; section (Lambda.ServiceException, Lambda.AWSLambdaException, Lambda.SdkClientException,  Lambda.TooManyRequestsException), the state machine will retry the invocation up to 6 times with a backoff interval of 2 seconds.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;After invoking the Lambda function, the state machine proceeds to the &quot;WaitforInvocation&quot; state.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The &quot;WaitforInvocation&quot; state is a Wait state that pauses the execution for 2 seconds.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;After the wait period,  the state machine goes  to the &quot;CheckInvocationsStatus&quot; state.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The &quot;CheckInvocationsStatus&quot; state is a Choice state that checks  the value of the &quot;$.status&quot; field in the output of the Lambda function. If the status is &quot;success&quot;, the state machine proceeds to  the &quot;Final&quot; state. Otherwise, it goes back to the &quot;WaitforInvocation&quot; state.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The &quot;Final&quot; state is a Succeed state that indicates the successful completion of the state machine.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;Now, lets go ahead and execute it!
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/amyu3l4skfdr3os7owe5.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;Click on ‘Start execution’ and  leave the input field empty.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7hib8yqc4i674d0132r8.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;Incase of a lambda that requires input, you can insert your input here as a Json&lt;/p&gt;
&lt;p&gt;After you start your execution, each state will turn into different colours which indicates the following.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Green: Indicates successful completion of a state or step.&lt;/li&gt;
&lt;li&gt;Red: Indicates a failure or error occurred during the execution of a state or step.&lt;/li&gt;
&lt;li&gt;Yellow: Indicates a state is in a waiting or paused state.&lt;/li&gt;
&lt;li&gt;Blue: Represents a decision or choice point where the state machine evaluates conditions to determine the next state.&lt;/li&gt;
&lt;li&gt;Gray: Represents a passive state that simply passes input to output without performing significant work.&lt;/li&gt;
&lt;li&gt;White: Represents transitions or connections between states, indicating the flow of execution from one state to another.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Finally this is how the execution will look like:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fp8g4hthee521cc883w4.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;Thank you. Hope you can now go ahead and apply the  “Busy Waiting” logic in your step functions.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31106/aws-step-functions-creating-a-busy-waiting-flow-to-wait-for-successful-lambda-executions</guid>
<pubDate>Wed, 14 Oct 2026 18:01:55 +0000</pubDate>
</item>
<item>
<title>How I Built and Deployed a Production ready MERN Application on AWS using Modular Terraform and Keyless GitHub Actions OIDC </title>
<link>https://coderlegion.com/30642/built-deployed-production-ready-application-modular-terraform-keyless-github-actions</link>
<description>&lt;p&gt;Deploying a full-stack web application into production is often one of the most daunting steps for modern developers. You build a complete application locally, but when it comes to hosting it securely on cloud infrastructure with HTTPS, custom domains, and automated deployment pipelines, things can quickly get overwhelming.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/n1f3sp27b4ry73xep145.jpeg&quot; alt=&quot;MERN Deployment Architecture&quot;&gt;&lt;/p&gt;
&lt;p&gt;In this post, I will walk you through &lt;strong&gt;how I built and deployed an end-to-end full-stack MERN application (&quot;Little List&quot;)&lt;/strong&gt;—from the frontend user interface and Express REST API to provisioning production AWS cloud infrastructure with &lt;strong&gt;Terraform&lt;/strong&gt; and automating deployments with &lt;strong&gt;Keyless GitHub Actions OIDC&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Whether you are a beginner looking to understand full-stack architecture &amp;amp; cloud deployment or an experienced engineer reviewing multi-environment Terraform design, this guide covers the entire end-to-end workflow!&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-is-little-list-application-overview-&quot;&gt;What is &quot;Little List&quot;? (Application Overview)&lt;/h3&gt;
&lt;p&gt;&quot;Little List&quot; is a full-stack personal productivity and management application built from scratch:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;React SPA Frontend (Vite)&lt;/strong&gt;: Modern, responsive UI with state management, JWT auth flows, interactive diary logging, and custom URL shortener interfaces.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Node.js &amp;amp; Express API Backend&lt;/strong&gt;: Modular REST API with secure authentication, refresh tokens, MongoDB integration, and route validation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MongoDB Database&lt;/strong&gt;: Persistent storage for users, diary entries, and shortened link analytics.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;prerequisites-1&quot;&gt;Prerequisites&lt;/h3&gt;
&lt;p&gt;Before diving in, here is what you will need if you want to replicate this deployment architecture:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;AWS Account&lt;/strong&gt;: An active AWS account with permissions to manage EC2, S3, CloudFront, ECR, IAM, and Route 53.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Terraform CLI&lt;/strong&gt;: Installed on your machine (&lt;code&gt;&amp;gt;= 1.16.2&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AWS CLI&lt;/strong&gt;: Installed and configured with your credentials (&lt;code&gt;aws configure&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Docker&lt;/strong&gt;: Installed locally for testing container builds.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Node.js &amp;amp; npm&lt;/strong&gt;: Installed locally for building the frontend.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GitHub Account&lt;/strong&gt;: A repository containing your MERN codebase.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Custom Domain (Optional)&lt;/strong&gt;: A domain registered on Namecheap, GoDaddy, or Route 53 if you want custom SSL support (e.g., &lt;code&gt;ideategudy.tech&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;-the-cloud-architecture-overview&quot;&gt;️ The Cloud Architecture Overview&lt;/h3&gt;
&lt;p&gt;Before writing code, let us look at the high-level architecture diagram of what we are provisioning on AWS:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;                               ┌──────────────────────┐
                               │    GitHub Actions    │
                               └──────────┬───────────┘
                                          │
                        ┌─────────────────┴─────────────────┐
                        │                                   │
                  Frontend deploy                      Backend deploy
                        │                                   │
                        ▼                                   ▼
                ┌───────────────┐                    ┌───────────────┐
                │      S3       │                    │      ECR      │
                │ React build   │                    │ Docker image  │
                └───────┬───────┘                    └───────┬───────┘
                        │                                    │
                        │                                    │ docker pull
                        │                                    ▼
                        │                            ┌───────────────┐
                        │                            │      EC2      │
                        │                            │ Express/Docker│
                        │                            └───────┬───────┘
                        │                                    │
                        │                            ┌───────┴───────┐
                        │                            │      ASG      │
                        │                            │  EC2 fleet    │
                        │                            └───────┬───────┘
                        │                                    │
                        ▼                            ┌───────▼───────┐
                ┌───────────────┐                    │      ALB      │
                │  Route 53 DNS │                    │ Load Balancer │
                └───────┬───────┘                    └───────▲───────┘
                        │                                    │
                        ▼                                    │
                ┌───────────────┐                            │
                │  CloudFront   │────────────────────────────┘
                │  CDN (HTTPS)  │ Path-based routing (/api/*)
                └───────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;core-components-explained-for-beginners-&quot;&gt;Core Components Explained for Beginners:&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;AWS Route 53&lt;/strong&gt;: Highly available DNS service that manages custom domain records (&lt;code&gt;dev.ideategudy.tech&lt;/code&gt;) and routes client requests directly to CloudFront via A-record Aliases.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Amazon S3&lt;/strong&gt;: Hosts the static, compiled single-page React frontend (&lt;code&gt;dist/&lt;/code&gt; build files) privately.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Amazon CloudFront&lt;/strong&gt;: A global Content Delivery Network (CDN) that serves the React app over HTTPS and acts as a single reverse proxy for both client and backend requests.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Amazon ECR (Elastic Container Registry)&lt;/strong&gt;: Private Docker image registry to store backend container builds.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AWS ALB (Application Load Balancer)&lt;/strong&gt;: Receives API traffic from CloudFront and balances requests across EC2 instances.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AWS Auto Scaling Group (ASG)&lt;/strong&gt;: Manages an auto-healing fleet of EC2 instances running Canonical Ubuntu 24.04 LTS and Docker.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GitHub OIDC (OpenID Connect)&lt;/strong&gt;: Allows GitHub Actions to obtain short-lived security tokens to deploy to AWS &lt;strong&gt;without storing permanent AWS Secret Access Keys in GitHub&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;repository-amp-project-structure&quot;&gt;Repository &amp;amp; Project Structure&lt;/h3&gt;
&lt;p&gt;Here is how the project files are structured:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;.
├── client/                     # Vite + React SPA Frontend
├── server/                     # Node.js + Express API Backend (Dockerized)
├── infra/                      # Modular Terraform Infrastructure
│   ├── main.tf                 # Root Terraform Module
│   ├── variables.tf            # Root Variables &amp;amp; Configuration Defaults
│   ├── outputs.tf              # Root Terraform Outputs
│   ├── versions.tf             # AWS &amp;amp; Provider Requirements
│   ├── dev.tfvars.example      # Development Environment Settings
│   ├── prod.tfvars.example     # Production Environment Settings
│   └── modules/
│       ├── vpc/                # VPC, Public Subnets (2 AZs), IGW, Route Tables
│       ├── backend/            # ECR, ALB, ASG, Launch Template, EC2 IAM Role
│       ├── frontend/           # S3 Bucket, CloudFront OAC, CDN Distribution, Route53/ACM
│       └── github_oidc/        # GitHub OIDC Identity Provider &amp;amp; IAM Role
└── .github/
    └── workflows/
        └── deploy.yml          # Parallel CI/CD Workflow (Frontend &amp;amp; Backend)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;key-technical-deep-dives&quot;&gt;Key Technical Deep Dives&lt;/h3&gt;
&lt;h4 id=&quot;1-unified-single-domain-routing-with-cloudfront&quot;&gt;1. Unified Single-Domain Routing with CloudFront&lt;/h4&gt;
&lt;p&gt;One common challenge with SPA + API setups is CORS and handling multiple URLs (&lt;code&gt;api.domain.com&lt;/code&gt; vs &lt;code&gt;domain.com&lt;/code&gt;). We solved this by serving &lt;strong&gt;both frontend and backend under the exact same domain (&lt;code&gt;https://dev.ideategudy.tech&lt;/code&gt;)&lt;/strong&gt; via CloudFront Path-Based Routing:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Request Path Pattern &lt;/th&gt;&lt;th&gt;Origin Target &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;&lt;code&gt;/&lt;/code&gt; or &lt;code&gt;/assets/*&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;s3-frontend&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Serves static React SPA files&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;/api/*&lt;/code&gt;, &lt;code&gt;/s/*&lt;/code&gt;, &lt;code&gt;/healthz&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;alb-api&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Forwards API requests directly to the EC2/Express backend&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;&lt;p&gt; &lt;strong&gt;Pro-Tip&lt;/strong&gt;: When forwarding requests through CloudFront to an ALB, ensure your CloudFront cache behavior explicitly forwards the &lt;code&gt;Authorization&lt;/code&gt;, &lt;code&gt;Accept&lt;/code&gt;, &lt;code&gt;Content-Type&lt;/code&gt;, &lt;code&gt;Origin&lt;/code&gt;, and &lt;code&gt;Referer&lt;/code&gt; headers! Otherwise, CloudFront strips the &lt;code&gt;Authorization: Bearer &amp;lt;jwt&amp;gt;&lt;/code&gt; header by default, leading to &lt;code&gt;401 Unauthorized&lt;/code&gt; errors on authenticated routes.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;2-zero-secrets-in-github-via-aws-oidc&quot;&gt;2. Zero Secrets in GitHub via AWS OIDC&lt;/h4&gt;
&lt;p&gt;Instead of creating long-lived IAM user keys (&lt;code&gt;AWS_ACCESS_KEY_ID&lt;/code&gt; and &lt;code&gt;AWS_SECRET_ACCESS_KEY&lt;/code&gt;) and pasting them into GitHub Secrets, we configured an &lt;strong&gt;AWS IAM OIDC Identity Provider&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;GitHub Actions assumes a temporal AWS IAM Role dynamically per pipeline run using OpenID Connect authentication. If your pipeline is compromised, there are no static credentials to leak!&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;custom-domain-subdomain-setup-dev-ideategudy-tech-amp-ssl-configuration&quot;&gt;Custom Domain, Subdomain Setup (&lt;code&gt;dev.ideategudy.tech&lt;/code&gt;) &amp;amp; SSL Configuration&lt;/h3&gt;
&lt;p&gt;Primary infrastructure resources (VPC, EC2, ASG, ALB, ECR, S3) are deployed in &lt;code&gt;eu-north-1&lt;/code&gt; (Stockholm). HTTPS is terminated at the CloudFront CDN level using &lt;strong&gt;AWS Certificate Manager (ACM)&lt;/strong&gt; in &lt;code&gt;us-east-1&lt;/code&gt; (required by CloudFront for global edge distributions).&lt;/p&gt;
&lt;h4 id=&quot;why-use-a-subdomain-dev-ideategudy-tech-&quot;&gt;Why Use a Subdomain (&lt;code&gt;dev.ideategudy.tech&lt;/code&gt;)?&lt;/h4&gt;
&lt;p&gt;Instead of pointing the root/apex domain (&lt;code&gt;ideategudy.tech&lt;/code&gt;) directly to this environment, we configured a dedicated subdomain (&lt;code&gt;dev.ideategudy.tech&lt;/code&gt;) for several key reasons:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Environment Isolation (&lt;code&gt;dev&lt;/code&gt; vs &lt;code&gt;prod&lt;/code&gt;)&lt;/strong&gt;: Using subdomains allows us to run isolated development (&lt;code&gt;dev.ideategudy.tech&lt;/code&gt;) and production (&lt;code&gt;ideategudy.tech&lt;/code&gt; or &lt;code&gt;app.ideategudy.tech&lt;/code&gt;) infrastructure in separate Terraform workspaces without DNS conflicts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Subdomain NS Delegation in Namecheap&lt;/strong&gt;: By adding custom &lt;code&gt;NS&lt;/code&gt; records for &lt;code&gt;dev.ideategudy.tech&lt;/code&gt; in Namecheap, we delegate only the &lt;code&gt;dev&lt;/code&gt; subdomain DNS resolution to AWS Route 53 while keeping the parent apex domain (&lt;code&gt;ideategudy.tech&lt;/code&gt;) managed at Namecheap.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Multi-Environment Testing&lt;/strong&gt;: It enables continuous testing of AWS infrastructure changes (CloudFront, ALB, ASG) on a real HTTPS domain without risking downtime for main production domain traffic.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h4 id=&quot;connecting-namecheap-subdomain-dev-ideategudy-tech-to-aws-&quot;&gt;Connecting Namecheap Subdomain (&lt;code&gt;dev.ideategudy.tech&lt;/code&gt;) to AWS:&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;In &lt;code&gt;infra/dev.tfvars&lt;/code&gt;:&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;domain_name         = &quot;dev.ideategudy.tech&quot;
create_route53_zone = true
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;terraform apply --var-file=&quot;dev.tfvars&quot;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Copy the 4 output nameservers from Terraform:&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;route53_nameservers = [
  &quot;ns-1143.awsdns-14.org&quot;,
  &quot;ns-1698.awsdns-20.co.uk&quot;,
  &quot;ns-263.awsdns-32.com&quot;,
  &quot;ns-847.awsdns-41.net&quot;
]
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;Log into &lt;strong&gt;Namecheap&lt;/strong&gt; -&amp;gt; &lt;strong&gt;Domain List&lt;/strong&gt; -&amp;gt; &lt;strong&gt;Manage &lt;code&gt;ideategudy.tech&lt;/code&gt;&lt;/strong&gt; -&amp;gt; &lt;strong&gt;Advanced DNS&lt;/strong&gt; -&amp;gt; Add 4 &lt;code&gt;NS&lt;/code&gt; Records for Host &lt;code&gt;dev&lt;/code&gt; pointing to the Route 53 Nameservers.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/fep5bbfm8njx7m4dmlnt.png&quot; alt=&quot;Namecheap Subdomain NS Records Screenshot&quot;&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;em&gt;Figure: Configured NS records in Namecheap delegating &lt;code&gt;dev.ideategudy.tech&lt;/code&gt; to AWS Route 53.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ol&gt;
&lt;li&gt;In minutes, free auto-renewing SSL is active across &lt;code&gt;https://dev.ideategudy.tech&lt;/code&gt;!&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;-automated-ci-cd-pipeline-deploy-yml-&quot;&gt;⚡ Automated CI/CD Pipeline (&lt;code&gt;deploy.yml&lt;/code&gt;)&lt;/h3&gt;
&lt;p&gt;When code is pushed to &lt;code&gt;main&lt;/code&gt;, GitHub Actions triggers &lt;code&gt;.github/workflows/deploy.yml&lt;/code&gt; which executes &lt;strong&gt;two parallel jobs&lt;/strong&gt;:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt; &lt;strong&gt;Note for Beginners&lt;/strong&gt;: You do &lt;strong&gt;not&lt;/strong&gt; need to manually build the React frontend or Docker container on your local computer before pushing. GitHub Actions automatically compiles the React production bundle and builds/pushes the Docker image in the cloud during the pipeline run!&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;deploy-frontend&lt;/code&gt;&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Builds Vite/React bundle.&lt;/li&gt;
&lt;li&gt;Authenticates keylessly to AWS via OIDC.&lt;/li&gt;
&lt;li&gt;Syncs static assets to the private S3 bucket (&lt;code&gt;aws s3 sync&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Invalidates CloudFront edge cache (&lt;code&gt;aws cloudfront create-invalidation&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;deploy-backend&lt;/code&gt;&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Builds Docker image for Express API.&lt;/li&gt;
&lt;li&gt;Authenticates to Amazon ECR via OIDC.&lt;/li&gt;
&lt;li&gt;Pushes new Docker tag to ECR.&lt;/li&gt;
&lt;li&gt;Syncs runtime application secrets into AWS SSM Parameter Store (&lt;code&gt;/mern-deploy/dev/...&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Executes zero-downtime container updates across EC2 ASG instances via AWS SSM &lt;code&gt;RunShellScript&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-by-step-deployment-instructions&quot;&gt;Step-by-Step Deployment Instructions&lt;/h3&gt;
&lt;h4 id=&quot;step-1-provision-aws-infrastructure-with-terraform&quot;&gt;Step 1: Provision AWS Infrastructure with Terraform&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd infra

# Copy example variables
cp dev.tfvars.example dev.tfvars

# Edit values in dev.tfvars (domain_name, github_org, github_repo)
terraform init
terraform plan --var-file=&quot;dev.tfvars&quot;
terraform apply --var-file=&quot;dev.tfvars&quot; --auto-approve
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/vdfbgebapmertp5757p3.png&quot; alt=&quot;Terraform Apply Output Screenshot&quot;&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;em&gt;Figure 1: Successful &lt;code&gt;terraform apply&lt;/code&gt; showing outputs for S3, CloudFront, ECR, and ALB.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;step-2-configure-github-repository-secrets-amp-variables&quot;&gt;Step 2: Configure GitHub Repository Secrets &amp;amp; Variables&lt;/h4&gt;
&lt;p&gt;In your GitHub repo under &lt;strong&gt;Settings -&amp;gt; Secrets and variables -&amp;gt; Actions&lt;/strong&gt;:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt; &lt;em&gt;Tip: You can retrieve all your Terraform output values anytime by running &lt;code&gt;terraform output&lt;/code&gt; inside the &lt;code&gt;infra/&lt;/code&gt; folder.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/3awxepbh4lq8twyoypi6.png&quot; alt=&quot;GitHub Repository Actions Secrets and Variables&quot;&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;em&gt;Figure 2: GitHub Actions repository variables and secrets configured for OIDC deployment.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Secrets&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;MONGODB_URI&lt;/code&gt;: MongoDB connection string&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ACCESS_TOKEN_SECRET&lt;/code&gt;: JWT access secret&lt;/li&gt;
&lt;li&gt;&lt;code&gt;REFRESH_TOKEN_SECRET&lt;/code&gt;: JWT refresh secret&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Variables&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;AWS_ROLE_ARN&lt;/code&gt;: Output &lt;code&gt;github_actions_role_arn&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;AWS_REGION&lt;/code&gt;: &lt;code&gt;eu-north-1&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ECR_REPOSITORY&lt;/code&gt;: Output &lt;code&gt;ecr_repository_name&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;FRONTEND_BUCKET&lt;/code&gt;: Output &lt;code&gt;frontend_bucket&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CLOUDFRONT_DISTRIBUTION_ID&lt;/code&gt;: Output &lt;code&gt;cloudfront_distribution_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;API_ASG_NAME&lt;/code&gt;: Output &lt;code&gt;api_asg_name&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SSM_PREFIX&lt;/code&gt;: Output &lt;code&gt;ssm_prefix&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CLIENT_URL&lt;/code&gt;: &lt;code&gt;https://dev.ideategudy.tech&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SERVER_URL&lt;/code&gt;: &lt;code&gt;https://dev.ideategudy.tech&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;step-3-trigger-automated-build-amp-deployment&quot;&gt;Step 3: Trigger Automated Build &amp;amp; Deployment&lt;/h4&gt;
&lt;p&gt;Push your updates to the repository:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git add .
git commit -m &quot;Deploy production architecture to AWS&quot;
git push origin main
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/k0x3dw9t8mfadcza0nif.png&quot; alt=&quot;GitHub Actions Successful CI/CD Pipeline Run&quot;&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;em&gt;Figure 3: Parallel CI/CD execution for &lt;code&gt;deploy-frontend&lt;/code&gt; and &lt;code&gt;deploy-backend&lt;/code&gt; jobs.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;live-application-demo&quot;&gt;Live Application Demo&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/vs3zqjqztaanfeu1ajyt.png&quot; alt=&quot;Live Deployed Application Screenshot&quot;&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;em&gt;Figure 4: The live full-stack application running on &lt;code&gt;https://dev.ideategudy.tech&lt;/code&gt; served securely over CloudFront HTTPS.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;-key-benefits-amp-architectural-trade-offs&quot;&gt;⚡ Key Benefits &amp;amp; Architectural Trade-Offs&lt;/h3&gt;
&lt;h4 id=&quot;key-benefits&quot;&gt;Key Benefits&lt;/h4&gt;
&lt;p&gt;Choosing this cloud setup over monolithic single-server hosting provides several high-value production advantages:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt; Zero-Downtime &amp;amp; Ultra-Fast Global Delivery&lt;/strong&gt;: &lt;br&gt;
By hosting the React SPA on &lt;strong&gt;Amazon S3 + CloudFront&lt;/strong&gt;, static assets are cached globally at edge locations close to users. CloudFront delivers lightning-fast page loads while shielding your backend EC2 instances from static file requests.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt; Extreme Cost Optimization (Free Tier Friendly)&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;S3 &amp;amp; CloudFront&lt;/strong&gt;: Nearly zero cost for static SPA bandwidth.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;EC2 Auto Scaling&lt;/strong&gt;: Scales t3.micro instances based on CPU utilization and traffic demands without paying for oversized idle instances.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Amazon ECR Lifecycle Policies&lt;/strong&gt;: Configured to auto-expire old image tags, keeping container storage well under free-tier limits.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt; Enterprise-Grade Keyless Security (OIDC)&lt;/strong&gt;:&lt;br&gt;
By avoiding permanent &lt;code&gt;AWS_ACCESS_KEY_ID&lt;/code&gt; and &lt;code&gt;AWS_SECRET_ACCESS_KEY&lt;/code&gt; credentials in GitHub Secrets, your deployment security posture is greatly hardened. GitHub Actions requests temporary, short-lived IAM credentials via OIDC scoped strictly to the resources it needs.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;️ High Availability &amp;amp; Auto-Healing&lt;/strong&gt;:&lt;br&gt;
The &lt;strong&gt;Application Load Balancer (ALB)&lt;/strong&gt; and &lt;strong&gt;Auto Scaling Group (ASG)&lt;/strong&gt; automatically monitor EC2 instance health. If an instance crashes or fails health checks, ASG automatically terminates it and launches a fresh, containerized replacement instance seamlessly.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt; Modular &amp;amp; Multi-Environment Ready&lt;/strong&gt;:&lt;br&gt;
The modular Terraform layout (&lt;code&gt;vpc&lt;/code&gt;, &lt;code&gt;backend&lt;/code&gt;, &lt;code&gt;frontend&lt;/code&gt;, &lt;code&gt;github_oidc&lt;/code&gt;) makes it effortless to spin up separate &lt;code&gt;dev&lt;/code&gt;, &lt;code&gt;staging&lt;/code&gt;, and &lt;code&gt;production&lt;/code&gt; environments in minutes with zero manual AWS Console clicking.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h4 id=&quot;-architectural-trade-offs-amp-considerations&quot;&gt;⚠️ Architectural Trade-Offs &amp;amp; Considerations&lt;/h4&gt;
&lt;p&gt;No cloud architecture is silver bullet. Understanding the trade-offs helps you decide when to choose this pattern over alternatives:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Initial Setup Complexity vs. Single-Server EC2&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;em&gt;Trade-off&lt;/em&gt;: Setting up S3, CloudFront OAC, ALB, Target Groups, ACM SSL, and OIDC requires more initial Terraform code than launching a single EC2 instance running Docker Compose + Nginx.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Mitigation&lt;/em&gt;: The modular Terraform structure (&lt;code&gt;modules/&lt;/code&gt;) abstracts away this complexity so it only needs to be written once.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cold Start / Provisioning Time for New EC2 Instances&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;em&gt;Trade-off&lt;/em&gt;: When ASG scales up a new instance, the user-data script installs Docker, pulls the image from ECR, and fetches SSM parameters before serving traffic (~2–3 minutes).&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Mitigation&lt;/em&gt;: Setting &lt;code&gt;api_min_size = 1&lt;/code&gt; ensures at least one instance is always warm and ready. For even faster scaling, custom pre-built AMIs (via Packer) can be used.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;CloudFront Cache Invalidation Lag&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;em&gt;Trade-off&lt;/em&gt;: Updating the React SPA requires invalidating CloudFront caches (&lt;code&gt;/*&lt;/code&gt;), which takes ~30–60 seconds to propagate across all edge locations worldwide.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Mitigation&lt;/em&gt;: Automated in the CI/CD pipeline (&lt;code&gt;aws cloudfront create-invalidation&lt;/code&gt;) so developers don't have to trigger it manually.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Stateful Session Management (Sticky Sessions)&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;em&gt;Trade-off&lt;/em&gt;: Because requests are load-balanced across multiple EC2 instances by the ALB, the backend API must remain &lt;strong&gt;stateless&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Mitigation&lt;/em&gt;: Authentication is handled using stateless &lt;strong&gt;JWT tokens&lt;/strong&gt; and external &lt;strong&gt;MongoDB&lt;/strong&gt; storage rather than in-memory server sessions.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;Building cloud infrastructure using modular Terraform modules and automated keyless CI/CD pipelines transforms complex AWS operations into predictable, repeatable, and maintainable software engineering workflows.&lt;/p&gt;
&lt;p&gt;By decoupling the architecture into public VPC subnets, S3/CloudFront SPA hosting, and auto-scaling EC2 container fleets, the application stays fast, cost-optimized, and resilient.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;related-documentation-amp-alternative-architecture&quot;&gt;Related Documentation &amp;amp; Alternative Architecture&lt;/h3&gt;
&lt;p&gt;Looking for a simpler, single-server EC2 deployment pattern? Check out the companion article:&lt;/p&gt;
&lt;p&gt; &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/ideategudy/how-i-built-and-deployed-a-full-stack-mern-app-on-aws-ec2-using-docker-compose-terraform-and-23k8&quot;&gt;How I Built and Deployed a Full-Stack MERN App on AWS EC2 using Docker Compose, Terraform, and GitHub Actions&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;em&gt;This guide covers deploying the full-stack MERN application on a single AWS EC2 instance using Docker Compose, Nginx reverse proxy, Certbot (Let's Encrypt SSL), and SSH-based GitHub Actions CI/CD.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;connect-amp-follow-me&quot;&gt;Connect &amp;amp; Follow Me&lt;/h3&gt;
&lt;p&gt;If you found this guide helpful or have any questions about AWS, Terraform, Docker, or MERN stack architecture, let us connect!&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ideateGudy&quot;&gt;github.com/ideateGudy&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LinkedIn&lt;/strong&gt;: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/in/ideategudy/&quot;&gt;linkedin.com/in/ideategudy&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;️ &lt;strong&gt;Source Code&lt;/strong&gt;: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ideateGudy/terraform-docker-compose-ci-cd-mern-aws-deploy&quot;&gt;github.com/ideateGudy/terraform-docker-compose-ci-cd-mern-aws-deploy&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Drop a reaction on Dev.to and feel free to star the GitHub repository! Happy coding!&lt;/em&gt; &lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30642/built-deployed-production-ready-application-modular-terraform-keyless-github-actions</guid>
<pubDate>Sat, 10 Oct 2026 20:34:17 +0000</pubDate>
</item>
<item>
<title>A Prompt-Injection Firewall for AWS: What I'd Build, and What I Measured</title>
<link>https://coderlegion.com/30639/a-prompt-injection-firewall-for-aws-what-id-build-and-what-i-measured</link>
<description>&lt;h3 id=&quot;-a-prompt-injection-firewall-for-aws-what-i-d-build-and-what-i-measured&quot;&gt;🛡️ A Prompt-Injection Firewall for AWS: What I'd Build, and What I Measured&lt;/h3&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Prompt injection is OWASP's #1 LLM risk across every edition, and a 2026 proof shows pure input-wrapper defenses face a hard trilemma: they cannot be continuous, utility-preserving, and complete at the same time.&lt;/li&gt;
&lt;li&gt;The space is no longer empty. NeuralTrust ships a commercial &quot;Generative Application Firewall,&quot; Check Point acquired Lakera, Cloudflare has Firewall for AI, and open-source projects exist. The honest gap is a cloud-native, AWS-reference, agent-aware design, not &quot;nobody has done this.&quot;&lt;/li&gt;
&lt;li&gt;My proposal: a multi-layer ensemble with a weighted decision engine, output and tool-call enforcement, and a feedback loop, built the trilemma into the design rather than pretending to beat it.&lt;/li&gt;
&lt;li&gt;I built the offline core (heuristic + vector detectors + decision engine) and benchmarked it on 285 labeled prompts. The measured trade-off is stark: at the threshold that catches 92% of attacks, 25% of legitimate prompts get flagged; tighten it until false positives are near zero and detection falls to 39%. No setting gives both. That is the trilemma, measured, and the argument for the semantic layer.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Prompt injection has sat at the top of the OWASP Top 10 for LLM Applications since the list began in 2023, and it is still number one in the 2025 and 2026 editions. That is unusual. Most vulnerability classes get a real fix and slide down the list. SQL injection had parameterized queries. XSS had output encoding and CSP. Prompt injection has no equivalent, because the flaw is architectural: an LLM reads the system prompt, the user's input, and any retrieved documents or tool output as one undifferentiated stream of text, with no built-in boundary between trusted instructions and untrusted data.&lt;/p&gt;
&lt;p&gt;I work on LLM systems on AWS, and this is the problem I keep coming back to. This post is not a product announcement. It is the design I would build if I were building a prompt-injection firewall today, grounded in what the research actually says and an honest account of what already exists. I will be clear throughout about which parts are established fact and which are my proposed approach.&lt;/p&gt;
&lt;h3 id=&quot;why-this-problem-resists-a-clean-fix&quot;&gt;Why this problem resists a clean fix&lt;/h3&gt;
&lt;p&gt;A 2026 paper, &lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2604.06436&quot;&gt;&quot;Why Prompt Injection Defense Wrappers Fail?&quot;&lt;/a&gt;, formalizes something practitioners had felt for a while. It argues that a defense that wraps the input faces a trilemma of three properties that cannot all hold at once:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Continuity&lt;/strong&gt; — a small change to the input should not flip the defense decision.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Utility preservation&lt;/strong&gt; — legitimate prompts must still get through and work.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Completeness&lt;/strong&gt; — every injection attempt must be caught.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You can get two. Not three. The paper is explicit that this bounds &lt;em&gt;input-wrapper&lt;/em&gt; style defenses specifically, and that it does not rule out training-time alignment, architectural changes, or defenses that deliberately trade away some utility. That nuance matters, and I will come back to it, because it is the single most important design constraint.&lt;/p&gt;
&lt;p&gt;The practical consequence is that any honest firewall should stop promising completeness. It should instead be explicit about where on the continuity/utility/completeness surface it chooses to sit, and make that choice configurable.&lt;/p&gt;
&lt;h3 id=&quot;what-already-exists-the-part-the-hype-usually-skips-&quot;&gt;What already exists (the part the hype usually skips)&lt;/h3&gt;
&lt;p&gt;When I first sketched this idea, my note to myself said &quot;no one has built this.&quot; That was wrong, and it is worth correcting in public, because anyone who works in LLM security will know the landscape.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Lakera Guard&lt;/strong&gt; — commercial, API-first prompt-injection and jailbreak detection. &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.csoonline.com/article/4058653/check-point-acquires-lakera-to-build-a-unified-ai-security-stack.html&quot;&gt;Check Point acquired Lakera in September 2025&lt;/a&gt; and folded it into its platform. Its headline accuracy and sub-50ms latency are the vendor's own figures, not something I measured.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NeuralTrust&lt;/strong&gt; — ships a commercial product it literally calls a Generative Application Firewall, an inline enforcement layer for LLM apps and agents. So the &quot;GAF&quot; concept is not paper-only; it is a shipping product.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cloudflare Firewall for AI&lt;/strong&gt; — a commercial layer that blocks unsafe prompts and addresses several OWASP LLM risks at the edge.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Open-source&lt;/strong&gt; — several GitHub projects named &lt;code&gt;prompt-shield&lt;/code&gt; (and the one-word &lt;code&gt;promptshield&lt;/code&gt;) implement detector ensembles, reverse-proxy enforcement, and threat scoring. &lt;strong&gt;LLM Guard&lt;/strong&gt; is an active rule-based scanner set, and &lt;strong&gt;NeMo Guardrails&lt;/strong&gt; (NVIDIA) handles conversation-flow control. &lt;strong&gt;Rebuff&lt;/strong&gt;, an earlier multi-layer project, is archived.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &quot;GAF&quot; framing itself comes from a 2026 paper, &lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2601.15824&quot;&gt;&quot;Introducing the Generative Application Firewall (GAF)&quot;&lt;/a&gt;, which proposes a single enforcement point that unifies prompt filters, output validators, and data masking, maintains session context, and covers agents and their tool calls, by analogy to a WAF for web traffic.&lt;/p&gt;
&lt;p&gt;So the honest gap is narrower and more specific than &quot;nobody has built a firewall.&quot; It is: an open, cloud-native &lt;strong&gt;reference architecture on AWS primitives&lt;/strong&gt; that is agent-aware (intercepts tool calls, not just prompts), uses a multi-detector ensemble rather than a single technique, and is explicit about the trilemma trade-off it makes. That is a real and useful gap. It is not a claim of inventing the category.&lt;/p&gt;
&lt;h3 id=&quot;the-architecture-i-d-build&quot;&gt;The architecture I'd build&lt;/h3&gt;
&lt;p&gt;The design is a pipeline: detect, decide, enforce, learn. Each stage maps cleanly onto AWS primitives, which is the point, it should be deployable without inventing new infrastructure.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;          ┌───────────────────────────────────────────────┐
 request  │  1. DETECTION  (ensemble, run in parallel)      │
 ───────► │   heuristic · ML classifier · LLM-as-judge      │
          │   vector similarity to known attacks            │
          └───────────────────────┬───────────────────────┘
                                   ▼
          ┌───────────────────────────────────────────────┐
          │  2. DECISION  (weighted vote + session context) │
          │   configurable threshold → the trilemma dial    │
          └───────────────────────┬───────────────────────┘
                                   ▼
          ┌───────────────────────────────────────────────┐
          │  3. ENFORCE   allow / block / sanitize / review │
          │   also: scan the OUTPUT, gate TOOL CALLS         │
          └───────────────────────┬───────────────────────┘
                                   ▼
          ┌───────────────────────────────────────────────┐
          │  4. LEARN   log verdicts, feed back FPs,         │
          │   grow the attack-vector store                   │
          └───────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On AWS, I'd map it like this: API Gateway as the entry point; the detector ensemble as Lambda functions, with the ML classifier on SageMaker and the LLM-as-judge on Bedrock; the attack-vector store on OpenSearch; the decision engine as a small Step Functions or Lambda orchestration reading session history from DynamoDB; and CloudWatch plus S3 for the audit trail. None of that is exotic, which is deliberate.&lt;/p&gt;
&lt;h4 id=&quot;why-an-ensemble-and-what-each-layer-is-actually-good-at&quot;&gt;Why an ensemble, and what each layer is actually good at&lt;/h4&gt;
&lt;p&gt;No single detector is enough, and the reasons are well documented. The &lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2608.27990v1&quot;&gt;CAITLYN paper&lt;/a&gt; puts the trade-off bluntly: deterministic signature and regex filters run at sub-millisecond speed with zero token cost but fall to trivial obfuscation and semantic rewrites. So the ensemble is not padding; each layer covers a different failure mode of the others.&lt;/p&gt;
&lt;p&gt;A heuristic layer is the cheap first pass. Illustrative, not a complete ruleset:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;SUSPICIOUS = [
    r&quot;ignore (all )?previous instructions&quot;,
    r&quot;disregard the (system|above) prompt&quot;,
    r&quot;you are now [a-z ]+&quot;,          # role-override attempts
    r&quot;reveal (your )?(system )?prompt&quot;,
]

def heuristic_score(text: str) -&amp;gt; float:
    hits = sum(bool(re.search(p, text, re.I)) for p in SUSPICIOUS)
    return min(hits / 2, 1.0)   # saturate; this is a signal, not a verdict
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That catches the lazy attacks for free and catches nothing clever, which is exactly why it only contributes a signal. A semantic layer (an ML classifier, or an LLM-as-judge on Bedrock) catches the paraphrased and indirect attacks the regex misses, at real token cost and latency. A vector-similarity layer compares the input against an embedding store of known attack patterns, so a variant of something you have seen scores high even if the wording is new.&lt;/p&gt;
&lt;h4 id=&quot;the-decision-engine-is-where-the-trilemma-becomes-a-dial&quot;&gt;The decision engine is where the trilemma becomes a dial&lt;/h4&gt;
&lt;p&gt;This is the heart of the honest version. Instead of a single detector shouting &quot;block,&quot; the engine combines the signals into one score and compares it to a threshold you set per tenant or per use case:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;WEIGHTS = {&quot;heuristic&quot;: 0.15, &quot;classifier&quot;: 0.35, &quot;judge&quot;: 0.35, &quot;vector&quot;: 0.15}

def decide(signals: dict[str, float], threshold: float) -&amp;gt; str:
    score = sum(WEIGHTS[k] * v for k, v in signals.items())
    if score &amp;gt;= threshold:            return &quot;block&quot;
    if score &amp;gt;= threshold * 0.6:      return &quot;flag_for_review&quot;
    return &quot;allow&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The threshold &lt;em&gt;is&lt;/em&gt; the trilemma choice, made explicit. A bank's internal agent can run it high and tolerate more false positives, because a missed injection is expensive. A low-stakes public chatbot runs it lower to protect utility. The firewall does not pretend to be complete; it hands you the knob and logs where you set it. That framing is the design taking the arXiv result seriously instead of marketing past it.&lt;/p&gt;
&lt;p&gt;The weights above show the full four-detector design. The benchmark in the next section only exercises the two detectors I actually built offline (heuristic and vector), so it runs with two weights rather than four. The semantic detectors in this snippet are the layer I have not built yet.&lt;/p&gt;
&lt;h3 id=&quot;i-built-the-offline-core-and-measured-it&quot;&gt;I built the offline core and measured it&lt;/h3&gt;
&lt;p&gt;Design claims are cheap, so I implemented the two offline layers (the heuristic detector and the vector-similarity detector) plus the decision engine, and ran them against a labeled corpus of 285 prompts: 192 injection and 93 benign. The injection set is deliberately &lt;em&gt;held out&lt;/em&gt;, paraphrases, obfuscations (spaced-out and hyphenated triggers), and indirect-via-document payloads that are &lt;strong&gt;not&lt;/strong&gt; the examples the vector detector was fitted on, so the catch rate reflects generalisation rather than memorisation. The benign set includes hard negatives that mention &quot;instructions,&quot; &quot;system prompt,&quot; &quot;override,&quot; and &quot;ignore&quot; innocently. Everything is pure standard-library Python with a fixed seed, so the run is deterministic and reproducible.&lt;/p&gt;
&lt;p&gt;Here is the actual output, sweeping the decision threshold (weights: heuristic 0.4, vector 0.6):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; thresh  detect%    FP%  precision     F1
--------------------------------------------
   0.15      98%    52%       0.80   0.88
   0.18      92%    25%       0.88   0.90
   0.20      84%    14%       0.93   0.88
   0.25      60%     8%       0.94   0.74
   0.30      39%     1%       0.99   0.55
   0.40      10%     0%       1.00   0.18
   0.60       5%     0%       1.00   0.09
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Read that top to bottom and the trilemma stops being an abstraction. At a threshold of 0.18 the firewall catches &lt;strong&gt;92%&lt;/strong&gt; of the injections, but it also flags &lt;strong&gt;a quarter of legitimate prompts&lt;/strong&gt;, the hard benign negatives about writing system prompts and documenting team instructions get swept up. Tighten the threshold to 0.30 and false positives drop to &lt;strong&gt;1%&lt;/strong&gt; (precision 0.99), but detection collapses to &lt;strong&gt;39%&lt;/strong&gt;: most attacks walk straight through. There is no row where detection is high and false positives are low at the same time. The best F1 (0.90) sits near the aggressive end, where you have already accepted a real false-positive cost.&lt;/p&gt;
&lt;p&gt;That is not a tuning failure I can polish away with better weights. It is the shape the 2604.06436 result predicts for this class of defense, and watching it fall out of real numbers is more convincing than any single headline accuracy figure. The honest reading is specific: a cheap offline ensemble reliably catches the obvious and the lexically similar, and genuinely struggles to separate a &lt;em&gt;subtle paraphrase&lt;/em&gt; of an attack from a &lt;em&gt;legitimate request that happens to talk about prompts&lt;/em&gt;. That overlap is exactly the region a semantic layer, an ML classifier or an LLM-as-judge on Bedrock, is supposed to resolve, and it is why I treat that layer as the next build step rather than a nice-to-have.&lt;/p&gt;
&lt;p&gt;A note on honesty, because it is the whole point of this post: the corpus is synthetic and template-generated, so its diversity is bounded by the templates; the numbers are operational rather than a formal benchmark against published baselines; and the implementation is not open-sourced. I would not quote these figures without having run the code, and I would not dress them up as production-grade. They are a measured illustration of a trade-off, nothing more and nothing less.&lt;/p&gt;
&lt;h4 id=&quot;the-two-things-that-make-it-agent-aware&quot;&gt;The two things that make it agent-aware&lt;/h4&gt;
&lt;p&gt;Most of the field still treats this as &quot;scan the user's prompt.&quot; For modern agentic systems that is half the surface. Two additions matter:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Output scanning.&lt;/strong&gt; The model's response can carry an injected instruction or leak a system prompt. Scanning the output, not just the input, is where several designs (including NeuralTrust's and the GAF paper's) converge, and I'd treat it as mandatory rather than optional.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tool-call gating.&lt;/strong&gt; This is the one I care most about, and where indirect injection lives. When an agent decides to call a tool, that decision can be the payload: a poisoned document retrieved via RAG tells the agent to call &lt;code&gt;send_email&lt;/code&gt; or &lt;code&gt;delete_record&lt;/code&gt;. Gating the tool call, checking the proposed action and arguments against policy before execution, is a different and arguably more important checkpoint than filtering the prompt:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def gate_tool_call(tool: str, args: dict, policy) -&amp;gt; str:
    if tool in policy.denied:                       return &quot;block&quot;
    if tool in policy.requires_human and args_risky(args, policy):
        return &quot;human_review&quot;
    return &quot;allow&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2502.01822&quot;&gt;&quot;Firewalls to Secure Dynamic LLM Agentic Networks&quot;&lt;/a&gt; work takes this further, projecting cross-agent messages onto a structured, task-scoped protocol so that persuasive framing and embedded instructions are stripped by construction. That is a stronger idea than scoring, and it is the direction I would want the tool-call layer to grow toward.&lt;/p&gt;
&lt;h3 id=&quot;honest-limitations-up-front&quot;&gt;Honest limitations, up front&lt;/h3&gt;
&lt;p&gt;Because a firewall that oversells itself is worse than none:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;It &lt;strong&gt;cannot be complete.&lt;/strong&gt; The 2604.06436 result makes that a theorem for input-wrapper defenses, not a matter of effort. Anyone claiming 100% is wrong or redefining the problem.&lt;/li&gt;
&lt;li&gt;The semantic layers &lt;strong&gt;cost latency and tokens.&lt;/strong&gt; The sub-50ms figures you see are vendor numbers for narrower, single-layer detectors; a multi-layer ensemble with an LLM-judge step will cost more, and I would not quote a latency number I had not measured.&lt;/li&gt;
&lt;li&gt;It is &lt;strong&gt;defense in depth, not a boundary.&lt;/strong&gt; The durable lesson from the evaluation literature is that security boundaries belong in application code and permissions. A firewall lowers the hit rate; least-privilege tool scoping and human approval on destructive actions are what contain the ones that get through.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;on-the-compliance-angle&quot;&gt;On the compliance angle&lt;/h3&gt;
&lt;p&gt;It is tempting to attach urgency to regulation, so here is the accurate version. The EU AI Act's &lt;strong&gt;high-risk&lt;/strong&gt; obligations were pushed back by the 2026 Digital Omnibus to December 2027 and August 2028, not August 2026 as early drafts implied. What did take effect on August 2, 2026 is the transparency regime (Article 50) and penalty powers for general-purpose models. So the regulatory pressure is real but phased, and the honest pitch is that security controls for LLM apps are becoming table stakes over the next two years, not that a deadline hits tomorrow.&lt;/p&gt;
&lt;h3 id=&quot;the-path-forward&quot;&gt;The path forward&lt;/h3&gt;
&lt;p&gt;The offline core and the trade-off measurement above are done. From here the order would be:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Add the semantic layer&lt;/strong&gt; (an ML classifier, or an LLM-as-judge on Bedrock) specifically to attack the overlap region the offline ensemble could not separate, and re-measure whether it buys real detection without pushing false positives back up. That is the hypothesis the current numbers set up.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Harden output scanning and tool-call gating&lt;/strong&gt;, and benchmark against indirect-injection scenarios (poisoned RAG content, malicious tool results), since that is the underserved part.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Publish it as an AWS reference architecture&lt;/strong&gt; with the numbers attached, honest about where it sits relative to NeuralTrust, Lakera, and the OSS projects, rather than claiming to replace them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Close the loop&lt;/strong&gt;: feed false positives back to retune thresholds and grow the attack-vector store, and treat that feedback data as the real moat over time.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That is a build I would stand behind, because every claim in it is either cited or measured. The gap it fills is specific: an open, agent-aware, AWS-native reference design that treats the defense trilemma as a dial instead of a thing to lie about.&lt;/p&gt;
&lt;p&gt;If you are working on LLM security, I'd genuinely like to hear which layer you think earns its keep, and whether tool-call gating or output scanning has mattered more in your own systems.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;sources&quot;&gt;Sources&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;OWASP Top 10 for LLM Applications (2025, 2026) — prompt injection ranked #1 across editions. &lt;a rel=&quot;nofollow&quot; href=&quot;https://genai.owasp.org/llm-top-10/&quot;&gt;https://genai.owasp.org/llm-top-10/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;arXiv 2604.06436 — &quot;Why Prompt Injection Defense Wrappers Fail?&quot; (the defense trilemma)&lt;/li&gt;
&lt;li&gt;arXiv 2601.15824 — &quot;Introducing the Generative Application Firewall (GAF)&quot;&lt;/li&gt;
&lt;li&gt;arXiv 2608.27990 — &quot;CAITLYN: Can LLM Agents Autonomously Synthesize Defenses against Emerging Injection Attacks?&quot;&lt;/li&gt;
&lt;li&gt;arXiv 2502.01822 — &quot;Firewalls to Secure Dynamic LLM Agentic Networks&quot;&lt;/li&gt;
&lt;li&gt;Check Point acquires Lakera (Sept 2025) — csoonline.com&lt;/li&gt;
&lt;li&gt;NeuralTrust Generative Application Firewall; Cloudflare Firewall for AI (vendor sources)&lt;/li&gt;
&lt;li&gt;EU AI Act timeline after the 2026 Digital Omnibus (high-risk obligations deferred to Dec 2027 / Aug 2028)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;The benchmark figures come from my own offline reference implementation (pure standard-library Python: regex heuristic + character tri-gram cosine detectors + a weighted decision engine), run against a synthetic, template-generated labeled corpus of 192 injection and 93 benign prompts (285 total), with seed attacks held disjoint from the test set. The implementation is not open-sourced; the numbers are operational, not a formal benchmark.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30639/a-prompt-injection-firewall-for-aws-what-id-build-and-what-i-measured</guid>
<pubDate>Sat, 10 Oct 2026 20:30:16 +0000</pubDate>
</item>
<item>
<title>Give Your Document OCR Pipeline a Memory</title>
<link>https://coderlegion.com/30618/give-your-document-ocr-pipeline-a-memory</link>
<description>&lt;p&gt;After OCR, most pipelines throw the layout away and re-discover the same form from scratch every time. &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/aws-samples/sample-textract-field-memory&quot;&gt;&lt;code&gt;textract-field-memory&lt;/code&gt;&lt;/a&gt; is a small, zero-dependency Python layer that remembers where fields sit, checks they landed in the right place, tells you which layout a document is, and warns you when a form quietly changes. It doesn't do OCR. It makes the OCR you already run a lot more useful.&lt;/p&gt;
&lt;h3 id=&quot;where-this-started&quot;&gt;Where this started&lt;/h3&gt;
&lt;p&gt;I kept running into the same annoying gap on document pipelines. You send a form through Textract (or Tesseract, or whatever), you get back field names and bounding boxes, you pull the values, and then you throw all the positional information away. The next copy of that exact same form shows up an hour later and the pipeline treats it like it's never seen anything like it before.&lt;/p&gt;
&lt;p&gt;That bugged me for a few reasons.&lt;/p&gt;
&lt;p&gt;Nothing checks that a field came from the right spot on the page. If &quot;Total&quot; gets read out of the shipping box instead of the amount box, the value still looks like a number, so it sails downstream and nobody blinks.&lt;/p&gt;
&lt;p&gt;Nothing notices when a form changes. A vendor nudges their invoice template, fields move a centimeter, and you find out three weeks later when a downstream job starts choking on garbage.&lt;/p&gt;
&lt;p&gt;And there's no cheap way to ask &quot;have I seen this layout before?&quot; So every document, familiar or not, takes the same expensive road.&lt;/p&gt;
&lt;p&gt;So I wrote a little library that sits &lt;em&gt;after&lt;/em&gt; OCR and keeps the memory the pipeline was throwing out.&lt;/p&gt;
&lt;h3 id=&quot;what-it-is-and-what-it-definitely-isn-t&quot;&gt;What it is, and what it definitely isn't&lt;/h3&gt;
&lt;p&gt;It's a spatial memory layer. You hand it field names plus normalized bounding boxes (x, y, width, height between 0 and 1). It learns roughly where each field tends to appear on a given template, and from then on it can validate positions, identify which template a document belongs to, and watch for drift.&lt;/p&gt;
&lt;p&gt;It is not an OCR engine and it never touches pixels. Something upstream has to extract the fields first. This library just remembers where they ended up and reasons about that.&lt;/p&gt;
&lt;p&gt;Under the hood there's nothing exotic going on, which I think is a feature. Spatial scoring is intersection-over-union plus the distance between box centers. Name matching is a normalized Levenshtein distance. Template identification is Jaccard overlap of the field-name sets combined with the spatial score. No model, no training step, no network calls. It's pure standard-library Python with zero dependencies, and templates are just JSON files on disk.&lt;/p&gt;
&lt;h3 id=&quot;a-quick-look-at-the-api&quot;&gt;A quick look at the API&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from field_memory import TemplateMemory
memory = TemplateMemory(store_path=&quot;./my_templates&quot;)
# 1. Learn — feed documents after OCR extraction
memory.record(document, template_id=&quot;employment-form&quot;)
# 2. Locate — spatial field lookup with a confidence score
matches = memory.locate(new_doc, &quot;Employee Name&quot;)
# → FieldMatch(combined_score=0.95, spatial_score=0.93, within_expected_region=True)
# 3. Identify — which known layout is this?
match = memory.identify_template(new_doc)
# → TemplateMatch(template_id=&quot;employment-form&quot;, similarity_score=0.87)
# 4. Detect drift — has the layout shifted?
drift = memory.detect_drift(new_doc, &quot;employment-form&quot;)
# → DriftReport(is_drifting=False, overall_drift_score=0.02)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Clustering comes for free. If you call &lt;code&gt;record()&lt;/code&gt; and leave out the &lt;code&gt;template_id&lt;/code&gt;, it either merges the document into a template it recognizes or spins up a new one. Run that for a while and you end up with a map of every distinct layout flowing through your pipeline without ever configuring one by hand.&lt;/p&gt;
&lt;h3 id=&quot;why-there-s-no-llm-inside&quot;&gt;Why there's no LLM inside&lt;/h3&gt;
&lt;p&gt;For a layout you've already seen, answering &quot;where's this field?&quot; is basically a dictionary lookup. Sub-millisecond, and free. The same question routed through an LLM is half a second to two seconds and costs you per field. Paying that toll on forms you've processed a thousand times is silly.&lt;/p&gt;
&lt;p&gt;So the design leans into being complementary instead of competitive:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def smart_extract(document, field_name):
    matches = memory.locate(document, field_name)
    if matches and matches[0].within_expected_region and matches[0].combined_score &amp;gt;= 0.85:
        return matches[0].key_value, &quot;spatial&quot;   # free, instant
    return call_llm(document, field_name), &quot;llm&quot;  # pay only when needed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Think of it as the cheap first pass that decides whether you even need the expensive one. The 0.85 threshold is just a starting point. Crank it up if a wrong answer is costly and you'd rather fall back to the LLM more often.&lt;/p&gt;
&lt;h3 id=&quot;where-it-actually-helps&quot;&gt;Where it actually helps&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Domain &lt;/th&gt;&lt;th&gt;What it remembers &lt;/th&gt;&lt;th&gt;What it catches&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Invoice processing &lt;/td&gt;&lt;td&gt;Where fields sit per vendor layout &lt;/td&gt;&lt;td&gt;Vendor template changes, wrong-position reads&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Insurance claims &lt;/td&gt;&lt;td&gt;Field positions across many form types &lt;/td&gt;&lt;td&gt;Unknown forms that need routing for review&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Healthcare intake &lt;/td&gt;&lt;td&gt;Where fields land on clinic forms &lt;/td&gt;&lt;td&gt;Layout revisions before they break downstream&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Manufacturing QA &lt;/td&gt;&lt;td&gt;Component positions on boards &lt;/td&gt;&lt;td&gt;Parts that shifted from baseline&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The only real requirement is that your data has named things with bounding boxes. That's it.&lt;/p&gt;
&lt;h3 id=&quot;some-numbers-and-the-caveats-that-go-with-them&quot;&gt;Some numbers, and the caveats that go with them&lt;/h3&gt;
&lt;p&gt;I put it through a stress test using real Textract (FORMS) on generated PDFs plus scanned, deliberately degraded versions of the same documents.&lt;/p&gt;
&lt;p&gt;Layout identification came out at 100% across five vendors whose invoices look nothing alike, trained on just three documents each. On dense tax returns with 33 fields crammed into a tight three-column grid, roughly 0.03 units apart, it located every field. Drift detection behaved the way I hoped: as I pushed forms further off their baseline, the drift scores climbed smoothly from 0.012 up to 0.047 and tripped the flag once they crossed the threshold. On injected anomalies (shifted, scattered, reversed, wrong-form) it caught four of five. And because it's all local computation, lookups stay under a millisecond.&lt;/p&gt;
&lt;p&gt;Now the honest part.&lt;/p&gt;
&lt;p&gt;The benchmark runs on synthetic PDFs generated with ReportLab and their scanned variants, not a big real-world labeled corpus. Sample sizes are small, around 20 forms per scenario.&lt;/p&gt;
&lt;p&gt;There's no proper precision/recall/F1 yet because I don't have a ground-truth label set built. The numbers above are operational rather than a formal comparison against baselines, and I'd treat them that way.&lt;/p&gt;
&lt;p&gt;The one anomaly it missed was the &quot;same layout, just fewer fields&quot; case. The fields that remain are still sitting in the right places, so spatially nothing looks wrong. You could argue that's correct behavior, but it's worth knowing going in.&lt;/p&gt;
&lt;p&gt;Handwritten forms are a dead end, because Textract FORMS gives back nothing to learn from. That's an upstream limitation, not something this library can paper over.&lt;/p&gt;
&lt;p&gt;I'd rather you hit these on the page than in production.&lt;/p&gt;
&lt;h3 id=&quot;try-it&quot;&gt;Try it&lt;/h3&gt;
&lt;p&gt;Clone it and install from source. This works right now, nothing to sign up for:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone https://github.com/aws-samples/sample-textract-field-memory
cd sample-textract-field-memory
pip install -e .
python examples/demo.py   # runs on synthetic data, no AWS needed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;demo.py&lt;/code&gt; walks through template learning, spatial matching, anomaly detection, and drift analysis on synthetic data, so you can watch the whole thing work without credentials or config.&lt;/p&gt;
&lt;p&gt;Repo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/aws-samples/sample-textract-field-memory&quot;&gt;github.com/aws-samples/sample-textract-field-memory&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;It lives under &lt;code&gt;aws-samples&lt;/code&gt; but there's no AWS dependency in it. Pure standard-library Python, works completely offline with any OCR source that gives you field names and boxes. MIT-0 licensed.&lt;/p&gt;
&lt;p&gt;If you run document pipelines, the thing I'd most like feedback on is turning these stress tests into a real labeled benchmark. And if the &quot;remember it, stop re-discovering it&quot; idea resonates, you've got the whole pitch.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30618/give-your-document-ocr-pipeline-a-memory</guid>
<pubDate>Sat, 10 Oct 2026 16:20:15 +0000</pubDate>
</item>
<item>
<title>Your LLM Keeps Making the Same Extraction Mistake. Here's How to Make It Learn.</title>
<link>https://coderlegion.com/30620/your-llm-keeps-making-the-same-extraction-mistake-heres-how-to-make-it-learn</link>
<description>&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — I open-sourced an AWS sample that makes LLM document extraction &lt;em&gt;learn from human corrections&lt;/em&gt;. Each QA fix is captured once and reused, so the pipeline gets &lt;strong&gt;more accurate and cheaper over time&lt;/strong&gt; — with no retraining and no redeployment. Repo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/aws-samples/sample-prompt-correction-memory&quot;&gt;github.com/aws-samples/sample-prompt-correction-memory&lt;/a&gt;. You can run the whole self-healing loop locally in 30 seconds, no AWS account needed.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;the-problem-corrections-that-evaporate&quot;&gt;The problem: corrections that evaporate&lt;/h3&gt;
&lt;p&gt;If you've built an LLM extraction pipeline, you know this pain.&lt;/p&gt;
&lt;p&gt;Your QA analyst opens today's invoice. The model extracted &lt;code&gt;payment_terms&lt;/code&gt; as &lt;strong&gt;&quot;quarterly.&quot;&lt;/strong&gt; That's wrong — &quot;quarterly&quot; is the &lt;em&gt;billing frequency&lt;/em&gt;; the actual payment term is &lt;strong&gt;Net 30&lt;/strong&gt;. The analyst corrects it. Done.&lt;/p&gt;
&lt;p&gt;Tomorrow, an identical invoice from the same vendor arrives. The model extracts... &lt;strong&gt;&quot;quarterly&quot;&lt;/strong&gt; again. Same mistake. The analyst corrects it again.&lt;/p&gt;
&lt;p&gt;The correction &lt;em&gt;evaporated.&lt;/em&gt; It fixed one document and taught the system nothing.&lt;/p&gt;
&lt;p&gt;Multiply that across thousands of documents and dozens of recurring error patterns, and you get the two options most teams settle for:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Pay analysts to re-fix the same patterns forever&lt;/strong&gt; (QA cost never goes down), or&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fine-tune a model&lt;/strong&gt; (expensive, slow, needs ML ops, and you redo it every time the patterns shift).&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;There's a third way that needs neither.&lt;/p&gt;
&lt;h3 id=&quot;the-idea-treat-corrections-as-durable-memory&quot;&gt;The idea: treat corrections as durable memory&lt;/h3&gt;
&lt;p&gt;What if every human correction became a permanent, reusable asset?&lt;/p&gt;
&lt;p&gt;That's the pattern behind &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/aws-samples/sample-prompt-correction-memory&quot;&gt;&lt;code&gt;sample-prompt-correction-memory&lt;/code&gt;&lt;/a&gt;. Instead of throwing corrections away, it stores them and feeds them back into future extractions — so the same mistake is never made twice.&lt;/p&gt;
&lt;p&gt;It works as &lt;strong&gt;three tiers&lt;/strong&gt;, and the magic is that traffic gradually shifts from the expensive tier to the free one:&lt;/p&gt;
&lt;h4 id=&quot;-tier-1-graduated-rules-0-no-llm-call-&quot;&gt;🟢 Tier 1 — Graduated rules ($0, no LLM call)&lt;/h4&gt;
&lt;p&gt;When the same correction pattern recurs enough times, the system synthesizes a &lt;strong&gt;deterministic rule&lt;/strong&gt; (a regex, a lookup, a normalization) that handles that field with zero LLM cost and sub-millisecond latency. &quot;Net 30&quot; → &lt;code&gt;30&lt;/code&gt; becomes a rule; it never needs a model again.&lt;/p&gt;
&lt;h4 id=&quot;-tier-2-cloud-llm-extraction-confidence-gated-&quot;&gt;🔵 Tier 2 — Cloud LLM extraction (confidence-gated)&lt;/h4&gt;
&lt;p&gt;Fields with no matching rule go to &lt;strong&gt;Amazon Bedrock (Claude Haiku 4.5)&lt;/strong&gt;. Every field comes back with a &lt;strong&gt;confidence score&lt;/strong&gt;. If confidence clears the threshold, accept it and move on.&lt;/p&gt;
&lt;h4 id=&quot;-tier-3-self-healing-only-when-needed-&quot;&gt;🟠 Tier 3 — Self-healing (only when needed)&lt;/h4&gt;
&lt;p&gt;If confidence is &lt;em&gt;below&lt;/em&gt; threshold, the system retrieves the most relevant past corrections, formats them as &lt;strong&gt;few-shot examples&lt;/strong&gt;, and re-extracts with a stronger model (&lt;strong&gt;Claude Sonnet 4.5&lt;/strong&gt;). The model learns from the specific mistakes it made before — in-context, no training.&lt;/p&gt;
&lt;p&gt;Every QA correction flows into a &lt;strong&gt;correction log&lt;/strong&gt; (DynamoDB) that powers both the self-healing retrieval &lt;em&gt;and&lt;/em&gt; the rule graduation. So the more the system is used, the smarter and cheaper it gets.&lt;/p&gt;
&lt;h3 id=&quot;why-this-matters-the-practical-payoff-&quot;&gt;Why this matters (the practical payoff)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No ML ops.&lt;/strong&gt; No retraining, no GPUs, no deployment cycles. A correction takes effect on the &lt;em&gt;very next&lt;/em&gt; document.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cost drops as quality rises.&lt;/strong&gt; Normally you trade one for the other. Here, recurring fixes graduate to $0 rules — so accuracy climbs while per-document cost falls.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;QA effort compounds instead of repeating.&lt;/strong&gt; Fix it once → fixed forever.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It's a QA action, not an ML project.&lt;/strong&gt; Improving the pipeline no longer requires the ML team. An analyst uploads a correction; the system improves.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The clearest fit is &lt;strong&gt;high-repetition, back-office document processing&lt;/strong&gt; — accounts payable, contracts, compliance filings, supply-chain docs — where the same field errors recur and analyst time is the real expense.&lt;/p&gt;
&lt;h3 id=&quot;the-architecture&quot;&gt;The architecture&lt;/h3&gt;
&lt;p&gt;Fully serverless, deployed with AWS SAM:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;Document ──► S3 ──► EventBridge ──► SQS ──► Lambda (extract)
                                              │
                          ┌───────────────────┼───────────────────┐
                          ▼                    ▼                   ▼
                   Tier 1: Rules       Tier 2: Bedrock      Tier 3: Self-heal
                   (deterministic)     (Claude Haiku 4.5)   (Claude Sonnet 4.5)
                          ▲                                        │
                          │                                        ▼
                          └──────── Correction Log (DynamoDB) ◄────┘
                                    (grows from QA feedback)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;QA corrections are uploaded as JSON to an S3 &lt;code&gt;corrections/&lt;/code&gt; prefix; a second Lambda validates and ingests them into the correction log. Everything is encrypted with a customer-managed KMS key, and IAM is scoped to the specific Bedrock model ARNs the sample uses.&lt;/p&gt;
&lt;h3 id=&quot;see-it-in-30-seconds-no-aws-account-&quot;&gt;See it in 30 seconds (no AWS account)&lt;/h3&gt;
&lt;p&gt;The quickstart runs the entire self-healing loop with a mocked Bedrock client, so you can watch the mechanism without deploying anything:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone https://github.com/aws-samples/sample-prompt-correction-memory
cd sample-prompt-correction-memory
pip install -e &quot;.[dev]&quot;
python examples/quickstart.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You'll see output like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;Step 1: Initial extraction (no correction memory)
  Field:      effective_date
  Value:      March 2024        Confidence: 0.55   ❌ NO
Step 2: Self-healing triggers (0.55 &amp;lt; threshold 0.70)
  Retrieving corrections for 'effective_date'...
  Found: &quot;March 2024&quot; → &quot;2024-03-01&quot;
Step 3: Re-extraction with correction memory
  Value:       2024-03-01       Confidence: 0.95   ✓ YES
  Self-Healed: True
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Low-confidence extraction → retrieve past correction → re-extract → correct answer. No retraining. No redeployment.&lt;/p&gt;
&lt;h3 id=&quot;deploying-to-aws&quot;&gt;Deploying to AWS&lt;/h3&gt;
&lt;p&gt;With the AWS CLI, SAM CLI, and Bedrock model access for Claude Haiku 4.5 + Sonnet 4.5:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;make deploy    # S3, DynamoDB, Lambda, EventBridge, SQS, KMS
make seed      # load sample corrections into the correction log
make trigger   # upload a sample document → triggers real extraction
make verify    # read back results (fields, confidence, self_healed)
make destroy   # empty buckets + delete the stack
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;make verify&lt;/code&gt; prints each extracted field with its confidence and whether self-healing kicked in — your proof it works end-to-end.&lt;/p&gt;
&lt;h3 id=&quot;a-companion-spatial-memory&quot;&gt;A companion: spatial memory&lt;/h3&gt;
&lt;p&gt;This sample provides &lt;strong&gt;semantic&lt;/strong&gt; correction memory (what was extracted wrong, and why). Its companion, &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/aws-samples/sample-textract-field-memory&quot;&gt;&lt;code&gt;sample-textract-field-memory&lt;/code&gt;&lt;/a&gt;, provides &lt;strong&gt;spatial&lt;/strong&gt; memory (where fields appear on a document layout). Together they form a dual memory for document pipelines: use the cheap spatial lookup when you're confident where a field is, and fall back to self-healing extraction when you're not.&lt;/p&gt;
&lt;h3 id=&quot;honest-limitations&quot;&gt;Honest limitations&lt;/h3&gt;
&lt;p&gt;Because it's more useful when it's used well:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;It's &lt;strong&gt;sample/reference code&lt;/strong&gt;, not a production library. Harden IAM, add monitoring, and put a human in the loop for high-stakes fields before real use.&lt;/li&gt;
&lt;li&gt;It helps most where the &lt;strong&gt;same field errors recur&lt;/strong&gt;. For wildly heterogeneous, one-off documents, there's less pattern to learn.&lt;/li&gt;
&lt;li&gt;Graduated rules currently take effect &lt;strong&gt;without a human approval step&lt;/strong&gt; — in production you'd add a review gate before a synthesized rule starts answering on its own.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don't use it with regulated data&lt;/strong&gt; (PII/PHI/PCI) as-is; that path needs a proper compliance review.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;try-it-and-tell-me&quot;&gt;Try it and tell me&lt;/h3&gt;
&lt;p&gt;The repo is open source under &lt;code&gt;aws-samples&lt;/code&gt;, MIT-0 licensed, with a benchmark suite, an interactive dashboard, and an offline test harness.&lt;/p&gt;
&lt;p&gt;👉 &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/aws-samples/sample-prompt-correction-memory&quot;&gt;github.com/aws-samples/sample-prompt-correction-memory&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;What repetitive extraction error is your team still fixing by hand? I'd love to hear which patterns you'd want a system like this to learn.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;This is an open-source AWS sample intended for demonstration and non-production use.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30620/your-llm-keeps-making-the-same-extraction-mistake-heres-how-to-make-it-learn</guid>
<pubDate>Sat, 10 Oct 2026 16:09:42 +0000</pubDate>
</item>
<item>
<title>Cut LLM Document-Extraction Cost by 85% Without Losing Accuracy</title>
<link>https://coderlegion.com/30616/cut-llm-document-extraction-cost-by-85-without-losing-accuracy</link>
<description>&lt;p&gt;Most production LLM extraction pipelines route every field through a frontier model, every time. It works, and for a while nobody questions the bill. Then volume grows, or someone runs the per-document math, and the real question shows up. How much of this spend is buying accuracy, and how much is just habit.&lt;/p&gt;
&lt;p&gt;In practice, most of it is habit. The majority of fields in a typical extraction job do not need a frontier model at all. A large share of the model calls answer questions the document never asked. A surprising fraction of calls re-extract data that has not changed since the last run. None of that is a model problem. It is an architecture problem, and architecture problems are the good kind, because you can fix them without waiting on anyone's roadmap.&lt;/p&gt;
&lt;p&gt;I build cost-optimized inference for document-extraction workloads with 100+ fields. This is the practitioner's version of what actually moves the number. The moves below, applied in sequence, take per-document cost down by &lt;strong&gt;85 to 93 percent&lt;/strong&gt; with negligible accuracy impact. The interesting part is not any single technique. It is why they compound instead of overlap.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Set a trusted baseline with the expensive model first, then prove a cheaper model can match it.&lt;/li&gt;
&lt;li&gt;Skip fields a document type cannot contain (adaptive selection).&lt;/li&gt;
&lt;li&gt;Route each field to the cheapest model that can do the job (tiered routing + distillation).&lt;/li&gt;
&lt;li&gt;Stop re-extracting unchanged fields (field-level caching).&lt;/li&gt;
&lt;li&gt;Stop paying for idle (serverless scale-to-zero).&lt;/li&gt;
&lt;li&gt;The savings multiply, not add, because each move attacks a different kind of waste.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;-set-the-baseline-with-the-expensive-model-first&quot;&gt;🧭 Set the baseline with the expensive model first&lt;/h3&gt;
&lt;p&gt;Before optimizing anything, run the workload on a frontier model and treat those results as the baseline. Early on the goal is not low cost. It is output you trust and a confidence signal you believe. Once that baseline exists, it becomes the yardstick for everything cheaper.&lt;/p&gt;
&lt;p&gt;Then, field by field, test whether a smaller and cheaper model can match it. Run the cheap model over the same documents, compare against the frontier baseline, and keep the fields where agreement is high. The fields that match move to the cheaper tier. The fields that do not stay on the frontier model.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;This is how the tier percentages below get decided in practice. You are not guessing which fields a lightweight model can handle. You are measuring it against output you already trust.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The same comparison runs later as the quality-floor monitor at the end of this post. It starts here, as the step that earns each downgrade.&lt;/p&gt;
&lt;h3 id=&quot;-start-by-not-doing-unnecessary-work-adaptive-field-selection&quot;&gt;✂️ Start by not doing unnecessary work: adaptive field selection&lt;/h3&gt;
&lt;p&gt;The cheapest extraction call is the one you never make.&lt;/p&gt;
&lt;p&gt;A contract schema might define 100+ fields, but a given document, say a simple service agreement, only contains 40 to 50 of them. Extracting the rest produces &quot;not found&quot; responses that cost tokens and return nothing. Classify the document type first, then extract only the fields that can actually appear in it.&lt;/p&gt;
&lt;p&gt;This one move eliminates 30 to 40 percent of calls per document. It is the right thing to do first, because every later optimization then operates on a smaller base.&lt;/p&gt;
&lt;h3 id=&quot;-right-size-the-model-to-the-field-tiered-routing&quot;&gt;🎯 Right-size the model to the field: tiered routing&lt;/h3&gt;
&lt;p&gt;Not every field needs the same horsepower. In a typical extraction job:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;~65 to 70 percent of fields are structurally simple:&lt;/strong&gt; dates, amounts, party names, reference numbers, categorical flags. Predictable formats, consistent locations. A lightweight model handles these at a fraction of frontier cost with equivalent accuracy.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;~20 percent need moderate reasoning:&lt;/strong&gt; interpreting indirect phrasing, resolving ambiguous references, synthesizing across sections. A mid-tier model (for example a Haiku or Nova Lite class model) is plenty.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;~10 percent genuinely need a frontier model:&lt;/strong&gt; obligation summaries, risk assessment, termination-condition analysis, cross-document context.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Route each field to the cheapest tier that can do the job. The routing decision is nearly free. It is a field-type to tier lookup table plus a confidence-based fallback, so a tier that returns low confidence escalates to the next one. No ML is needed for routing, because the mapping is stable for a given document corpus. Blended per-document cost drops 60 to 75 percent, and quality holds because the hard fields still get frontier attention. The split above is not assumed. It comes straight out of the baseline comparison from the previous section.&lt;/p&gt;
&lt;h4 id=&quot;distillation-earn-your-way-to-a-cheaper-tier-1&quot;&gt;Distillation: earn your way to a cheaper Tier 1&lt;/h4&gt;
&lt;p&gt;Tiered routing gets better when Tier 1 is a model you distilled for your own documents. Run a frontier model (the teacher) over 200 to 500 representative documents to produce gold-standard extractions, then train a smaller student model to reproduce that behavior on your specific fields. Managed services such as Amazon Bedrock Model Distillation handle the training without ML expertise.&lt;/p&gt;
&lt;p&gt;The student model learns your narrow task, the dates, amounts, party names, and term lengths you see every day, and it runs much cheaper and faster with a small accuracy gap (about 2 percent) on the tasks it was trained for. Fields outside its training distribution fall through to the mid-tier or frontier model.&lt;/p&gt;
&lt;p&gt;The bootstrap problem is obvious. You do not have 200 documents on day one, so distill progressively. Months one and two run routing-only on general-purpose models (40 to 50 percent savings) while storing every validated result as future training data. In month three, once you have accumulated enough examples, distillation runs and Tier 1 becomes your student model (another 30 to 40 percent off). Each quarter you re-distill with more data, and the share of fields handled by the cheapest tier climbs from about 70 percent toward 85 percent. The cost curve bends down the longer the system runs.&lt;/p&gt;
&lt;h3 id=&quot;-stop-paying-twice-field-level-caching&quot;&gt;♻️ Stop paying twice: field-level caching&lt;/h3&gt;
&lt;p&gt;Documents get reprocessed when schemas are updated, prompts improve, or a quality review flags systematic errors. Without caching, reprocessing 200 documents costs exactly what processing them the first time did.&lt;/p&gt;
&lt;p&gt;Key each result by the document-section SHA-256, the field name, the prompt version, and the model version. If all four match, return the cached value with zero inference. When you update a prompt for one field type, only that field's cache entries invalidate, and everything else serves from cache. A prompt update typically touches three to five field types, so 85 to 90 percent of fields serve from cache on reprocessing. For a 200-document corpus reprocessed after a four-field prompt change, that is 800 extractions instead of 7,000, an 88 percent reduction on that cycle.&lt;/p&gt;
&lt;h3 id=&quot;-stop-paying-for-idle-serverless-scale-to-zero&quot;&gt;🛑 Stop paying for idle: serverless scale-to-zero&lt;/h3&gt;
&lt;p&gt;A 200-document-per-month workload is idle most of the time. Documents land in object storage, a lightweight function writes metadata that triggers a stream event, and a containerized extraction agent runs only when work exists. During idle periods the system costs nothing beyond storage. For bursty, low-volume workloads this removes the fixed infrastructure floor that usually makes small deployments uneconomical. KEDA is a clean way to get scale-to-zero and event-driven scaling for this on Kubernetes.&lt;/p&gt;
&lt;h3 id=&quot;-why-these-compound-instead-of-add&quot;&gt;🧮 Why these compound instead of add&lt;/h3&gt;
&lt;p&gt;A tempting mistake is to add the savings together. Distillation saves 75 percent, adaptive selection 35 percent, caching 30 percent, so the total must be 140 percent. That is impossible. The techniques do not each operate on the original cost. Each operates on the residual the previous stage left behind. They address different sources of waste, so they multiply:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Stage &lt;/th&gt;&lt;th&gt;Technique &lt;/th&gt;&lt;th&gt;Operates on &lt;/th&gt;&lt;th&gt;Residual&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;1 &lt;/td&gt;&lt;td&gt;Adaptive field selection &lt;/td&gt;&lt;td&gt;Total field count &lt;/td&gt;&lt;td&gt;60 to 70 percent of baseline&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;2 &lt;/td&gt;&lt;td&gt;Distillation + tiered routing &lt;/td&gt;&lt;td&gt;Remaining fields &lt;/td&gt;&lt;td&gt;20 to 25 percent of baseline&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;3 &lt;/td&gt;&lt;td&gt;Field-level caching &lt;/td&gt;&lt;td&gt;Reprocessed documents &lt;/td&gt;&lt;td&gt;10 to 15 percent on reprocessing&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;4 &lt;/td&gt;&lt;td&gt;Serverless scale-to-zero &lt;/td&gt;&lt;td&gt;Infrastructure &lt;/td&gt;&lt;td&gt;Zero idle cost&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Result&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;strong&gt;Compound effect&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;strong&gt;End to end&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;strong&gt;5 to 15 percent of naive baseline&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Roughly &lt;code&gt;(1 - 0.35) x (1 - 0.65) x (1 - 0.25)&lt;/code&gt;, which lands around 0.07 to 0.15 of the original, an &lt;strong&gt;85 to 93 percent reduction&lt;/strong&gt;. Each technique matters most at a different stage. Adaptive selection eliminates unnecessary work, distillation reduces the cost of necessary work, caching eliminates repeated work, and serverless eliminates idle overhead. No one of them gets you there alone.&lt;/p&gt;
&lt;h3 id=&quot;-making-it-safe-the-quality-floor-monitor&quot;&gt;🛡️ Making it safe: the quality-floor monitor&lt;/h3&gt;
&lt;p&gt;Aggressive cost optimization earns an obvious objection. If 70 percent of fields go to a cheaper model, you need a way to know it is not silently getting worse, or drifting as document patterns change.&lt;/p&gt;
&lt;p&gt;Build the check in. Each week, randomly re-run 5 to 10 percent of Tier-1 fields through the frontier model as a shadow extraction and compare. This is the same baseline comparison from the start of the post, now running continuously. Agreement at or above 98 percent means the floor is holding. Below 95 percent raises an alert. Below 90 percent auto-triggers re-distillation on the last three months of data. Shadow validation adds only about 3 to 5 percent to total cost, and it means the system monitors itself, with no dedicated QA team required.&lt;/p&gt;
&lt;p&gt;The same monitor also spots promotions. If the mid-tier and frontier models consistently agree on a field currently routed to Tier 2, that field is a candidate to fold into the next distillation cycle, so the system keeps shifting work to the cheapest tier on its own.&lt;/p&gt;
&lt;h3 id=&quot;-the-payoff-beyond-the-bill&quot;&gt;💡 The payoff beyond the bill&lt;/h3&gt;
&lt;p&gt;The reason this is worth doing well is not only the savings on an existing workload. Every percentage point off per-unit cost widens the set of use cases where automation is worth building at all. A workload that only penciled out for one of your five document types at naive pricing might clear the bar for three or four once the architecture is right. Cost optimization is not a late-stage cleanup. Treat it as a first-class design requirement alongside accuracy, latency, and reliability, and more of what you want to automate becomes viable.&lt;/p&gt;
&lt;p&gt;Five moves, five kinds of waste:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Expensive-model baseline, then prove the cheap model&lt;/strong&gt; turns every downgrade into a measured decision.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Adaptive field selection&lt;/strong&gt; removes unnecessary work.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tiered routing + distillation&lt;/strong&gt; reduces the cost of necessary work.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Field-level caching&lt;/strong&gt; removes repeated work.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Serverless scale-to-zero&lt;/strong&gt; removes idle overhead.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Apply them in sequence, guard them with a self-checking quality floor, and you get frontier-grade extraction at a small fraction of the naive cost, without betting accuracy on it.&lt;/p&gt;
&lt;h3 id=&quot;two-patterns-i-open-sourced&quot;&gt;Two patterns I open-sourced&lt;/h3&gt;
&lt;p&gt;Two of the ideas above are available as small, dependency-light libraries under AWS Samples (MIT-0), if you want a reference to build from:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/aws-samples/sample-textract-field-memory&quot;&gt;aws-samples/sample-textract-field-memory&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/aws-samples/sample-prompt-correction-memory&quot;&gt;aws-samples/sample-prompt-correction-memory&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;If you are optimizing an extraction pipeline, I would be curious where your biggest cost sink turned out to be: unnecessary calls, over-powered models, reprocessing, or idle. Drop a comment.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30616/cut-llm-document-extraction-cost-by-85-without-losing-accuracy</guid>
<pubDate>Sat, 10 Oct 2026 16:08:52 +0000</pubDate>
</item>
<item>
<title>AWS: IAM, Regions, Availability Zones, and the Shared Responsibility Model</title>
<link>https://coderlegion.com/30007/aws-iam-regions-availability-zones-and-the-shared-responsibility-model</link>
<description>&lt;h2 id=&quot;aws-iam-regions-availability-zones-and-the-shared-responsibility-model&quot;&gt;AWS: IAM, Regions, Availability Zones, and the Shared Responsibility Model&lt;/h2&gt;
&lt;h3 id=&quot;a-practical-beginner-s-guide-with-certification-mapping-saa-mla-c02-professional-foundations-&quot;&gt;A Practical Beginner's Guide with Certification Mapping (SAA / MLA-C02 / Professional Foundations)&lt;/h3&gt;
&lt;h3 id=&quot;the-beginner-problem&quot;&gt;The Beginner Problem&lt;/h3&gt;
&lt;p&gt;One of the most common mistakes new AWS users make is launching resources before understanding three foundational concepts:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Who is allowed to do something? → IAM&lt;/li&gt;
&lt;li&gt;Where are resources running? → Regions and Availability Zones&lt;/li&gt;
&lt;li&gt;Who is responsible for security? → Shared Responsibility Model&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A beginner might create an EC2 instance, upload files to Amazon S3, and then realize:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;They accidentally used administrator permissions everywhere.&lt;/li&gt;
&lt;li&gt;They deployed resources in the wrong Region.&lt;/li&gt;
&lt;li&gt;They assumed AWS automatically backs up or secures everything.&lt;/li&gt;
&lt;li&gt;They cannot find a resource because they're viewing a different Region.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These mistakes appear simple, but they are exactly the type of concepts tested heavily in:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;AWS Certified Solutions Architect – Associate (SAA)&lt;/li&gt;
&lt;li&gt;AWS Certified Machine Learning Engineer – Associate (MLA-C02)&lt;/li&gt;
&lt;li&gt;AWS Professional-level architecture and security domains&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In this hands-on article, you'll build foundational AWS knowledge through practical implementation instead of memorization.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;learning-objectives&quot;&gt;Learning Objectives&lt;/h2&gt;
&lt;p&gt;By the end of this exercise you should understand:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;IAM users, groups, permissions, and policies&lt;/li&gt;
&lt;li&gt;Least privilege access&lt;/li&gt;
&lt;li&gt;AWS Regions and Availability Zones (AZs)&lt;/li&gt;
&lt;li&gt;High availability fundamentals&lt;/li&gt;
&lt;li&gt;AWS Shared Responsibility Model&lt;/li&gt;
&lt;li&gt;AWS account security best practices&lt;/li&gt;
&lt;li&gt;How these concepts fit into real cloud architectures&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;where-these-concepts-fit-in-real-architecture&quot;&gt;Where These Concepts Fit in Real Architecture&lt;/h2&gt;
&lt;p&gt;Before opening the console, it helps to understand where these concepts fit.&lt;/p&gt;
&lt;p&gt;A simple AWS application may look like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;User
   |
   v
IAM Authentication &amp;amp; Authorization
   |
   v
VPC
   |
   +---- EC2 Instance (AZ-A)
   |
   +---- EC2 Instance (AZ-B)
   |
   +---- Amazon S3 Bucket
   |
   +---- RDS Database
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;architecture-diagram-description&quot;&gt;Architecture Diagram Description&lt;/h4&gt;
&lt;p&gt;Create an architecture image containing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;One AWS Region&lt;/li&gt;
&lt;li&gt;Two Availability Zones within that Region&lt;/li&gt;
&lt;li&gt;One EC2 instance in each Availability Zone&lt;/li&gt;
&lt;li&gt;One Amazon S3 bucket shared across the Region&lt;/li&gt;
&lt;li&gt;One IAM user accessing AWS services&lt;/li&gt;
&lt;li&gt;Labels showing:&lt;ul&gt;
&lt;li&gt;IAM controls access&lt;/li&gt;
&lt;li&gt;AZs improve availability&lt;/li&gt;
&lt;li&gt;Regions determine geographic placement&lt;/li&gt;
&lt;li&gt;AWS and customer share security responsibilities&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;understanding-iam&quot;&gt;Understanding IAM&lt;/h2&gt;
&lt;h3 id=&quot;what-is-iam-&quot;&gt;What Is IAM?&lt;/h3&gt;
&lt;p&gt;AWS Identity and Access Management (IAM) is the AWS service used to control who can access AWS resources and what actions they can perform.&lt;/p&gt;
&lt;p&gt;Think of IAM as the security guard for your AWS account.&lt;/p&gt;
&lt;p&gt;IAM answers three questions:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Who are you?&lt;/li&gt;
&lt;li&gt;What resources can you access?&lt;/li&gt;
&lt;li&gt;What actions are you allowed to perform?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Without IAM, every user would effectively have administrator permissions, which creates major security risks.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;real-world-example&quot;&gt;Real-World Example&lt;/h3&gt;
&lt;p&gt;Imagine a startup team:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Team Member &lt;/th&gt;&lt;th&gt;Access Needed&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Developer &lt;/td&gt;&lt;td&gt;Amazon EC2&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Data Analyst &lt;/td&gt;&lt;td&gt;Amazon S3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Security Engineer &lt;/td&gt;&lt;td&gt;IAM&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Intern &lt;/td&gt;&lt;td&gt;Read-only access&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Giving all of them AdministratorAccess would violate AWS security best practices.&lt;/p&gt;
&lt;p&gt;Instead, IAM allows organizations to grant only the permissions required for each role. This principle is known as &lt;strong&gt;least privilege&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;understanding-regions-and-availability-zones&quot;&gt;Understanding Regions and Availability Zones&lt;/h2&gt;
&lt;p&gt;AWS infrastructure is organized into Regions and Availability Zones.&lt;/p&gt;
&lt;p&gt;These concepts are frequently tested in AWS certification exams because they directly affect:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Availability&lt;/li&gt;
&lt;li&gt;Resilience&lt;/li&gt;
&lt;li&gt;Disaster recovery&lt;/li&gt;
&lt;li&gt;Compliance&lt;/li&gt;
&lt;li&gt;Cost management&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-is-a-region-&quot;&gt;What Is a Region?&lt;/h3&gt;
&lt;p&gt;A Region is a geographic AWS location.&lt;/p&gt;
&lt;p&gt;Examples include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;US East (N. Virginia)&lt;/li&gt;
&lt;li&gt;US East (Ohio)&lt;/li&gt;
&lt;li&gt;Europe (Frankfurt)&lt;/li&gt;
&lt;li&gt;Asia Pacific (Mumbai)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When selecting a Region, consider:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;User location&lt;/li&gt;
&lt;li&gt;Regulatory requirements&lt;/li&gt;
&lt;li&gt;Available AWS services&lt;/li&gt;
&lt;li&gt;Latency requirements&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;example&quot;&gt;Example&lt;/h4&gt;
&lt;p&gt;If your customers are primarily in Pakistan and India, you may choose:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ap-south-1 (Mumbai)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;to reduce latency compared to a North American Region.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-is-an-availability-zone-&quot;&gt;What Is an Availability Zone?&lt;/h3&gt;
&lt;p&gt;An Availability Zone (AZ) is an isolated AWS facility within a Region.&lt;/p&gt;
&lt;p&gt;Examples:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;us-east-1a
us-east-1b
us-east-1c
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each Availability Zone is designed to operate independently.&lt;/p&gt;
&lt;p&gt;If one Availability Zone experiences problems:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Applications running in another AZ may continue operating.&lt;/li&gt;
&lt;li&gt;High availability architectures remain functional.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-multiple-availability-zones-matter&quot;&gt;Why Multiple Availability Zones Matter&lt;/h3&gt;
&lt;p&gt;Single-AZ deployment:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;EC2
 |
AZ-A
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If AZ-A fails:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Application Unavailable
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Multi-AZ deployment:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;EC2 (AZ-A)
     |
Load Balancer
     |
EC2 (AZ-B)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If one AZ fails:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Traffic continues through surviving AZ
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is a core AWS high-availability pattern.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;understanding-the-shared-responsibility-model&quot;&gt;Understanding the Shared Responsibility Model&lt;/h2&gt;
&lt;p&gt;One of the most important AWS concepts is understanding who is responsible for what.&lt;/p&gt;
&lt;p&gt;Many beginners incorrectly assume:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;“AWS manages security, so I'm automatically protected.”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This is false.&lt;/p&gt;
&lt;p&gt;AWS security follows a Shared Responsibility Model.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;aws-responsibilities&quot;&gt;AWS Responsibilities&lt;/h3&gt;
&lt;p&gt;AWS is responsible for &lt;strong&gt;security OF the cloud&lt;/strong&gt;, including:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Physical data centers&lt;/li&gt;
&lt;li&gt;Physical network infrastructure&lt;/li&gt;
&lt;li&gt;Power systems&lt;/li&gt;
&lt;li&gt;Cooling systems&lt;/li&gt;
&lt;li&gt;Hardware&lt;/li&gt;
&lt;li&gt;Virtualization infrastructure&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;AWS manages and protects the underlying cloud infrastructure.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;customer-responsibilities&quot;&gt;Customer Responsibilities&lt;/h3&gt;
&lt;p&gt;Customers are responsible for &lt;strong&gt;security IN the cloud&lt;/strong&gt;, including:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;IAM permissions&lt;/li&gt;
&lt;li&gt;User management&lt;/li&gt;
&lt;li&gt;Data protection&lt;/li&gt;
&lt;li&gt;Application security&lt;/li&gt;
&lt;li&gt;Operating system patching on EC2&lt;/li&gt;
&lt;li&gt;Security Groups&lt;/li&gt;
&lt;li&gt;Encryption configuration&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;certification-exam-example&quot;&gt;Certification Exam Example&lt;/h3&gt;
&lt;p&gt;Question:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;An EC2 instance is compromised because security updates were never installed.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Who is responsible?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Answer:&lt;/strong&gt; Customer&lt;/p&gt;
&lt;p&gt;Reason:&lt;/p&gt;
&lt;p&gt;For Amazon EC2, customers are responsible for managing and patching the guest operating system.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;prerequisites-2&quot;&gt;Prerequisites&lt;/h2&gt;
&lt;p&gt;Before beginning:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;AWS account&lt;/li&gt;
&lt;li&gt;Access to AWS Management Console&lt;/li&gt;
&lt;li&gt;MFA enabled on the root account (recommended)&lt;/li&gt;
&lt;li&gt;Modern web browser&lt;/li&gt;
&lt;li&gt;Basic cloud computing familiarity&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=7787482253992601113&quot; alt=&quot;&quot;&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; AWS updates the console regularly. If your interface differs from the screenshots or instructions shown here, verify the latest workflow using the official AWS documentation.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id=&quot;hands-on-lab&quot;&gt;Hands-On Lab&lt;/h2&gt;
&lt;h3 id=&quot;part-1-create-an-iam-user&quot;&gt;Part 1: Create an IAM User&lt;/h3&gt;
&lt;h4 id=&quot;certification-concepts&quot;&gt;Certification Concepts&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;SAA&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Identity and Access Management&lt;/li&gt;
&lt;li&gt;Security Best Practices&lt;/li&gt;
&lt;li&gt;Least Privilege&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;MLA-C02&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Secure access to machine learning resources&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-1-open-iam&quot;&gt;Step 1: Open IAM&lt;/h3&gt;
&lt;p&gt;Navigate to:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;AWS Console
→ IAM
→ Users
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=17891762231632025962&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;why-this-step-matters&quot;&gt;Why This Step Matters&lt;/h4&gt;
&lt;p&gt;Users represent individual human identities.&lt;/p&gt;
&lt;p&gt;AWS recommends avoiding daily use of the root account.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-2-create-a-new-user&quot;&gt;Step 2: Create a New User&lt;/h3&gt;
&lt;p&gt;Select:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Create User
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Example username:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;student-user
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Enable:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Provide user access to AWS Management Console
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=11039505930096335567&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;why-this-option-matters&quot;&gt;Why This Option Matters&lt;/h4&gt;
&lt;p&gt;This creates a separate identity rather than sharing root credentials.&lt;/p&gt;
&lt;p&gt;This follows AWS security best practices.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-3-assign-permissions&quot;&gt;Step 3: Assign Permissions&lt;/h3&gt;
&lt;p&gt;Choose:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Attach policies directly
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Select:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ReadOnlyAccess
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=7748793186907347402&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;why-this-policy-&quot;&gt;Why This Policy?&lt;/h4&gt;
&lt;p&gt;ReadOnlyAccess allows a user to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;View resources&lt;/li&gt;
&lt;li&gt;Explore the account&lt;/li&gt;
&lt;li&gt;Learn AWS safely&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It prevents:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Resource deletion&lt;/li&gt;
&lt;li&gt;Infrastructure modification&lt;/li&gt;
&lt;li&gt;Administrative changes&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This demonstrates least privilege access.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-4-review-and-create&quot;&gt;Step 4: Review and Create&lt;/h3&gt;
&lt;p&gt;Review:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Username&lt;/li&gt;
&lt;li&gt;Console access&lt;/li&gt;
&lt;li&gt;Attached permissions&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Click:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Create User
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Save:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Sign-in URL&lt;/li&gt;
&lt;li&gt;Username&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You will use them later for testing.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;part-2-explore-aws-regions&quot;&gt;Part 2: Explore AWS Regions&lt;/h2&gt;
&lt;h4 id=&quot;certification-concepts-1&quot;&gt;Certification Concepts&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;SAA&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Global Infrastructure&lt;/li&gt;
&lt;li&gt;Regional Design&lt;/li&gt;
&lt;li&gt;Workload Placement&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-1-locate-the-region-selector&quot;&gt;Step 1: Locate the Region Selector&lt;/h3&gt;
&lt;p&gt;In the AWS Console, locate the Region selector in the upper-right corner.&lt;/p&gt;
&lt;p&gt;Examples:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;us-east-1
us-west-2
eu-west-1
ap-south-1
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;section&quot;&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=5154150106740859128&quot; alt=&quot;&quot;&gt;&lt;/h3&gt;
&lt;h3 id=&quot;step-2-switch-between-regions&quot;&gt;Step 2: Switch Between Regions&lt;/h3&gt;
&lt;p&gt;Move from one Region to another.&lt;/p&gt;
&lt;p&gt;Observe:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;EC2 instance lists change&lt;/li&gt;
&lt;li&gt;VPC configurations change&lt;/li&gt;
&lt;li&gt;Resource inventories change&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;key-lesson&quot;&gt;Key Lesson&lt;/h4&gt;
&lt;p&gt;Most AWS resources are Regional.&lt;/p&gt;
&lt;p&gt;Resources created in one Region do not automatically appear in another Region.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;part-3-explore-availability-zones&quot;&gt;Part 3: Explore Availability Zones&lt;/h2&gt;
&lt;h4 id=&quot;certification-concepts-2&quot;&gt;Certification Concepts&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;SAA&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Fault Tolerance&lt;/li&gt;
&lt;li&gt;Availability&lt;/li&gt;
&lt;li&gt;Resilience Design&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-1-open-ec2&quot;&gt;Step 1: Open EC2&lt;/h3&gt;
&lt;p&gt;Navigate to:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;EC2
→ Launch Instance
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You do not need to launch an instance.&lt;/p&gt;
&lt;p&gt;You are only exploring the configuration options.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-2-review-network-placement&quot;&gt;Step 2: Review Network Placement&lt;/h3&gt;
&lt;p&gt;Locate:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Availability Zone
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Examples:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;us-east-1a
us-east-1b
us-east-1c
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=13202585907387584382&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;why-this-matters&quot;&gt;Why This Matters&lt;/h4&gt;
&lt;p&gt;Workloads distributed across multiple Availability Zones are more resilient than workloads running in a single AZ.&lt;/p&gt;
&lt;p&gt;This principle is used heavily in production architectures.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;part-4-apply-the-shared-responsibility-model&quot;&gt;Part 4: Apply the Shared Responsibility Model&lt;/h2&gt;
&lt;h4 id=&quot;certification-concepts-3&quot;&gt;Certification Concepts&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;SAA&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Security Design&lt;/li&gt;
&lt;li&gt;Operational Excellence&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Professional Certifications&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Governance&lt;/li&gt;
&lt;li&gt;Risk Management&lt;/li&gt;
&lt;li&gt;Compliance&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;scenario&quot;&gt;Scenario&lt;/h3&gt;
&lt;p&gt;You launch:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Amazon EC2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now ask:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Who secures what?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;aws-secures&quot;&gt;AWS Secures&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Physical servers&lt;/li&gt;
&lt;li&gt;Network facilities&lt;/li&gt;
&lt;li&gt;Storage hardware&lt;/li&gt;
&lt;li&gt;Data center operations&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;you-secure&quot;&gt;You Secure&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Application code&lt;/li&gt;
&lt;li&gt;IAM permissions&lt;/li&gt;
&lt;li&gt;Operating system updates&lt;/li&gt;
&lt;li&gt;Security Groups&lt;/li&gt;
&lt;li&gt;Credentials&lt;/li&gt;
&lt;li&gt;Data access controls&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;validation-and-testing&quot;&gt;Validation and Testing&lt;/h3&gt;
&lt;h4 id=&quot;test-1-iam-login&quot;&gt;Test 1: IAM Login&lt;/h4&gt;
&lt;p&gt;Log in using the newly created IAM user.&lt;/p&gt;
&lt;p&gt;Expected Result:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Login succeeds
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h4 id=&quot;test-2-attempt-administrative-actions&quot;&gt;Test 2: Attempt Administrative Actions&lt;/h4&gt;
&lt;p&gt;Attempt:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Create a new IAM policy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Expected Result:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Access Denied
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Reason:&lt;/p&gt;
&lt;p&gt;ReadOnlyAccess does not permit administrative changes.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;test-3-switch-regions&quot;&gt;Test 3: Switch Regions&lt;/h4&gt;
&lt;p&gt;Move between Regions.&lt;/p&gt;
&lt;p&gt;Expected Result:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Different resources appear
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h4 id=&quot;test-4-identify-availability-zones&quot;&gt;Test 4: Identify Availability Zones&lt;/h4&gt;
&lt;p&gt;Locate at least two AZs within your selected Region.&lt;/p&gt;
&lt;p&gt;Expected Result:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Multiple AZs available
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h4 id=&quot;test-5-shared-responsibility-review&quot;&gt;Test 5: Shared Responsibility Review&lt;/h4&gt;
&lt;p&gt;For an EC2 deployment, identify customer responsibilities.&lt;/p&gt;
&lt;p&gt;Expected Answers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;IAM configuration&lt;/li&gt;
&lt;li&gt;Operating system patching&lt;/li&gt;
&lt;li&gt;Security Group configuration&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;troubleshooting&quot;&gt;Troubleshooting&lt;/h2&gt;
&lt;h3 id=&quot;case-1-resources-cannot-be-found&quot;&gt;Case 1: Resources Cannot Be Found&lt;/h3&gt;
&lt;h4 id=&quot;cause&quot;&gt;Cause&lt;/h4&gt;
&lt;p&gt;Wrong Region selected.&lt;/p&gt;
&lt;h4 id=&quot;fix&quot;&gt;Fix&lt;/h4&gt;
&lt;p&gt;Verify the Region selector.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;case-2-access-denied-errors&quot;&gt;Case 2: Access Denied Errors&lt;/h3&gt;
&lt;h4 id=&quot;cause-1&quot;&gt;Cause&lt;/h4&gt;
&lt;p&gt;IAM policy lacks required permissions.&lt;/p&gt;
&lt;h4 id=&quot;fix-1&quot;&gt;Fix&lt;/h4&gt;
&lt;p&gt;Review attached policies and permissions.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;case-3-user-cannot-sign-in&quot;&gt;Case 3: User Cannot Sign In&lt;/h3&gt;
&lt;h4 id=&quot;cause-2&quot;&gt;Cause&lt;/h4&gt;
&lt;p&gt;Console access was not enabled during user creation.&lt;/p&gt;
&lt;h4 id=&quot;fix-2&quot;&gt;Fix&lt;/h4&gt;
&lt;p&gt;Edit the user settings and enable console access.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;case-4-using-root-account-for-everything&quot;&gt;Case 4: Using Root Account for Everything&lt;/h3&gt;
&lt;h4 id=&quot;cause-3&quot;&gt;Cause&lt;/h4&gt;
&lt;p&gt;Common beginner mistake.&lt;/p&gt;
&lt;h4 id=&quot;fix-3&quot;&gt;Fix&lt;/h4&gt;
&lt;p&gt;Create IAM users for daily activities.&lt;/p&gt;
&lt;p&gt;Enable MFA on the root account.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;case-5-assuming-aws-configures-security-automatically&quot;&gt;Case 5: Assuming AWS Configures Security Automatically&lt;/h3&gt;
&lt;h4 id=&quot;cause-4&quot;&gt;Cause&lt;/h4&gt;
&lt;p&gt;Misunderstanding Shared Responsibility.&lt;/p&gt;
&lt;h4 id=&quot;fix-4&quot;&gt;Fix&lt;/h4&gt;
&lt;p&gt;Review which responsibilities belong to AWS and which belong to customers.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;security-considerations&quot;&gt;Security Considerations&lt;/h2&gt;
&lt;p&gt;Follow these security practices:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Enable MFA on privileged accounts.&lt;/li&gt;
&lt;li&gt;Avoid using the root user for routine work.&lt;/li&gt;
&lt;li&gt;Apply least privilege permissions.&lt;/li&gt;
&lt;li&gt;Remove unused users.&lt;/li&gt;
&lt;li&gt;Review permissions regularly.&lt;/li&gt;
&lt;li&gt;Use IAM roles where possible.&lt;/li&gt;
&lt;li&gt;Protect access keys.&lt;/li&gt;
&lt;li&gt;Monitor account activity.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2 id=&quot;cost-and-free-tier-awareness&quot;&gt;Cost and Free Tier Awareness&lt;/h2&gt;
&lt;p&gt;This exercise primarily uses informational console activities.&lt;/p&gt;
&lt;p&gt;However, always remember:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;AWS pricing changes over time.&lt;/li&gt;
&lt;li&gt;Free Tier eligibility changes.&lt;/li&gt;
&lt;li&gt;Different Regions may have different pricing.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Before launching resources:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Review AWS Pricing&lt;/li&gt;
&lt;li&gt;Review AWS Free Tier documentation&lt;/li&gt;
&lt;li&gt;Confirm current pricing directly in AWS documentation&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Never assume a resource is free because a tutorial says so.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;cleanup&quot;&gt;Cleanup&lt;/h2&gt;
&lt;h3 id=&quot;delete-test-iam-user&quot;&gt;Delete Test IAM User&lt;/h3&gt;
&lt;p&gt;Navigate to:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;IAM
→ Users
→ student-user
→ Delete
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;verify-deletion&quot;&gt;Verify Deletion&lt;/h3&gt;
&lt;p&gt;Confirm:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;User no longer exists
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;delete-additional-resources&quot;&gt;Delete Additional Resources&lt;/h3&gt;
&lt;p&gt;If you launched resources while experimenting, remove:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;EC2 Instances&lt;/li&gt;
&lt;li&gt;EBS Volumes&lt;/li&gt;
&lt;li&gt;Elastic IP Addresses&lt;/li&gt;
&lt;li&gt;Load Balancers&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Review each service manually to ensure cleanup is complete.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;intermediate-extension-challenge&quot;&gt;Intermediate Extension Challenge&lt;/h2&gt;
&lt;p&gt;Create:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Developers Group
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Attach:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ReadOnlyAccess
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Create multiple users.&lt;/li&gt;
&lt;li&gt;Add users to the group.&lt;/li&gt;
&lt;li&gt;Test permissions.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;skills-developed&quot;&gt;Skills Developed&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;IAM Groups&lt;/li&gt;
&lt;li&gt;Permission Management&lt;/li&gt;
&lt;li&gt;Least Privilege at Scale&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;advanced-professional-discussion&quot;&gt;Advanced / Professional Discussion&lt;/h2&gt;
&lt;p&gt;At the professional architect level, these concepts become organizational design decisions rather than basic AWS features.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;multi-account-strategy&quot;&gt;Multi-Account Strategy&lt;/h3&gt;
&lt;p&gt;Many organizations separate:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Development
Testing
Production
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;into different AWS accounts.&lt;/p&gt;
&lt;p&gt;Benefits:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Better security boundaries&lt;/li&gt;
&lt;li&gt;Easier auditing&lt;/li&gt;
&lt;li&gt;Reduced operational risk&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;multi-region-architectures&quot;&gt;Multi-Region Architectures&lt;/h3&gt;
&lt;p&gt;Mission-critical applications often deploy across multiple Regions.&lt;/p&gt;
&lt;p&gt;Benefits:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Disaster recovery&lt;/li&gt;
&lt;li&gt;Geographic redundancy&lt;/li&gt;
&lt;li&gt;Improved global user experience&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;advanced-iam&quot;&gt;Advanced IAM&lt;/h3&gt;
&lt;p&gt;Large organizations frequently adopt:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;IAM Roles&lt;/li&gt;
&lt;li&gt;Federation&lt;/li&gt;
&lt;li&gt;AWS IAM Identity Center&lt;/li&gt;
&lt;li&gt;Attribute-Based Access Control (ABAC)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;to manage access at scale.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;compliance-considerations&quot;&gt;Compliance Considerations&lt;/h3&gt;
&lt;p&gt;Region selection may be driven by:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Data residency requirements&lt;/li&gt;
&lt;li&gt;Regulatory obligations&lt;/li&gt;
&lt;li&gt;Customer contracts&lt;/li&gt;
&lt;li&gt;Disaster recovery requirements&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These considerations commonly appear in AWS architecture certification exams.&lt;/p&gt;
&lt;p&gt;References&lt;br&gt;
AWS IAM Documentation: &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/iam/&quot;&gt;https://docs.aws.amazon.com/iam/&lt;/a&gt;&lt;br&gt;
Getting Started with IAM: &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/IAM/latest/UserGuide/getting-started.html&quot;&gt;https://docs.aws.amazon.com/IAM/latest/UserGuide/getting-started.html&lt;/a&gt;&lt;br&gt;
AWS Regions and Availability Zones: &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/global-infrastructure/latest/regions/aws-regions-availability-zones.html&quot;&gt;https://docs.aws.amazon.com/global-infrastructure/latest/regions/aws-regions-availability-zones.html&lt;/a&gt;&lt;br&gt;
AWS Regions Documentation: &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/global-infrastructure/latest/regions/aws-regions.html&quot;&gt;https://docs.aws.amazon.com/global-infrastructure/latest/regions/aws-regions.html&lt;/a&gt;&lt;br&gt;
AWS Shared Responsibility Model: &lt;a rel=&quot;nofollow&quot; href=&quot;https://aws.amazon.com/compliance/shared-responsibility-model/&quot;&gt;https://aws.amazon.com/compliance/shared-responsibility-model/&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30007/aws-iam-regions-availability-zones-and-the-shared-responsibility-model</guid>
<pubDate>Tue, 06 Oct 2026 13:20:33 +0000</pubDate>
</item>
<item>
<title>Developer Weekly Briefing — October 2, 2026</title>
<link>https://coderlegion.com/29529/developer-weekly-briefing-october-2-2026</link>
<description>&lt;p&gt;A lighter week by volume — but the three security stories that did land are worth understanding carefully. Here's what mattered.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;a-crafted-package-name-was-enough-to-reach-an-ai-agent-s-aws-credentials&quot;&gt;A crafted package name was enough to reach an AI agent's AWS credentials&lt;/h3&gt;
&lt;p&gt;BeyondTrust's Phantom Labs researcher Sergio Garcia found two command injection vulnerabilities in the Amazon Bedrock AgentCore Python SDK's install_packages() function — and the attack surface was exactly as small as the title suggests.&lt;/p&gt;
&lt;p&gt;The SDK let agents install Python packages into a Code Interpreter sandbox by passing names to pip install. The first version validated names against a blocklist of five characters. It left out the newline. A name like &quot;pandas&quot; followed by a newline and a shell command reached Code Interpreter as two lines. The second ran. That's CVE-2026-12530, affecting SDK versions 1.1.3 through 1.6.0.&lt;/p&gt;
&lt;p&gt;AWS patched it with a regex allowlist. Garcia put a shell command substitution inside square brackets — the pip extras syntax — and the shell ran it before pip did. That's CVE-2026-16796, affecting versions 1.6.1 through 1.18.0. Both carry CVSS 4.0 scores of 8.4.&lt;/p&gt;
&lt;p&gt;Where the Code Interpreter session had an execution role attached, the same commands pulled the role's temporary AWS credentials. A function that only installs packages looks far safer to hand an agent than a shell. That's exactly why careful teams expose it.&lt;/p&gt;
&lt;p&gt;The fix that held arrived in version 1.18.1: quote the package name so the shell reads it as text, not a command. The logging detail worth acting on: both CloudTrail data events and AgentCore Observability application logs are off by default. CloudTrail doesn't record the command. The application logs do. Watch for references to the metadata service address 169.254.169.254 in those logs.&lt;/p&gt;
&lt;p&gt;Mitch Ashley's operational advice: &quot;Scope every role to what it needs, treat package names and dependency files as untrusted input, and turn on the AWS logging by default.&quot; If you're running agents on AgentCore, upgrade to 1.18.1 or later, audit your execution role permissions, and consider a fixed package list rather than one the agent can extend.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://techstrong.ai/articles/a-package-name-was-all-it-took-to-reach-an-ai-agents-aws-credentials/&quot;&gt;Read more&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;github-s-security-autofix-agent-now-remembers-what-it-fixed&quot;&gt;GitHub's security autofix agent now remembers what it fixed&lt;/h3&gt;
&lt;p&gt;GitHub agentic autofix — which handles security alerts by exploring relevant files, proposing a fix, rerunning CodeQL to confirm, and opening a draft PR — now uses Copilot Memory when enabled. Before working on an alert, it checks stored memories for context. After creating a fix, it saves the pattern.&lt;/p&gt;
&lt;p&gt;The more interesting part is where those patterns go. They don't stay inside autofix — they inform other Copilot features, including code review and the cloud agent, about how this specific repository handles security issues. A fix that runs through autofix can surface as a flag in code review before a similar mistake merges. That moves the work from cleaning up alerts after the fact to catching them earlier.&lt;/p&gt;
&lt;p&gt;The design detail worth noting: repository facts are stored with citations pointing to the code that supports them, and facts are validated against the current branch before use. Any unused fact is deleted after 28 days. For enterprise and organization plans, an administrator has to enable Memory before any of this applies. Both features are still in public preview.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devops.com/githubs-security-autofix-agent-now-remembers-what-it-fixed/&quot;&gt;Read more&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;ibm-bob-goes-self-hosted-reaching-the-enterprises-that-wouldn-t-send-code-to-the-cloud&quot;&gt;IBM Bob goes self-hosted — reaching the enterprises that wouldn't send code to the cloud&lt;/h3&gt;
&lt;p&gt;IBM made Bob generally available as SaaS in April and said on-premises was coming. It's here. Bob can now run on-premises, in private and sovereign clouds, and in fully air-gapped environments with no outside network connection.&lt;/p&gt;
&lt;p&gt;The target market is clear: banks, insurers, government agencies, and organizations with mainframe modernization work they're unwilling to push through someone else's infrastructure. IBM says more than 80,000 employees now use Bob internally, up from 100 developers when internal use began in June 2025, with surveyed users reporting an average 45% productivity gain. The IBM Z Premium Package also gets self-hosted support and deeper mainframe-specific context.&lt;/p&gt;
&lt;p&gt;Mitch Ashley's framing is worth keeping: &quot;Bob is IBM's bid for a beachhead with enterprise developers, and self-hosting puts it where cloud-first coding agents struggle to reach. An agent trusted within those core systems is positioned to become the platform on which the same teams build their own agents. That makes choosing Bob a multi-year commitment.&quot;&lt;/p&gt;
&lt;p&gt;Self-hosting moves the operational burden to the customer: provisioning GPU capacity, managing model updates, monitoring agent behavior, maintaining audit trails. Air-gapped environments also narrow available models. Worth pricing that operational overhead before committing.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devops.com/ibm-moves-its-bob-coding-agent-inside-the-firewall/&quot;&gt;Read more&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;git-2-56-conflict-markers-branch-cleanup-and-performance-at-scale&quot;&gt;Git 2.56: conflict markers, branch cleanup, and performance at scale&lt;/h3&gt;
&lt;p&gt;Git 2.56 ships with changes that matter at two levels — safety guardrails for everyday use and performance improvements that compound at scale.&lt;/p&gt;
&lt;p&gt;The new &lt;code&gt;git add --resolved&lt;/code&gt; only stages currently unmerged paths and scans them for leftover conflict markers before staging. The new &lt;code&gt;git branch --delete-merged&lt;/code&gt; cleans up merged branches with pattern matching and a --dry-run preview. Both are exactly the guardrails agent workflows need — Mitch Ashley: &quot;An agent that opens branches, resolves conflicts, and rebases hits every rough edge constantly, and a conflict marker staged into a shared branch becomes verification debt someone must find.&quot;&lt;/p&gt;
&lt;p&gt;On performance: merge-base lookups on the Linux kernel dropped from 167,441 traversal steps to 3,887. A path-limited diff on a Chromium checkout went from about eight minutes to 0.07 seconds. Path-walk repacking on Fluent UI shrank a 558.5 MB pack to 164.4 MB — 71% smaller. For teams running large repositories, or agents running Git at machine speed, the cumulative savings are significant.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devops.com/git-2-56-takes-aim-at-messy-merges-slow-diffs-and-branch-sprawl/&quot;&gt;Read more&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;also-from-coder-legion-and-insights-from-analytics-this-week&quot;&gt;Also from Coder Legion and Insights From Analytics this week&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CData's new AI gateway fixes the three reasons enterprise AI stalls.&lt;/strong&gt; Context — or the lack of it — is the most common reason enterprise AI projects don't reach production. CData's approach puts the context layer at the center rather than treating it as an afterthought. &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/28982/cdatas-new-ai-gateway-puts-context-at-the-center-fixes-the-three-reasons-enterprise-stalls&quot;&gt;Read more&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What developers already know about data center delays.&lt;/strong&gt; The supply chain reality behind GPU availability and data center timelines, from a developer perspective. &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/28977/what-developers-already-know-about-data-center-delays&quot;&gt;Read more&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PSI's Emmy: 500,000 designs, one physicist in the loop.&lt;/strong&gt; How PSI uses AI to rethink data center trade-offs at a scale no human design process could cover. &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/28968/500-000-designs-one-physicist-in-the-loop-how-psis-emmy-rethinks-data-center-trade-offs&quot;&gt;Read more&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Developer Spotlight: Mike Dabydeen on Rust, AI, and engineering leadership.&lt;/strong&gt; A conversation with the developer whose comments on the Coder Legion PocketOS thread generated some of the best technical discussion we've published this year. &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/29467/mike-dabydeen-on-rust-ai-engineering-leadership&quot;&gt;Read more&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Every AI agent has a boss. It's time we named them.&lt;/strong&gt; From Insights From Analytics — the accountability question that most agentic AI governance frameworks are still avoiding. &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.insightsfromanalytics.com/post/every-ai-agent-has-a-boss-it-s-time-we-named-them&quot;&gt;Read more&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;The through-line this week: agent execution roles, credential scope, and memory persistence are the new attack surface. A package name reaches AWS credentials. A fix pattern teaches a code reviewer. A coding agent goes air-gapped behind a firewall. All three stories are about where agents sit in the trust model — and how much damage the wrong scope creates when something goes wrong.&lt;/p&gt;
&lt;p&gt;See you next Friday.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Developer Weekly Briefing is published every Friday on &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com&quot;&gt;Coder Legion&lt;/a&gt;. Written by &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/user/Tom+Smith&quot;&gt;Tom Smith&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29529/developer-weekly-briefing-october-2-2026</guid>
<pubDate>Fri, 02 Oct 2026 12:18:45 +0000</pubDate>
</item>
<item>
<title>Why the Enterprise Demands VPC for MCP: Deep Dive into SEOSiri’s Isolated AI Security Blueprint</title>
<link>https://coderlegion.com/29368/why-the-enterprise-demands-vpc-for-mcp-deep-dive-into-seosiris-isolated-security-blueprint</link>
<description>&lt;h3 id=&quot;section-1&quot;&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=15612186850484753467&quot; alt=&quot;&quot;&gt;&lt;/h3&gt;
&lt;h3 id=&quot;section-2&quot;&gt;&lt;/h3&gt;
&lt;p&gt;The adoption of the Model Context Protocol (MCP) has changed how developers orchestrate contextual data between Large Language Models (LLMs) and systems. However, as autonomous AI agents move from local terminal playgrounds into the corporate workspace, a significant structural friction point emerges: Network Security.&lt;br&gt;
Engineering and InfoSec teams have reached a turning point where exposing internal resources over standard public proxies is no longer viable.&lt;/p&gt;
&lt;h3 id=&quot;this-post-analyzes-the-architectural-necessity-of-supporting-virtual-private-clouds-vpc-for-mcp-architectures-details-how-the-updated-enterprise-suite-handles-this-natively-and-explores-why-the-corporate-landscape-is-rapidly-adopting-seosiri-solutions-&quot;&gt;This post analyzes the architectural necessity of supporting Virtual Private Clouds (VPC) for MCP architectures, details how the updated enterprise suite handles this natively, and explores why the corporate landscape is rapidly adopting &lt;a rel=&quot;nofollow&quot; href=&quot;https://developers.seosiri.com/#manual&quot;&gt;SEOSiri Solutions&lt;/a&gt;.&lt;/h3&gt;
&lt;h3 id=&quot;1-the-architectural-problem-why-mcp-needed-vpc-protection&quot;&gt;1. The Architectural Problem: Why MCP Needed VPC Protection&lt;/h3&gt;
&lt;p&gt;The Model Context Protocol establishes a direct interface enabling AI clients to read schemas, execute local development scripts, and query underlying file structures. While highly effective, exposing this capability across public endpoints presents fundamental enterprise challenges:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;li&gt;Data Leakage Exposures: Without isolated boundaries, every database pointer, microservice context layer, and internal schema route traverses standard public internet paths.&lt;/li&gt;
&lt;li&gt;Compliance Deadlocks: Regulated sectors (such as FinTech, Healthcare, and Defense) operate under strict internal mandates that strictly prohibit public ingress or egress configurations for core computational stacks.&lt;/li&gt;
&lt;li&gt;Attack Surface Multipliers: Traditional public gateways require persistent firewall openings, increasing the vulnerability of internal development infrastructure to lateral scan vulnerabilities.&lt;/li&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;by-introducing-native-vpc-support-mcp-operations-are-brought-directly-into-standard-zero-trust-network-boundaries-enabling-secure-ai-agent-interaction-behind-isolated-perimeters-&quot;&gt;By introducing native VPC Support, MCP operations are brought directly into standard zero-trust network boundaries, enabling secure AI agent interaction behind isolated perimeters.&lt;/h3&gt;
&lt;h3 id=&quot;2-deep-dive-how-seosiri-bridges-the-isolated-network-layer&quot;&gt;2. Deep Dive: How SEOSiri Bridges the Isolated Network Layer&lt;/h3&gt;
&lt;p&gt;The latest deployment module from the &lt;a rel=&quot;nofollow&quot; href=&quot;https://developers.seosiri.com&quot;&gt;SEOSiri Developer Portal&lt;/a&gt; adapts the core Model Context Protocol to operate within fully locked-down cloud network structures:&lt;/p&gt;
&lt;p&gt;[ Isolated Core VPC Subnet ]&lt;br&gt;
  ├── Internal Data Source / DB&lt;br&gt;
  ├── Proprietary Private Repository&lt;br&gt;
  └── SEOSiri Hardened MCP Server ──► [ Intercept / Scrubbing Layer ] ──► [ Local AI Client ]&lt;/p&gt;
&lt;h3 id=&quot;key-technical-capabilities-built-into-the-security-layer&quot;&gt;Key Technical Capabilities Built into the Security Layer&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;li&gt;Private Ingress Restrictions: Restricts incoming traffic utilizing granular CIDR-block parsing logic, ensuring only explicitly allowed cluster configurations can trigger context exchanges.&lt;/li&gt;
&lt;li&gt;The PII Shield Intercept: Integrated directly within the internal server node, this pipeline monitors outgoing strings, automatically redacting and tokenizing sensitive values before structural arrays reach model reasoning endpoints.&lt;/li&gt;
&lt;li&gt;Hybrid Cloud Topologies: Native multi-cloud support allows identical initialization frameworks whether your architecture resides on AWS VPC, Google Cloud VPC, or Microsoft Azure VNets.&lt;/li&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-operational-advantages-the-core-benefits&quot;&gt;3. Operational Advantages: The Core Benefits&lt;/h3&gt;
&lt;p&gt;Implementing an enterprise-grade VPC strategy for your contextual AI layer yields immediate architectural dividends:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;/li&gt;
&lt;li&gt;Sub-Millisecond Processing Rates: Running the gateway close to local database partitions reduces transit latency overhead during highly interactive multi-turn agent execution sequences.&lt;/li&gt;
&lt;li&gt;Native Log Stream Aggregation: System telemetry routes directly into your internal logging tools (like AWS CloudWatch, Google Cloud Operations Suite, or Datadog), keeping compliance audits fully within your administrative perimeter.&lt;/li&gt;
&lt;li&gt;Consistent IAM Authentication Policies: Connects directly into existing enterprise access managers using OpenID Connect (OIDC) or strict OAuth2 verification schemas without requiring secondary secret tokens.&lt;/li&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-why-the-corporate-world-is-standardizing-on-seosiri-mcp&quot;&gt;4. Why the Corporate World is Standardizing on SEOSiri MCP&lt;/h3&gt;
&lt;p&gt;Enterprise procurement and security management groups are shifting away from fragmented open-source wrappers toward verified setups for several critical reasons:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Production-Grade Stability: Rather than fragile point-to-point scripts, it provides an audited platform tailored for high-concurrency enterprise microservices.&lt;/li&gt;
&lt;li&gt;Simplified Compliance Mapping: Pre-configured network isolation maps provide simple documentation validation for internal security review teams during software vetting cycles.&lt;/li&gt;
&lt;li&gt;Future-Proof Extensibility: The platform offers structured integration options that work seamlessly with modern development tools like Claude Desktop, Cursor AI, and Atlassian Rovo without modifying original backend logic.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h4 id=&quot;-deploy-within-your-private-network-vpc-tunnels&quot;&gt;🛠️ Deploy Within Your Private Network / VPC Tunnels&lt;/h4&gt;
&lt;p&gt;Setting up an isolated instance within your internal network or Cloudflare Zero Trust framework requires minimal configuration changes. Configure the required secure environment flags upon runtime initialization:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Initialize the SEOSiri hardened edge gateway via Cloudflare Zero Trust Tunnel (cloudflared)
# or via private container backend within your isolated VPC subnet:
docker run -d \
  --name seosiri-mcp-vpc-instance \
  --network=&quot;vpc-private-backend&quot; \
  --publish 127.0.0.1:8080:8080 \
  -e ALLOWED_CIDR_BLOCKS=&quot;10.0.0.0/16&quot; \
  -e ENCRYPTION_KEY_SECRET=&quot;$VPC_ENCRYPTION_KEY&quot; \
  -e OAUTH_DISCOVERY_URL=&quot;https://developers.seosiri.com/.well-known/openid-configuration&quot; \
  ghcr.io/seosiri-official/stal-mcp:latest

To access comprehensive configuration templates, cluster orchestration manifests, and step-by-step guides, visit the documentation space on the [SEOSiri Developer Portal][1].
For additional insights into this architecture and long-term release updates, check out the official announcement breakdown on the [SEOSiri Live Announcement Page][2].
------------------------------


  [1]: https://developers.seosiri.com/
  [2]: https://www.seosiri.com/2026/10/seosiri-mcp-vpc-support.html
&lt;/code&gt;&lt;/pre&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29368/why-the-enterprise-demands-vpc-for-mcp-deep-dive-into-seosiris-isolated-security-blueprint</guid>
<pubDate>Thu, 01 Oct 2026 08:36:31 +0000</pubDate>
</item>
<item>
<title>Building Email Aliases with Amazon SES: Lessons from EpicMail.org</title>
<link>https://coderlegion.com/27964/building-email-aliases-with-amazon-ses-lessons-from-epicmail-org</link>
<description>&lt;p&gt;You create an email address for a project. A month later, the project ends, but the email keeps coming.&lt;/p&gt;
&lt;p&gt;Having a separate address helped you organize the mail. Being able to switch that address off would make it much more useful.&lt;/p&gt;
&lt;p&gt;That is the idea behind managed aliases in EpicMail. Instead of giving users another mailbox to maintain, we wanted to give them more control over the addresses feeding the mailbox they already have.&lt;/p&gt;
&lt;p&gt;I'm Serge, the founder of EpicMail. We use Amazon SES for our built-in email service. Building aliases around it led to some useful product and engineering lessons, especially about forwarding, identity, and what a successful send actually means.&lt;/p&gt;
&lt;h3 id=&quot;decide-what-an-alias-promises&quot;&gt;Decide what an alias promises&lt;/h3&gt;
&lt;p&gt;Consider these example addresses:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;john+project [AT] epicmail.org
john+shopping [AT] epicmail.org
john+newsletters [AT] epicmail.org
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;They all belong to the same base mailbox, but each can represent a different relationship. A project has an end date. A shopping address might become noisy. Newsletters might be more useful in another account.&lt;/p&gt;
&lt;p&gt;We chose aliases that users explicitly create and manage. Once created, an alias can receive mail, appear as the sending address, show activity, be blocked, or forward to another connected account the user owns.&lt;/p&gt;
&lt;p&gt;An arbitrary suffix does not automatically become an active EpicMail address. That is a deliberate product choice: the aliases area should show the addresses the user has chosen to use.&lt;/p&gt;
&lt;p&gt;There is also a privacy distinction worth making clear. Plus addresses expose the base address. They are useful for organization and control, but they are not anonymous email masks. Someone who sees &lt;code&gt;john+shopping [AT] epicmail.org&lt;/code&gt; can also see &lt;code&gt;john&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;know-what-ses-provides&quot;&gt;Know what SES provides&lt;/h3&gt;
&lt;p&gt;SES gives developers sending and receiving capabilities. The user experience around an alias is something the application still has to define.&lt;/p&gt;
&lt;p&gt;For straightforward sending, a verified domain identity can cover addresses beneath that domain without individually verifying each address. AWS documents the details and exceptions in its &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/ses/latest/dg/creating-identities.html&quot;&gt;identity verification guide&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Receiving is a separate setup. It requires a supported receiving region, the appropriate MX records, receipt rules, and permissions for the resources involved. The &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/ses/latest/dg/receiving-email-receipt-rules-console-walkthrough.html&quot;&gt;AWS receipt-rule walkthrough&lt;/a&gt; covers those pieces.&lt;/p&gt;
&lt;p&gt;Those capabilities make a feature like this possible. They do not decide whether an alias should work automatically, which account should receive a forward, or what a user should see after blocking an address.&lt;/p&gt;
&lt;p&gt;Writing down those behaviors before building the screens makes the implementation much easier to reason about.&lt;/p&gt;
&lt;h3 id=&quot;test-the-whole-conversation&quot;&gt;Test the whole conversation&lt;/h3&gt;
&lt;p&gt;Receiving a message is only the start. A user also expects to reply without accidentally switching identities.&lt;/p&gt;
&lt;p&gt;For an alias feature, a useful acceptance test is a complete conversation:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Send a message to the alias from another mailbox.&lt;/li&gt;
&lt;li&gt;Open it and reply using the alias.&lt;/li&gt;
&lt;li&gt;Check the address the other person sees.&lt;/li&gt;
&lt;li&gt;Reply back and confirm the conversation returns to the intended mailbox.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Do the same with a message sent directly from the alias. It is easy to make the compose selector look correct while overlooking what happens at the other end.&lt;/p&gt;
&lt;p&gt;BCC deserves a test too. The visible &lt;code&gt;To&lt;/code&gt; and &lt;code&gt;Cc&lt;/code&gt; headers do not describe every legitimate delivery. AWS distinguishes envelope recipients from message headers in its &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/ses/latest/dg/receiving-email-notifications-contents.html&quot;&gt;receiving-notification documentation&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;These are useful tests even if you use a different email provider. The recipient experiences the entire exchange, not just your compose window.&lt;/p&gt;
&lt;h3 id=&quot;forwarding-changes-the-inbox-experience&quot;&gt;Forwarding changes the inbox experience&lt;/h3&gt;
&lt;p&gt;Suppose someone forwards an alias to another account they have also connected to EpicMail. If both copies remain in their inboxes, a unified view can show the same message twice.&lt;/p&gt;
&lt;p&gt;The forward worked, but the resulting experience is annoying.&lt;/p&gt;
&lt;p&gt;We added an auto-archive option alongside alias forwarding. It lets the user keep the original stored in the EpicMail mailbox while clearing it from Inbox after the forward is accepted for sending. Archiving preserves the message without keeping it in the way.&lt;/p&gt;
&lt;p&gt;It also makes the setting easier to explain. The user chooses where they want to read the message, while retaining a copy in the original mailbox.&lt;/p&gt;
&lt;p&gt;A forwarding feature should answer that practical question, not leave the user to discover the duplicate-copy problem afterward.&lt;/p&gt;
&lt;h3 id=&quot;use-honest-status-language&quot;&gt;Use honest status language&lt;/h3&gt;
&lt;p&gt;Email has more than one meaning of &quot;sent.&quot;&lt;/p&gt;
&lt;p&gt;Your sending service can accept a message before the destination server receives it. Even acceptance by a destination server does not guarantee that a person will find it in their inbox or read it.&lt;/p&gt;
&lt;p&gt;SES provides separate &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/ses/latest/dg/monitor-sending-activity-using-notifications-sns.html&quot;&gt;delivery, bounce, and complaint notifications&lt;/a&gt;. Those events help describe different outcomes; they should not be collapsed into an unsupported promise that every accepted message reached a person's inbox.&lt;/p&gt;
&lt;p&gt;This affects product language as much as infrastructure. A count of forwarded messages needs a clear meaning. An error needs to tell the user whether they should take action. A success message should describe the stage the application actually knows about.&lt;/p&gt;
&lt;p&gt;For our auto-archive option, the boundary is acceptance for sending. It is not a guarantee of final delivery, and a later bounce does not automatically restore an already archived original. That is a useful limitation to state plainly.&lt;/p&gt;
&lt;h3 id=&quot;treat-suppression-as-part-of-deliverability&quot;&gt;Treat suppression as part of deliverability&lt;/h3&gt;
&lt;p&gt;SES suppression lists are another reason an address can look valid without a message being delivered as expected.&lt;/p&gt;
&lt;p&gt;AWS distinguishes bounce and complaint reasons, and its account-level suppression list applies to an AWS account in a particular region. The &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/ses/latest/dg/sending-email-suppression-list.html&quot;&gt;suppression-list documentation&lt;/a&gt; explains the behavior and available controls.&lt;/p&gt;
&lt;p&gt;For developers, the lesson is to understand the reason and scope before changing suppression settings. A complaint carries different meaning from a delivery failure. Removing an entry is not a universal fix for deliverability, and changing your own account's list does not control another sender's system.&lt;/p&gt;
&lt;p&gt;This is part of the operational knowledge needed around email features, even when the interface itself is small.&lt;/p&gt;
&lt;h3 id=&quot;make-control-easy-to-find&quot;&gt;Make control easy to find&lt;/h3&gt;
&lt;p&gt;A useful aliases screen should answer ordinary questions quickly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;What did I create this address for?&lt;/li&gt;
&lt;li&gt;Is it still active?&lt;/li&gt;
&lt;li&gt;Where does its mail go?&lt;/li&gt;
&lt;li&gt;Has it been used?&lt;/li&gt;
&lt;li&gt;How do I stop receiving mail through it?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In EpicMail, aliases have an area for management and activity, with search and paging as the list grows. The sending identity is available from the compose window, where that choice belongs.&lt;/p&gt;
&lt;p&gt;The aliases share the original mailbox's received-message allowance. Creating more addresses does not create additional mailboxes or additional allowances. Keeping that relationship clear avoids surprises later.&lt;/p&gt;
&lt;h3 id=&quot;why-we-added-this-to-epicmail&quot;&gt;Why we added this to EpicMail&lt;/h3&gt;
&lt;p&gt;EpicMail brings existing accounts into one place: &lt;strong&gt;A unified inbox for Gmail, Outlook, Yahoo, iCloud, IMAP, and POP3.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Managed aliases add another way to organize incoming mail, specifically through the optional paid &lt;code&gt;@epicmail.org&lt;/code&gt; inbox. They are included with that inbox without a separate fee per alias, and share its 5,000-message received allowance per usage period. This feature does not create aliases at a user's connected Gmail, Outlook, or other provider account.&lt;/p&gt;
&lt;p&gt;There is a walkthrough on the &lt;a rel=&quot;nofollow&quot; href=&quot;https://epicmail.org/email-aliases?utm_source=coderlegion&amp;amp;utm_medium=referral&amp;amp;utm_campaign=epicmail_aliases_2026&amp;amp;utm_content=ses_aliases_article&quot;&gt;EpicMail email aliases page&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The design question I would leave other developers with is this: when someone no longer wants to use an address, how easily can they take control of it? That answer can make an alias much more useful than a different spelling of the same inbox.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27964/building-email-aliases-with-amazon-ses-lessons-from-epicmail-org</guid>
<pubDate>Mon, 21 Sep 2026 14:02:27 +0000</pubDate>
</item>
<item>
<title>Beyond the Model: Why AWS Spent $8B on Generators &amp; What It Means for Cloud Architecture</title>
<link>https://coderlegion.com/27660/beyond-the-model-why-aws-spent-8b-on-generators-what-it-means-for-cloud-architecture</link>
<description>&lt;p&gt;Hey Legions,&lt;/p&gt;
&lt;p&gt;When we think about scaling AI, we usually talk about model params, CUDA kernels, or vector search efficiency. But there’s a physical limit we’re rapidly hitting: Raw Grid Power.&lt;/p&gt;
&lt;p&gt;Amazon Web Services (AWS) just locked in an $8 Billion contract with Generac (plus a 2.6% stock warrant) to supply industrial backup generators for their AI data centers. First $2.4B in hardware lands in 2027-2028.&lt;/p&gt;
&lt;p&gt;Here is a quick breakdown of the backend realities driving this deal:&lt;/p&gt;
&lt;p&gt;Checkpoint Loss Risk: Training massive models takes weeks across thousands of GPUs. A 100ms grid flicker doesn't just drop a connection; it corrupts active memory states and destroys millions of dollars in compute time.&lt;/p&gt;
&lt;p&gt;Continuous Heavy Load: A hyperscale AI data center draws 100 to 500 Megawatts continuously. That is equivalent to powering up to 375,000 homes.&lt;/p&gt;
&lt;p&gt;Grid Upgrade Mandates: With legislation like the Ratepayer Protection Act passing, cloud providers are now forced to pay full infrastructure costs to upgrade public grids.&lt;/p&gt;
&lt;p&gt;The Hardware Bottleneck: Power availability—not just GPU allocation—is now the primary bottleneck for scaling LLMs and cloud clusters.&lt;/p&gt;
&lt;p&gt;As devs, are you seeing infrastructure costs or cloud provider power limits affecting how you architect your backends, or are you mostly relying on managed APIs?&lt;/p&gt;
&lt;p&gt;👉 Read my full deep-dive analysis here: [https://www.thefluxread.com/2026/09/amazon-just-spent-8-billion-on.html]&lt;/p&gt;
&lt;h2 id=&quot;cloud-devops-systemarchitecture-aws-ai-infrastructure&quot;&gt;Cloud #DevOps #SystemArchitecture #AWS #AI #Infrastructure&lt;/h2&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27660/beyond-the-model-why-aws-spent-8b-on-generators-what-it-means-for-cloud-architecture</guid>
<pubDate>Fri, 18 Sep 2026 15:57:55 +0000</pubDate>
</item>
<item>
<title>SafeLine WAF vs AWS WAF: Self-Hosted Control vs Managed Cloud Rules</title>
<link>https://coderlegion.com/26768/safeline-waf-vs-aws-waf-self-hosted-control-vs-managed-cloud-rules</link>
<description>&lt;h2 id=&quot;safeline-waf-vs-aws-waf-self-hosted-control-vs-managed-cloud-rules&quot;&gt;SafeLine WAF vs AWS WAF: Self-Hosted Control vs Managed Cloud Rules&lt;/h2&gt;
&lt;p&gt;If your app lives on AWS, AWS WAF is the path of least resistance: it slots into&lt;br&gt;
ALB, CloudFront, and API Gateway. SafeLine is a self-hosted alternative that works whether or not&lt;br&gt;
you are on AWS. Here is how to think about the choice.&lt;/p&gt;
&lt;h3 id=&quot;aws-waf-integrated-but-tied-to-the-platform&quot;&gt;AWS WAF: integrated, but tied to the platform&lt;/h3&gt;
&lt;p&gt;AWS WAF gives you managed rule groups, rate-based rules, and tight integration with the rest of&lt;br&gt;
your AWS bill and IAM. The catch is lock-in: it only protects AWS-fronted endpoints, and costs&lt;br&gt;
scale with requests and rule evaluations.&lt;/p&gt;
&lt;h3 id=&quot;safeline-portable-and-self-hosted&quot;&gt;SafeLine: portable and self-hosted&lt;/h3&gt;
&lt;p&gt;SafeLine runs as a container anywhere — on EC2, in a bare-metal rack, or on another cloud. You&lt;br&gt;
get the same semantic-analysis engine regardless of where your app lives, and the Community&lt;br&gt;
Edition is free. For a team that runs a mixed or non-AWS fleet, that portability is the headline&lt;br&gt;
benefit.&lt;/p&gt;
&lt;h3 id=&quot;operational-differences&quot;&gt;Operational differences&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Cost model&lt;/strong&gt;: AWS bills per rule and per request; SafeLine's self-hosted Community Edition&lt;br&gt;
has no per-request fee.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Control&lt;/strong&gt;: SafeLine puts the dashboard and policy in your hands; AWS WAF is managed through&lt;br&gt;
the AWS console/CLI.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Detection&lt;/strong&gt;: SafeLine's semantic engine needs less signature tuning than AWS WAF's rule&lt;br&gt;
groups.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;when-aws-waf-still-wins&quot;&gt;When AWS WAF still wins&lt;/h3&gt;
&lt;p&gt;If you are all-in on AWS and want managed rules with zero servers to patch, AWS WAF is convenient.&lt;br&gt;
SafeLine wins when you want to avoid per-request cost and keep protection consistent across&lt;br&gt;
environments.&lt;/p&gt;
&lt;h3 id=&quot;faq&quot;&gt;FAQ&lt;/h3&gt;
&lt;h4 id=&quot;can-safeline-protect-an-alb-backed-app-&quot;&gt;Can SafeLine protect an ALB-backed app?&lt;/h4&gt;
&lt;p&gt;Yes — run SafeLine as a reverse proxy in front of the ALB or as the entry point.&lt;/p&gt;
&lt;h4 id=&quot;is-there-a-free-tier-&quot;&gt;Is there a free tier?&lt;/h4&gt;
&lt;p&gt;SafeLine's Community Edition is free (10 apps / 800 QPS).&lt;/p&gt;
&lt;h4 id=&quot;how-fast-is-setup-&quot;&gt;How fast is setup?&lt;/h4&gt;
&lt;p&gt;A single container deploy completes in minutes with the official install script.&lt;/p&gt;
&lt;h3 id=&quot;wrap-up&quot;&gt;Wrap Up&lt;/h3&gt;
&lt;p&gt;If you want a &lt;strong&gt;self-hosted WAF&lt;/strong&gt; you fully control, SafeLine is worth a look. It sits in&lt;br&gt;
front of your existing stack as a container, uses a semantic-analysis engine to catch SQL&lt;br&gt;
injection, XSS, and bot abuse with very low false positives, and the &lt;strong&gt;Community Edition is&lt;br&gt;
free&lt;/strong&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Official docs: &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;https://docs.waf.chaitin.com/en/home&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/chaitin/SafeLine&quot;&gt;https://github.com/chaitin/SafeLine&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Live demo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://demo.waf.chaitin.com:9443/statistics&quot;&gt;https://demo.waf.chaitin.com:9443/statistics&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Deploy it in minutes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;bash -c &quot;$(curl -fsSLk https://waf.chaitin.com/release/latest/manager.sh)&quot; -- --en
&lt;/code&gt;&lt;/pre&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26768/safeline-waf-vs-aws-waf-self-hosted-control-vs-managed-cloud-rules</guid>
<pubDate>Fri, 11 Sep 2026 01:59:47 +0000</pubDate>
</item>
<item>
<title>Infrastructure as Code with AWS CloudFormation</title>
<link>https://coderlegion.com/26565/infrastructure-as-code-with-aws-cloudformation</link>
<description>&lt;p&gt;By the end of this guide, you will not just know what CloudFormation does. You will understand why each feature exists, what problem it solves, what breaks without it, and how features chain together to solve real infrastructure problems. Every concept is introduced with a problem first, not a definition first.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;table-of-contents&quot;&gt;Table of Contents&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;What is CloudFormation and Why It Matters&lt;/li&gt;
&lt;li&gt;Logical and Physical Resources&lt;/li&gt;
&lt;li&gt;Templates: Portable vs Non-Portable&lt;/li&gt;
&lt;li&gt;Template Parameters and Pseudo Parameters&lt;/li&gt;
&lt;li&gt;Intrinsic Functions&lt;/li&gt;
&lt;li&gt;Mappings&lt;/li&gt;
&lt;li&gt;Outputs&lt;/li&gt;
&lt;li&gt;Conditions&lt;/li&gt;
&lt;li&gt;DependsOn&lt;/li&gt;
&lt;li&gt;Wait Conditions and cfn-signal&lt;/li&gt;
&lt;li&gt;cfn-init&lt;/li&gt;
&lt;li&gt;cfn-hup&lt;/li&gt;
&lt;li&gt;Nested Stacks&lt;/li&gt;
&lt;li&gt;Cross-Stack References&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-is-cloudformation-and-why-it-matters&quot;&gt;What is CloudFormation and Why It Matters&lt;/h3&gt;
&lt;h4 id=&quot;the-problem-it-solves&quot;&gt;The Problem It Solves&lt;/h4&gt;
&lt;p&gt;Imagine you are building a three-tier web application. You need a VPC, subnets, an EC2 instance, a security group, an RDS database, an S3 bucket, and a load balancer. You click through the AWS Console and get it working. Three weeks later, a colleague asks you to replicate the exact environment for staging. You click through everything again. Two hours later, something is different you missed a security group rule, the subnet CIDR is wrong, and the RDS instance has a different parameter group.&lt;/p&gt;
&lt;p&gt;This is the core problem. Manual infrastructure is not repeatable, not auditable, and not scalable.&lt;/p&gt;
&lt;p&gt;CloudFormation is AWS's answer. You describe your infrastructure in a template a YAML or JSON file and CloudFormation takes that description and builds the real AWS resources. The template becomes the single source of truth.&lt;/p&gt;
&lt;h4 id=&quot;what-cloudformation-actually-is&quot;&gt;What CloudFormation Actually Is&lt;/h4&gt;
&lt;p&gt;CloudFormation is a declarative infrastructure provisioning engine. You declare &lt;em&gt;what&lt;/em&gt; you want, not &lt;em&gt;how&lt;/em&gt; to create it. CloudFormation figures out the &lt;em&gt;how&lt;/em&gt;, including the order in which resources must be created, the dependencies between them, and what to clean up if something fails.&lt;/p&gt;
&lt;h4 id=&quot;why-it-matters-beyond-convenience&quot;&gt;Why It Matters Beyond Convenience&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Repeatability&lt;/strong&gt; The same template deployed ten times produces ten identical environments.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Version control&lt;/strong&gt; Your infrastructure lives in Git. Every change is tracked. Every rollback is a &lt;code&gt;git revert&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Accountability&lt;/strong&gt; Who changed the security group? Check the commit history.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Speed&lt;/strong&gt; A 40-resource stack that takes two hours to click through manually deploys in minutes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Disaster recovery&lt;/strong&gt; When a region fails, you re-deploy the template in another region. Your infrastructure is code, not memory.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cost&lt;/strong&gt; Stacks can be deleted entirely after use. Temporary environments cost nothing when they are gone.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;CloudFormation is not just a tool. It is a practice the practice of treating infrastructure with the same discipline as application code.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;logical-and-physical-resources&quot;&gt;Logical and Physical Resources&lt;/h3&gt;
&lt;h4 id=&quot;the-concept&quot;&gt;The Concept&lt;/h4&gt;
&lt;p&gt;This is the foundation. If you misunderstand this, everything else will be confusing.&lt;/p&gt;
&lt;p&gt;A &lt;strong&gt;logical resource&lt;/strong&gt; is what you write in the template. It is a declaration a name you give to a resource and a description of what you want.&lt;/p&gt;
&lt;p&gt;A &lt;strong&gt;physical resource&lt;/strong&gt; is what AWS actually creates when CloudFormation processes your template. It has a real ID an instance ID, a bucket name, a security group ID.&lt;/p&gt;
&lt;h4 id=&quot;the-relationship&quot;&gt;The Relationship&lt;/h4&gt;
&lt;p&gt;When you write this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Resources:
  MyWebServer:
    Type: AWS::EC2::Instance
    Properties:
      InstanceType: t3.micro
      ImageId: ami-0c55b159cbfafe1f0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;MyWebServer&lt;/code&gt; is the logical resource. The actual EC2 instance that gets created say &lt;code&gt;i-0a1b2c3d4e5f6&lt;/code&gt; is the physical resource.&lt;/p&gt;
&lt;p&gt;The logical resource exists only in your template. The physical resource exists in AWS.&lt;/p&gt;
&lt;h4 id=&quot;why-this-distinction-matters&quot;&gt;Why This Distinction Matters&lt;/h4&gt;
&lt;p&gt;CloudFormation tracks the mapping between logical and physical resources in something called the &lt;strong&gt;stack&lt;/strong&gt;. This mapping is what enables CloudFormation to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Update a resource when you change the template&lt;/li&gt;
&lt;li&gt;Replace a resource when a change requires replacement&lt;/li&gt;
&lt;li&gt;Delete all physical resources when you delete the stack&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you manually delete a physical resource that CloudFormation manages, the stack loses track of it. On the next update or delete, CloudFormation will fail or behave unpredictably because the physical resource it expects to find is gone.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Rule:&lt;/strong&gt; Never manually modify a physical resource that is managed by a CloudFormation stack. Make the change in the template instead.&lt;/p&gt;
&lt;h4 id=&quot;what-happens-during-stack-creation&quot;&gt;What Happens During Stack Creation&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;CloudFormation reads your template.&lt;/li&gt;
&lt;li&gt;It builds a dependency graph of all logical resources.&lt;/li&gt;
&lt;li&gt;It creates physical resources in the correct order.&lt;/li&gt;
&lt;li&gt;It maps each logical resource to its new physical resource.&lt;/li&gt;
&lt;li&gt;The stack reaches &lt;code&gt;CREATE_COMPLETE&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If any resource creation fails, CloudFormation rolls back it deletes everything it already created and the stack reaches &lt;code&gt;ROLLBACK_COMPLETE&lt;/code&gt;. Nothing is left half-built.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;templates-portable-vs-non-portable&quot;&gt;Templates: Portable vs Non-Portable&lt;/h3&gt;
&lt;h4 id=&quot;the-problem-with-non-portable-templates&quot;&gt;The Problem with Non-Portable Templates&lt;/h4&gt;
&lt;p&gt;Here is a simple template:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;AWSTemplateFormatVersion: &quot;2010-09-09&quot;
Description: Simple EC2 instance

Resources:
  MyInstance:
    Type: AWS::EC2::Instance
    Properties:
      InstanceType: t3.micro
      ImageId: ami-0c55b159cbfafe1f0
      SubnetId: subnet-0abc1234
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This works. Once. In one region. For one person.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;ImageId&lt;/code&gt; is an AMI ID. AMI IDs are region-specific. &lt;code&gt;ami-0c55b159cbfafe1f0&lt;/code&gt; in &lt;code&gt;us-east-1&lt;/code&gt; is not the same image in &lt;code&gt;eu-west-1&lt;/code&gt; in fact, it probably does not exist there at all. The &lt;code&gt;SubnetId&lt;/code&gt; is hardcoded to a specific subnet in a specific AWS account.&lt;/p&gt;
&lt;p&gt;If you or anyone else tries to deploy this template in a different region or a different account, it will fail.&lt;/p&gt;
&lt;p&gt;This is a &lt;strong&gt;non-portable template&lt;/strong&gt;. It has hardcoded values that only work in one specific context.&lt;/p&gt;
&lt;h4 id=&quot;what-would-happen-if-you-ran-it&quot;&gt;What Would Happen If You Ran It&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;In your account, same region: Works.&lt;/li&gt;
&lt;li&gt;In your account, different region: Fails. AMI ID does not exist.&lt;/li&gt;
&lt;li&gt;In a colleague's account: Fails. Subnet ID does not exist.&lt;/li&gt;
&lt;li&gt;In a CI/CD pipeline targeting staging: Fails. Both IDs are wrong.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;the-solution-portable-templates&quot;&gt;The Solution: Portable Templates&lt;/h4&gt;
&lt;p&gt;A portable template contains no hardcoded environment-specific values. Instead, it accepts inputs, references dynamic values, and uses lookup mechanisms to resolve environment-specific details at deploy time.&lt;/p&gt;
&lt;p&gt;The tools that enable portability are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Parameters&lt;/strong&gt; Inputs you provide at deploy time&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pseudo Parameters&lt;/strong&gt; Values AWS provides automatically (account ID, region, etc.)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Intrinsic Functions&lt;/strong&gt; Functions that resolve values dynamically&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mappings&lt;/strong&gt; Lookup tables built into the template&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The portable version of the above template:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;AWSTemplateFormatVersion: &quot;2010-09-09&quot;
Description: Portable EC2 instance

Parameters:
  SubnetId:
    Type: AWS::EC2::Subnet::Id
    Description: The subnet to launch the instance into

Mappings:
  RegionAMIMap:
    us-east-1:
      AMI: ami-0c55b159cbfafe1f0
    eu-west-1:
      AMI: ami-0d71ea30463e0ff49

Resources:
  MyInstance:
    Type: AWS::EC2::Instance
    Properties:
      InstanceType: t3.micro
      ImageId: !FindInMap [RegionAMIMap, !Ref AWS::Region, AMI]
      SubnetId: !Ref SubnetId
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now this template works in any region that is in the map, and in any account. The subnet is provided at deploy time. The AMI is looked up based on which region you are deploying to.&lt;/p&gt;
&lt;p&gt;This is the difference between a template you write once and a template you write once and use everywhere.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;template-parameters-and-pseudo-parameters&quot;&gt;Template Parameters and Pseudo Parameters&lt;/h3&gt;
&lt;h4 id=&quot;template-parameters&quot;&gt;Template Parameters&lt;/h4&gt;
&lt;p&gt;Parameters make your template an interface. Instead of hardcoding values, you expose inputs that the person deploying the template or an automation system provides at deploy time.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Parameters:
  EnvironmentName:
    Type: String
    Default: development
    AllowedValues:
      - development
      - staging
      - production
    Description: The environment this stack is being deployed to

  InstanceType:
    Type: String
    Default: t3.micro
    AllowedValues:
      - t3.micro
      - t3.small
      - t3.medium
    Description: EC2 instance type

  DBPassword:
    Type: String
    NoEcho: true
    MinLength: 8
    MaxLength: 32
    Description: Database password will not be displayed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Key parameter types:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Type &lt;/th&gt;&lt;th&gt;What It Validates&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;String&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Any string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Number&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Numeric value&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;AWS::EC2::Subnet::Id&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Must be a valid subnet ID in your account&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;AWS::EC2::KeyPair::KeyName&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Must be a valid key pair name&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;AWS::SSM::Parameter::Value&amp;lt;String&amp;gt;&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Pulls value from Systems Manager Parameter Store&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The &lt;code&gt;NoEcho: true&lt;/code&gt; property is important. It prevents the value from being displayed in the Console or CLI output. Use it for passwords and secrets. Note: it is not encryption. The value is still stored in the stack. Do not use it for genuinely sensitive production secrets use Secrets Manager or SSM SecureString instead.&lt;/p&gt;
&lt;p&gt;To reference a parameter inside the template:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Properties:
  InstanceType: !Ref InstanceType
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;!Ref&lt;/code&gt; on a parameter returns its value. That is it. Simple.&lt;/p&gt;
&lt;h4 id=&quot;pseudo-parameters&quot;&gt;Pseudo Parameters&lt;/h4&gt;
&lt;p&gt;Pseudo parameters are values that AWS provides automatically. You do not declare them. They are always available.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Pseudo Parameter &lt;/th&gt;&lt;th&gt;What It Returns&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;AWS::AccountId&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Your 12-digit AWS account ID&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;AWS::Region&lt;/code&gt; &lt;/td&gt;&lt;td&gt;The region being deployed to, e.g. &lt;code&gt;eu-west-1&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;AWS::StackName&lt;/code&gt; &lt;/td&gt;&lt;td&gt;The name of the current stack&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;AWS::StackId&lt;/code&gt; &lt;/td&gt;&lt;td&gt;The full ARN of the stack&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;AWS::NoValue&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Used to conditionally remove a property&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;AWS::Partition&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;aws&lt;/code&gt;, &lt;code&gt;aws-cn&lt;/code&gt;, or &lt;code&gt;aws-us-gov&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;AWS::URLSuffix&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;amazonaws.com&lt;/code&gt; or region-specific suffix&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Usage example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Resources:
  MyBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub &quot;myapp-${AWS::AccountId}-${AWS::Region}-logs&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This creates a bucket name like &lt;code&gt;myapp-123456789012-eu-west-1-logs&lt;/code&gt;. Because account IDs and regions are globally unique, this bucket name will never collide with anyone else's, and the same template deployed to multiple regions will always produce a unique bucket name.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;AWS::NoValue&lt;/code&gt; is used when you want to conditionally exclude a property:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Properties:
  DBSnapshotIdentifier: !If [IsProduction, !Ref SnapshotId, !Ref AWS::NoValue]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the condition &lt;code&gt;IsProduction&lt;/code&gt; is false, the property is completely removed from the resource definition. AWS sees it as if you never wrote it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;intrinsic-functions&quot;&gt;Intrinsic Functions&lt;/h3&gt;
&lt;h4 id=&quot;what-they-are&quot;&gt;What They Are&lt;/h4&gt;
&lt;p&gt;Intrinsic functions are built-in functions that CloudFormation evaluates when it processes your template. They let you compute values, reference other resources, join strings, look up mappings, and make decisions all at deploy time.&lt;/p&gt;
&lt;p&gt;You cannot use them in every part of a template. They work in the &lt;code&gt;Properties&lt;/code&gt; section of resources, in &lt;code&gt;Outputs&lt;/code&gt;, and in &lt;code&gt;Metadata&lt;/code&gt;. They do not work in the &lt;code&gt;Parameters&lt;/code&gt; section.&lt;/p&gt;
&lt;h4 id=&quot;the-core-functions&quot;&gt;The Core Functions&lt;/h4&gt;
&lt;h5 id=&quot;-ref&quot;&gt;!Ref&lt;/h5&gt;
&lt;p&gt;Returns the value of a parameter or the default identifier of a resource.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# On a parameter: returns the parameter value
InstanceType: !Ref InstanceTypeParam

# On a resource: returns the resource's primary identifier
SubnetId: !Ref MySubnet   # Returns the Subnet ID of MySubnet
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What &lt;code&gt;!Ref&lt;/code&gt; returns depends on the resource type. For an EC2 instance it returns the instance ID. For an S3 bucket it returns the bucket name. For a security group it returns the group ID. Always check the CloudFormation documentation for what &lt;code&gt;!Ref&lt;/code&gt; returns for each resource type.&lt;/p&gt;
&lt;h5 id=&quot;-getatt&quot;&gt;!GetAtt&lt;/h5&gt;
&lt;p&gt;Returns a specific attribute of a resource not just the primary identifier.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# Get the ARN of a Lambda function
FunctionArn: !GetAtt MyLambdaFunction.Arn

# Get the DNS name of a load balancer
LoadBalancerDNS: !GetAtt MyALB.DNSName

# Get the ARN of an IAM role
RoleArn: !GetAtt MyRole.Arn
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;!GetAtt&lt;/code&gt; is how you wire resources together. You create a load balancer and then pass its DNS name to your Route 53 record. You create an IAM role and pass its ARN to a Lambda function.&lt;/p&gt;
&lt;h5 id=&quot;-sub&quot;&gt;!Sub&lt;/h5&gt;
&lt;p&gt;Substitutes variables into a string. The most readable way to build dynamic strings.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# Simple substitution with pseudo parameters
BucketName: !Sub &quot;myapp-${AWS::AccountId}-${AWS::Region}&quot;

# Substitution with logical resource references
Description: !Sub &quot;This is the ${EnvironmentName} environment stack&quot;

# Substitution with explicit variable map
Command: !Sub
  - &quot;aws s3 cp s3://${BucketName}/config.json /etc/myapp/config.json&quot;
  - BucketName: !Ref MyBucket
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;!Sub&lt;/code&gt; is cleaner than &lt;code&gt;!Join&lt;/code&gt; for most string-building tasks. Use it by default and only reach for &lt;code&gt;!Join&lt;/code&gt; when you are working with lists.&lt;/p&gt;
&lt;h5 id=&quot;-join&quot;&gt;!Join&lt;/h5&gt;
&lt;p&gt;Joins a list of values with a delimiter.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# Join with no delimiter
PolicyArn: !Join [&quot;&quot;, [&quot;arn:aws:iam::&quot;, !Ref AWS::AccountId, &quot;:root&quot;]]

# Join with comma delimiter
AllowedOrigins: !Join [&quot;,&quot;, [!Ref Domain1, !Ref Domain2]]
&lt;/code&gt;&lt;/pre&gt;
&lt;h5 id=&quot;-select&quot;&gt;!Select&lt;/h5&gt;
&lt;p&gt;Returns a single value from a list by index.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# Get the first availability zone in the region
AvailabilityZone: !Select [0, !GetAZs &quot;&quot;]

# Get the second
AvailabilityZone: !Select [1, !GetAZs &quot;&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;!GetAZs&lt;/code&gt; returns all availability zones in a region. &lt;code&gt;!Select&lt;/code&gt; picks one. This combination is used constantly when creating subnets across AZs.&lt;/p&gt;
&lt;h5 id=&quot;-findinmap&quot;&gt;!FindInMap&lt;/h5&gt;
&lt;p&gt;Looks up a value in a Mapping (covered in the next section).&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;ImageId: !FindInMap [RegionAMIMap, !Ref AWS::Region, AMI]
&lt;/code&gt;&lt;/pre&gt;
&lt;h5 id=&quot;-if&quot;&gt;!If&lt;/h5&gt;
&lt;p&gt;Returns one of two values based on a condition.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;InstanceType: !If [IsProduction, m5.xlarge, t3.micro]
&lt;/code&gt;&lt;/pre&gt;
&lt;h5 id=&quot;-and-or-not-equals&quot;&gt;!And, !Or, !Not, !Equals&lt;/h5&gt;
&lt;p&gt;Logical operators used when defining conditions.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Conditions:
  IsProductionAndEU: !And
    - !Condition IsProduction
    - !Equals [!Ref AWS::Region, &quot;eu-west-1&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;h5 id=&quot;-importvalue&quot;&gt;!ImportValue&lt;/h5&gt;
&lt;p&gt;Imports a value exported by another stack. This is how Cross-Stack References work — covered in detail in section 14.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;VpcId: !ImportValue SharedInfra-VpcId
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;how-functions-compose&quot;&gt;How Functions Compose&lt;/h4&gt;
&lt;p&gt;The power comes from nesting. Functions can be composed:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;SecurityGroupId: !Select
  - 0
  - !Split [&quot;,&quot;, !ImportValue SharedInfra-SecurityGroupIds]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here: import a comma-separated string of security group IDs from another stack, split it into a list, then select the first one. Three functions, one clean result.&lt;/p&gt;
&lt;p&gt;This is not just syntax. It is the way CloudFormation templates become self-describing infrastructure documents that adapt to context.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;mappings&quot;&gt;Mappings&lt;/h3&gt;
&lt;h4 id=&quot;what-they-are-1&quot;&gt;What They Are&lt;/h4&gt;
&lt;p&gt;Mappings are lookup tables built into your template. They let you define a set of key-value pairs and then look up a value at deploy time based on known context like which region you are deploying to, or which environment was selected.&lt;/p&gt;
&lt;h4 id=&quot;structure&quot;&gt;Structure&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Mappings:
  RegionAMIMap:
    us-east-1:
      AMI: ami-0c55b159cbfafe1f0
      BastionAMI: ami-0a887e401f7654935
    eu-west-1:
      AMI: ami-0d71ea30463e0ff49
      BastionAMI: ami-08d658f84a6d84a80
    ap-southeast-1:
      AMI: ami-01f7527546b557442
      BastionAMI: ami-0c5199d385b432989

  EnvironmentConfig:
    development:
      InstanceType: t3.micro
      MultiAZ: false
      DeletionProtection: false
    production:
      InstanceType: m5.large
      MultiAZ: true
      DeletionProtection: true
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;usage&quot;&gt;Usage&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Resources:
  MyInstance:
    Type: AWS::EC2::Instance
    Properties:
      ImageId: !FindInMap [RegionAMIMap, !Ref AWS::Region, AMI]
      InstanceType: !FindInMap [EnvironmentConfig, !Ref EnvironmentName, InstanceType]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;!FindInMap&lt;/code&gt; takes three arguments: the map name, the top-level key, and the second-level key. It returns the value at that intersection.&lt;/p&gt;
&lt;h4 id=&quot;limitations&quot;&gt;Limitations&lt;/h4&gt;
&lt;p&gt;Mappings are static. They are baked into the template at write time. You cannot populate them dynamically at deploy time, and you cannot look up values in external systems like SSM Parameter Store.&lt;/p&gt;
&lt;p&gt;This means:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;When an AMI is updated, you must update the mapping manually and redeploy.&lt;/li&gt;
&lt;li&gt;You cannot have a different mapping value per account without writing account IDs into the template.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For dynamic lookups, the solution is SSM Parameter Store with &lt;code&gt;AWS::SSM::Parameter::Value&amp;lt;String&amp;gt;&lt;/code&gt; parameter types, or Custom Resources (covered in section 19). The AWS-managed AMI parameter store path (&lt;code&gt;/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64&lt;/code&gt;) is the standard way to always get the latest Amazon Linux AMI without hardcoding AMI IDs at all.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;outputs&quot;&gt;Outputs&lt;/h3&gt;
&lt;h4 id=&quot;what-they-are-2&quot;&gt;What They Are&lt;/h4&gt;
&lt;p&gt;Outputs are values that CloudFormation makes available after a stack is created or updated. They serve two purposes:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Visibility&lt;/strong&gt; Display useful information about what was created (endpoint URLs, resource IDs, ARNs).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cross-Stack References&lt;/strong&gt; Export a value so other stacks can import it.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;structure-1&quot;&gt;Structure&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Outputs:
  WebServerPublicIP:
    Description: Public IP address of the web server
    Value: !GetAtt MyWebServer.PublicIp

  LoadBalancerDNS:
    Description: DNS name for the application load balancer
    Value: !GetAtt MyALB.DNSName

  VpcId:
    Description: VPC ID for use by other stacks
    Value: !Ref MyVPC
    Export:
      Name: !Sub &quot;${AWS::StackName}-VpcId&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;when-to-use-export&quot;&gt;When to Use Export&lt;/h4&gt;
&lt;p&gt;Only add &lt;code&gt;Export&lt;/code&gt; when you intend for another stack to reference the value. Not every output needs to be exported. Exports create a dependency you cannot delete the exporting stack while any other stack is consuming its exports.&lt;/p&gt;
&lt;h4 id=&quot;what-would-happen-without-outputs&quot;&gt;What Would Happen Without Outputs&lt;/h4&gt;
&lt;p&gt;Without outputs, you would need to go into the AWS Console or run CLI commands to find the DNS name of your load balancer, the ID of your VPC, or the ARN of your IAM role. Outputs surface these values automatically after deployment, making them available to operators, pipelines, and other stacks.&lt;/p&gt;
&lt;p&gt;In a CI/CD pipeline:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# After CloudFormation deploy, grab the load balancer URL
ALB_URL=$(aws cloudformation describe-stacks \
  --stack-name my-app \
  --query &quot;Stacks[0].Outputs[?OutputKey=='LoadBalancerDNS'].OutputValue&quot; \
  --output text)

# Use it to run integration tests
curl -f &quot;http://$ALB_URL/health&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Outputs make your stack queryable. This is essential for automated pipelines.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;conditions&quot;&gt;Conditions&lt;/h3&gt;
&lt;h4 id=&quot;the-problem&quot;&gt;The Problem&lt;/h4&gt;
&lt;p&gt;You want one template that can deploy to both development and production, but the production environment needs a Multi-AZ RDS instance and an additional NAT Gateway, while development needs neither. Without conditions, you need two templates. With conditions, you need one.&lt;/p&gt;
&lt;h4 id=&quot;structure-2&quot;&gt;Structure&lt;/h4&gt;
&lt;p&gt;Conditions are defined in the &lt;code&gt;Conditions&lt;/code&gt; section and referenced in resources.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Parameters:
  EnvironmentName:
    Type: String
    AllowedValues: [development, production]

Conditions:
  IsProduction: !Equals [!Ref EnvironmentName, production]
  IsNotProduction: !Not [!Condition IsProduction]

Resources:
  PrimaryDatabase:
    Type: AWS::RDS::DBInstance
    Properties:
      DBInstanceClass: !If [IsProduction, db.m5.large, db.t3.micro]
      MultiAZ: !If [IsProduction, true, false]
      DeletionProtection: !If [IsProduction, true, false]

  NATGateway:
    Type: AWS::EC2::NatGateway
    Condition: IsProduction
    Properties:
      SubnetId: !Ref PublicSubnet
      AllocationId: !GetAtt ElasticIP.AllocationId
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;Condition: IsProduction&lt;/code&gt; on &lt;code&gt;NATGateway&lt;/code&gt; means: only create this resource if &lt;code&gt;IsProduction&lt;/code&gt; is true. The &lt;code&gt;!If&lt;/code&gt; inside &lt;code&gt;PrimaryDatabase&lt;/code&gt; means: use different property values depending on the condition.&lt;/p&gt;
&lt;h4 id=&quot;condition-operators&quot;&gt;Condition Operators&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Conditions:
  IsProduction: !Equals [!Ref Env, production]
  IsUS: !Equals [!Ref AWS::Region, us-east-1]
  
  # Both must be true
  IsProductionUS: !And
    - !Condition IsProduction
    - !Condition IsUS

  # Either must be true
  IsProductionOrUS: !Or
    - !Condition IsProduction
    - !Condition IsUS

  # Invert
  IsNotProduction: !Not [!Condition IsProduction]
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;limitation&quot;&gt;Limitation&lt;/h4&gt;
&lt;p&gt;Conditions are evaluated at deploy time. They cannot change during stack execution. You cannot create a condition that says &quot;if resource X was successfully created, then create resource Y&quot; that is what &lt;code&gt;DependsOn&lt;/code&gt; and &lt;code&gt;WaitConditions&lt;/code&gt; handle.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;dependson&quot;&gt;DependsOn&lt;/h3&gt;
&lt;h4 id=&quot;the-problem-1&quot;&gt;The Problem&lt;/h4&gt;
&lt;p&gt;CloudFormation builds resources in parallel where possible. It determines the order automatically by following &lt;code&gt;!Ref&lt;/code&gt; and &lt;code&gt;!GetAtt&lt;/code&gt; references if resource B references resource A, CloudFormation knows to create A first.&lt;/p&gt;
&lt;p&gt;But sometimes resource B does not reference resource A in its properties, yet it still needs A to exist before it can be created or function correctly. CloudFormation does not know about this implicit dependency, so it might try to create both simultaneously, and B fails because A is not ready.&lt;/p&gt;
&lt;h4 id=&quot;the-solution&quot;&gt;The Solution&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;DependsOn&lt;/code&gt; explicitly tells CloudFormation: do not start creating this resource until that resource is complete.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Resources:
  MyVPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16

  InternetGateway:
    Type: AWS::EC2::InternetGateway

  VPCGatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref MyVPC
      InternetGatewayId: !Ref InternetGateway

  PublicSubnet:
    Type: AWS::EC2::Subnet
    DependsOn: VPCGatewayAttachment
    Properties:
      VpcId: !Ref MyVPC
      CidrBlock: 10.0.1.0/24
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here, &lt;code&gt;PublicSubnet&lt;/code&gt; references &lt;code&gt;MyVPC&lt;/code&gt; via &lt;code&gt;!Ref&lt;/code&gt;, so CloudFormation knows it must wait for the VPC. But the subnet does not reference &lt;code&gt;VPCGatewayAttachment&lt;/code&gt; in its properties. Without &lt;code&gt;DependsOn&lt;/code&gt;, CloudFormation might create the subnet before the gateway is attached, and routing will not work correctly.&lt;/p&gt;
&lt;h4 id=&quot;a-more-common-use-case-rds-and-ec2&quot;&gt;A More Common Use Case — RDS and EC2&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Resources:
  Database:
    Type: AWS::RDS::DBInstance
    Properties:
      DBInstanceClass: db.t3.micro
      Engine: mysql

  AppServer:
    Type: AWS::EC2::Instance
    DependsOn: Database
    Properties:
      UserData: !Base64
        !Sub |
          #!/bin/bash
          echo &quot;DB_HOST=${Database.Endpoint.Address}&quot; &amp;gt;&amp;gt; /etc/app/config
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;AppServer&lt;/code&gt; does reference &lt;code&gt;Database&lt;/code&gt; via &lt;code&gt;!Sub&lt;/code&gt;, which creates an implicit dependency. But &lt;code&gt;DependsOn&lt;/code&gt; is also good practice here you want the database to be fully ready before the application server boots and tries to connect.&lt;/p&gt;
&lt;h4 id=&quot;what-happens-without-it&quot;&gt;What Happens Without It&lt;/h4&gt;
&lt;p&gt;Without &lt;code&gt;DependsOn&lt;/code&gt; where it is needed, resources are created in the wrong order. The stack may still reach &lt;code&gt;CREATE_COMPLETE&lt;/code&gt; but your application may fail at runtime because the dependency was not ready when the dependent resource was configured.&lt;/p&gt;
&lt;h4 id=&quot;limitation-1&quot;&gt;Limitation&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;DependsOn&lt;/code&gt; only tells CloudFormation to wait until the resource &lt;em&gt;creation&lt;/em&gt; is complete. It does not tell CloudFormation to wait until the resource is &lt;em&gt;ready to serve traffic&lt;/em&gt; or until a process inside the resource has finished running. For that, you need Wait Conditions and cfn-signal.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wait-conditions-and-cfn-signal&quot;&gt;Wait Conditions and cfn-signal&lt;/h3&gt;
&lt;h4 id=&quot;the-problem-that-dependson-cannot-solve&quot;&gt;The Problem That DependsOn Cannot Solve&lt;/h4&gt;
&lt;p&gt;CloudFormation considers an EC2 instance &quot;created&quot; the moment the API call to launch it succeeds. From CloudFormation's perspective, the resource is done. But the instance has not finished booting. The operating system is still starting. Your bootstrap script the one that installs your application, configures the web server, and starts your process is still running.&lt;/p&gt;
&lt;p&gt;If a second resource depends on the application being ready, &lt;code&gt;DependsOn&lt;/code&gt; is not enough. CloudFormation will proceed the moment the EC2 API reports success, not when your application is actually ready.&lt;/p&gt;
&lt;h4 id=&quot;the-solution-cfn-signal-and-waitconditions&quot;&gt;The Solution: cfn-signal and WaitConditions&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;cfn-signal&lt;/code&gt; is a script that runs inside your EC2 instance and sends a signal back to CloudFormation: &quot;I am done and I succeeded&quot; or &quot;I am done and I failed.&quot;&lt;/p&gt;
&lt;p&gt;A &lt;code&gt;WaitCondition&lt;/code&gt; is a CloudFormation resource that pauses the stack and waits for a specific number of signals before proceeding.&lt;/p&gt;
&lt;p&gt;Together, they let you tell CloudFormation: &quot;Wait until the bootstrap process inside the instance has finished before you continue creating other resources.&quot;&lt;/p&gt;
&lt;h4 id=&quot;how-it-works-step-by-step&quot;&gt;How It Works. Step by Step&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;You create an EC2 instance with a &lt;code&gt;UserData&lt;/code&gt; script that runs your bootstrap logic.&lt;/li&gt;
&lt;li&gt;At the end of the script, you call &lt;code&gt;cfn-signal&lt;/code&gt; to send a success or failure signal.&lt;/li&gt;
&lt;li&gt;CloudFormation sees the signal and either continues or fails the stack.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;the-code-1&quot;&gt;The Code&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Resources:
  WaitHandle:
    Type: AWS::CloudFormation::WaitConditionHandle

  WebServer:
    Type: AWS::EC2::Instance
    Properties:
      ImageId: !FindInMap [RegionAMIMap, !Ref AWS::Region, AMI]
      InstanceType: t3.micro
      UserData:
        !Base64
          !Sub |
            #!/bin/bash -xe
            # Install and configure application
            yum update -y
            yum install -y httpd
            systemctl start httpd
            systemctl enable httpd
            echo &quot;&amp;lt;h1&amp;gt;Hello from ${AWS::StackName}&amp;lt;/h1&amp;gt;&quot; &amp;gt; /var/www/html/index.html
            
            # Signal CloudFormation that bootstrap is complete
            /opt/aws/bin/cfn-signal -e $? \
              --stack ${AWS::StackName} \
              --resource WebServerWaitCondition \
              --region ${AWS::Region}

  WebServerWaitCondition:
    Type: AWS::CloudFormation::WaitCondition
    DependsOn: WebServer
    Properties:
      Handle: !Ref WaitHandle
      Timeout: 600
      Count: 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;-e $?&lt;/code&gt; passes the exit code of the last command. If the script ran successfully, &lt;code&gt;$?&lt;/code&gt; is &lt;code&gt;0&lt;/code&gt; CloudFormation receives a success signal. If the script failed, &lt;code&gt;$?&lt;/code&gt; is non-zero CloudFormation receives a failure signal, fails the wait condition, and rolls back the stack.&lt;/p&gt;
&lt;h4 id=&quot;what-happens-without-cfn-signal&quot;&gt;What Happens Without cfn-signal&lt;/h4&gt;
&lt;p&gt;Without signalling, CloudFormation marks the EC2 instance as &lt;code&gt;CREATE_COMPLETE&lt;/code&gt; the moment the API call succeeds typically within 10-15 seconds. Any resource that depends on the instance being fully configured will attempt to use it before it is ready. Load balancer health checks will fail. Downstream resources will misconfigure. Your application stack reaches &lt;code&gt;CREATE_COMPLETE&lt;/code&gt; in a broken state.&lt;/p&gt;
&lt;p&gt;This is the most common cause of &quot;CloudFormation says it worked but the application is not working&quot; problems.&lt;/p&gt;
&lt;h4 id=&quot;timeout&quot;&gt;Timeout&lt;/h4&gt;
&lt;p&gt;The &lt;code&gt;Timeout&lt;/code&gt; is in seconds. &lt;code&gt;600&lt;/code&gt; means CloudFormation will wait up to 10 minutes for the signal. If no signal arrives, the stack times out and rolls back. Size this based on how long your bootstrap realistically takes, with a comfortable buffer.&lt;/p&gt;
&lt;h4 id=&quot;limitation-2&quot;&gt;Limitation&lt;/h4&gt;
&lt;p&gt;cfn-signal handles the moment of creation well. But what about ongoing configuration? What if you need to update the instance configuration after the stack is deployed, or re-apply configuration if it drifts? For that, you need cfn-init.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;cloudformation-init-cfn-init-&quot;&gt;CloudFormation Init (cfn-init)&lt;/h3&gt;
&lt;h4 id=&quot;the-problem-with-userdata&quot;&gt;The Problem with UserData&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;UserData&lt;/code&gt; is a blunt instrument. It is a script that runs once at instance launch and that is it. If you update the CloudFormation template and the instance is not replaced, &lt;code&gt;UserData&lt;/code&gt; does not re-run. If the configuration drifts someone manually changes a file on the instance there is no way for CloudFormation to detect or correct it.&lt;/p&gt;
&lt;p&gt;Also, &lt;code&gt;UserData&lt;/code&gt; is imperative: you write step-by-step instructions. The result depends entirely on the starting state of the machine. If any step fails partway through, you have a half-configured instance with no clean way to recover.&lt;/p&gt;
&lt;h4 id=&quot;the-solution-cfn-init&quot;&gt;The Solution: cfn-init&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;cfn-init&lt;/code&gt; is a declarative configuration engine built into the CloudFormation helper tools. Instead of writing scripts that say &quot;run these commands,&quot; you declare the desired state: &quot;these packages should be installed, these files should exist with this content, these services should be running.&quot;&lt;/p&gt;
&lt;p&gt;Configuration is written in the &lt;code&gt;Metadata&lt;/code&gt; section of the resource using the &lt;code&gt;AWS::CloudFormation::Init&lt;/code&gt; key.&lt;/p&gt;
&lt;h4 id=&quot;structure-3&quot;&gt;Structure&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Resources:
  WebServer:
    Type: AWS::EC2::Instance
    Metadata:
      AWS::CloudFormation::Init:
        config:
          packages:
            yum:
              httpd: []
              php: []

          files:
            /var/www/html/index.php:
              content: !Sub |
                &amp;lt;?php
                echo &quot;&amp;lt;h1&amp;gt;Environment: ${EnvironmentName}&amp;lt;/h1&amp;gt;&quot;;
                echo &quot;&amp;lt;p&amp;gt;Stack: ${AWS::StackName}&amp;lt;/p&amp;gt;&quot;;
                ?&amp;gt;
              mode: &quot;000644&quot;
              owner: apache
              group: apache

            /etc/httpd/conf.d/myapp.conf:
              content: |
                &amp;lt;VirtualHost *:80&amp;gt;
                    DocumentRoot /var/www/html
                    DirectoryIndex index.php
                &amp;lt;/VirtualHost&amp;gt;
              mode: &quot;000644&quot;
              owner: root
              group: root

          services:
            sysvinit:
              httpd:
                enabled: true
                ensureRunning: true
                files:
                  - /etc/httpd/conf.d/myapp.conf
                packages:
                  yum:
                    - httpd

    Properties:
      ImageId: !FindInMap [RegionAMIMap, !Ref AWS::Region, AMI]
      InstanceType: t3.micro
      UserData:
        !Base64
          !Sub |
            #!/bin/bash -xe
            # Run cfn-init to apply the configuration
            /opt/aws/bin/cfn-init -v \
              --stack ${AWS::StackName} \
              --resource WebServer \
              --region ${AWS::Region}
            
            # Signal success or failure
            /opt/aws/bin/cfn-signal -e $? \
              --stack ${AWS::StackName} \
              --resource WebServerWaitCondition \
              --region ${AWS::Region}

  WebServerWaitCondition:
    Type: AWS::CloudFormation::WaitCondition
    DependsOn: WebServer
    Properties:
      Handle: !Ref WaitHandle
      Timeout: 600
      Count: 1

  WaitHandle:
    Type: AWS::CloudFormation::WaitConditionHandle
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;the-four-cfn-init-keys&quot;&gt;The Four cfn-init Keys&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Key &lt;/th&gt;&lt;th&gt;What It Does&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;packages&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Installs system packages via yum, apt, rpm, or other package managers&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;files&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Creates files with specific content, permissions, and ownership&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;commands&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Runs shell commands in a specific order with optional test conditions&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;services&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Ensures services are started, enabled, and restarted when dependencies change&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 id=&quot;configsets-ordering-multiple-configurations&quot;&gt;configSets Ordering Multiple Configurations&lt;/h4&gt;
&lt;p&gt;When you have complex configuration that needs to run in phases, use &lt;code&gt;configSets&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Metadata:
  AWS::CloudFormation::Init:
    configSets:
      full_install:
        - install_cfn
        - install_base
        - install_app
        - configure_app

    install_cfn:
      files:
        /etc/cfn/cfn-hup.conf:
          content: !Sub |
            [main]
            stack=${AWS::StackId}
            region=${AWS::Region}
          mode: &quot;000400&quot;
          owner: root
          group: root

    install_base:
      packages:
        yum:
          httpd: []
          php: []
          php-mysqlnd: []

    install_app:
      files:
        /var/www/html/index.php:
          content: !Sub |
            &amp;lt;?php phpinfo(); ?&amp;gt;
          mode: &quot;000644&quot;
          owner: apache
          group: apache

    configure_app:
      services:
        sysvinit:
          httpd:
            enabled: true
            ensureRunning: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then in &lt;code&gt;UserData&lt;/code&gt;, reference the configSet:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;/opt/aws/bin/cfn-init -v \
  --stack ${AWS::StackName} \
  --resource WebServer \
  --configsets full_install \
  --region ${AWS::Region}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;what-happens-without-cfn-init&quot;&gt;What Happens Without cfn-init&lt;/h4&gt;
&lt;p&gt;Without cfn-init, you rely entirely on &lt;code&gt;UserData&lt;/code&gt; scripts. These are harder to maintain, harder to debug, and run only once at launch. Configuration drift is invisible, and updating configuration requires replacing the instance. cfn-init makes your instance configuration as declarative and auditable as your infrastructure definition.&lt;/p&gt;
&lt;h4 id=&quot;limitation-3&quot;&gt;Limitation&lt;/h4&gt;
&lt;p&gt;cfn-init applies configuration at launch. It does not continuously monitor or re-apply configuration when the template changes, unless the instance is replaced. For tracking template changes and re-applying configuration without replacing instances, you need cfn-hup.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;cfn-hup&quot;&gt;cfn-hup&lt;/h3&gt;
&lt;h4 id=&quot;the-problem-cfn-init-alone-cannot-solve&quot;&gt;The Problem cfn-init Alone Cannot Solve&lt;/h4&gt;
&lt;p&gt;You update your CloudFormation template specifically, you change the content of a configuration file managed by cfn-init. You run &lt;code&gt;aws cloudformation update-stack&lt;/code&gt;. CloudFormation processes the update.&lt;/p&gt;
&lt;p&gt;If the EC2 instance is not being replaced (because the change does not require replacement only a metadata change), CloudFormation will not re-run cfn-init. The instance keeps running with the old configuration. The update completes, the stack reaches &lt;code&gt;UPDATE_COMPLETE&lt;/code&gt;, and your configuration is silently out of date.&lt;/p&gt;
&lt;h4 id=&quot;the-solution-cfn-hup&quot;&gt;The Solution: cfn-hup&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;cfn-hup&lt;/code&gt; is a daemon that runs on the instance and polls the CloudFormation stack for changes to the resource's metadata. When it detects a change, it re-runs cfn-init to apply the updated configuration.&lt;/p&gt;
&lt;p&gt;This gives you the ability to update instance configuration through CloudFormation without replacing the instance.&lt;/p&gt;
&lt;h4 id=&quot;how-to-set-it-up&quot;&gt;How to Set It Up&lt;/h4&gt;
&lt;p&gt;cfn-hup requires two configuration files on the instance, typically created via cfn-init itself:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Metadata:
  AWS::CloudFormation::Init:
    configSets:
      full_install:
        - install_cfn
        - install_app

    install_cfn:
      files:
        /etc/cfn/cfn-hup.conf:
          content: !Sub |
            [main]
            stack=${AWS::StackId}
            region=${AWS::Region}
            interval=5
          mode: &quot;000400&quot;
          owner: root
          group: root

        /etc/cfn/hooks.d/cfn-auto-reloader.conf:
          content: !Sub |
            [cfn-auto-reloader-hook]
            triggers=post.update
            path=Resources.WebServer.Metadata.AWS::CloudFormation::Init
            action=/opt/aws/bin/cfn-init -v \
              --stack ${AWS::StackName} \
              --resource WebServer \
              --configsets full_install \
              --region ${AWS::Region}
            runas=root
          mode: &quot;000400&quot;
          owner: root
          group: root

      services:
        sysvinit:
          cfn-hup:
            enabled: true
            ensureRunning: true
            files:
              - /etc/cfn/cfn-hup.conf
              - /etc/cfn/hooks.d/cfn-auto-reloader.conf
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;how-it-works&quot;&gt;How It Works&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;cfn-hup.conf&lt;/code&gt; tells cfn-hup which stack to watch and how often to poll (every 5 minutes in this example).&lt;/p&gt;
&lt;p&gt;&lt;code&gt;cfn-auto-reloader.conf&lt;/code&gt; tells cfn-hup what to do when it detects a change: watch the &lt;code&gt;Metadata.AWS::CloudFormation::Init&lt;/code&gt; path of the &lt;code&gt;WebServer&lt;/code&gt; resource, and when it changes, re-run cfn-init with the full configSet.&lt;/p&gt;
&lt;p&gt;When you update the template — change a file, add a package, modify a service — cfn-hup detects the metadata change within the poll interval and re-applies the full configuration to the running instance.&lt;/p&gt;
&lt;h4 id=&quot;the-complete-pattern&quot;&gt;The Complete Pattern&lt;/h4&gt;
&lt;p&gt;cfn-init, cfn-signal, and cfn-hup form a complete configuration management pattern:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;cfn-init&lt;/code&gt; applies configuration at launch&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cfn-signal&lt;/code&gt; tells CloudFormation that configuration is complete&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cfn-hup&lt;/code&gt; keeps configuration in sync with the template over time&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Together, they turn your EC2 instances into configuration-as-code managed infrastructure that stays synchronized with your CloudFormation templates without requiring replacement.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;nested-stacks&quot;&gt;Nested Stacks&lt;/h3&gt;
&lt;h4 id=&quot;the-problem-with-single-large-stacks&quot;&gt;The Problem with Single Large Stacks&lt;/h4&gt;
&lt;p&gt;A CloudFormation stack has a hard limit of 500 resources. But the real problem appears well before that limit.&lt;/p&gt;
&lt;p&gt;A template with 80+ resources becomes difficult to read, difficult to test, and difficult to reason about. Changes to one part of the template require updating and deploying the entire thing. Teams working on different parts of the infrastructure step on each other. Re-using patterns across projects is impossible because everything is in one monolithic file.&lt;/p&gt;
&lt;p&gt;This is the same problem that led software engineers to adopt functions, modules, and packages. The solution is the same: decomposition.&lt;/p&gt;
&lt;h4 id=&quot;the-solution-nested-stacks&quot;&gt;The Solution — Nested Stacks&lt;/h4&gt;
&lt;p&gt;A nested stack is a CloudFormation resource of type &lt;code&gt;AWS::CloudFormation::Stack&lt;/code&gt;. It references another CloudFormation template stored in S3 and deploys it as a child stack. The parent stack manages the lifecycle of its child stacks.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Resources:
  NetworkStack:
    Type: AWS::CloudFormation::Stack
    Properties:
      TemplateURL: https://s3.amazonaws.com/mybucket/templates/network.yaml
      Parameters:
        EnvironmentName: !Ref EnvironmentName
        VpcCidr: 10.0.0.0/16
      TimeoutInMinutes: 20

  AppStack:
    Type: AWS::CloudFormation::Stack
    DependsOn: NetworkStack
    Properties:
      TemplateURL: https://s3.amazonaws.com/mybucket/templates/app.yaml
      Parameters:
        EnvironmentName: !Ref EnvironmentName
        VpcId: !GetAtt NetworkStack.Outputs.VpcId
        SubnetIds: !GetAtt NetworkStack.Outputs.SubnetIds
      TimeoutInMinutes: 30

  DatabaseStack:
    Type: AWS::CloudFormation::Stack
    DependsOn: NetworkStack
    Properties:
      TemplateURL: https://s3.amazonaws.com/mybucket/templates/database.yaml
      Parameters:
        SubnetIds: !GetAtt NetworkStack.Outputs.SubnetIds
        DBPassword: !Ref DBPassword
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The parent template is thin it orchestrates the child stacks and passes data between them via outputs and parameters. Each child template is a focused, independently testable unit.&lt;/p&gt;
&lt;h4 id=&quot;passing-data-between-nested-stacks&quot;&gt;Passing Data Between Nested Stacks&lt;/h4&gt;
&lt;p&gt;Notice &lt;code&gt;!GetAtt NetworkStack.Outputs.VpcId&lt;/code&gt;. This is how you pass data from one nested stack to another:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The &lt;code&gt;network.yaml&lt;/code&gt; template has an &lt;code&gt;Outputs&lt;/code&gt; section that exports &lt;code&gt;VpcId&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The parent references it via &lt;code&gt;!GetAtt NetworkStack.Outputs.VpcId&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The parent passes it as a parameter to &lt;code&gt;AppStack&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# network.yaml Outputs section
Outputs:
  VpcId:
    Value: !Ref MyVPC
  SubnetIds:
    Value: !Join [&quot;,&quot;, [!Ref SubnetA, !Ref SubnetB]]
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;what-would-happen-without-nested-stacks&quot;&gt;What Would Happen Without Nested Stacks&lt;/h4&gt;
&lt;p&gt;All resources live in one template. It grows. Team members modify the same file. Changes to the network layer require touching the application template. Testing one component requires deploying everything. The template becomes the infrastructure equivalent of a 5,000-line monolithic application technically functional but practically unmaintainable.&lt;/p&gt;
&lt;h4 id=&quot;limitation-4&quot;&gt;Limitation&lt;/h4&gt;
&lt;p&gt;Nested stacks solve the organization and size problem within a single deployment. They do not solve the problem of sharing infrastructure across multiple independent teams or projects. If Team A creates a VPC that Team B also needs, nested stacks are not the right tool Cross-Stack References are.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;cross-stack-references&quot;&gt;Cross-Stack References&lt;/h3&gt;
&lt;h4 id=&quot;the-problem-nested-stacks-do-not-solve&quot;&gt;The Problem Nested Stacks Do Not Solve&lt;/h4&gt;
&lt;p&gt;Nested stacks keep related resources together. But what about shared infrastructure that multiple independent stacks need to reference?&lt;/p&gt;
&lt;p&gt;Your networking team creates a VPC stack: VPC, subnets, route tables, NAT gateways. This is the foundation. Three separate application teams each deploy their own stacks that need to run inside that VPC.&lt;/p&gt;
&lt;p&gt;With nested stacks, the networking stack would need to be the parent of all three application stacks. That creates an artificial coupling the networking team owns the deployment of all application stacks. This does not reflect how real teams work.&lt;/p&gt;
&lt;p&gt;The right model is: the networking stack exists independently, exports its values, and each application stack imports what it needs.&lt;/p&gt;
&lt;h4 id=&quot;the-solution-exports-and-importvalue&quot;&gt;The Solution: Exports and ImportValue&lt;/h4&gt;
&lt;p&gt;A stack can export named values. Any other stack in the same region and account can import those values.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Exporting stack (networking stack):&lt;/strong&gt;&lt;/p&gt;
&lt;h4 id=&quot;naming-convention&quot;&gt;Naming Convention&lt;/h4&gt;
&lt;p&gt;Export names must be unique within a region and account. The convention &lt;code&gt;StackName-ResourceName&lt;/code&gt; is standard. You can also use &lt;code&gt;!Sub&lt;/code&gt; with pseudo parameters for uniqueness:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Export:
  Name: !Sub &quot;${AWS::StackName}-VpcId&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;putting-it-all-together-&quot;&gt;Putting It All Together.&lt;/h3&gt;
&lt;p&gt;Every feature in CloudFormation exists because there was a real problem that could not be solved without it. Here is how they connect:&lt;/p&gt;
&lt;p&gt;None of these features are optional in a serious production environment. Each one closes a gap that, without it, requires manual intervention, custom scripting, or accepted risk.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Written by &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.leonardkachi.click/&quot;&gt;&lt;strong&gt;Onyedikachi Obidiegwu&lt;/strong&gt;&lt;/a&gt; | Cloud Security Engineer&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26565/infrastructure-as-code-with-aws-cloudformation</guid>
<pubDate>Wed, 09 Sep 2026 07:23:07 +0000</pubDate>
</item>
<item>
<title>Act Security's Amphi Shrinks a 30,000-Character AWS Policy Down to 7,000</title>
<link>https://coderlegion.com/26162/act-securitys-amphi-shrinks-a-30-000-character-aws-policy-down-to-7-000</link>
<description>&lt;p&gt;David Kerber pulled up a policy with 30,000 characters in it — right at the edge of what AWS will let you attach to a role — and ran it through a tool called IAM Shrink. A few seconds later it was 25,000 characters. He ran it again: 23,000. One more pass, and it dropped to 7,000. Same permissions, a quarter of the size, no additional attack surface.&lt;/p&gt;
&lt;p&gt;That's the kind of problem engineers hit constantly and rarely talk about: AWS IAM policies quickly become unreadable and oversized, and fixing them by hand means collapsing action lists into wildcards without accidentally granting permissions you didn't intend. Kerber built a tool for it because he got tired of doing it manually during consulting work. He's now a Cloud Software Architect at Act Security, which acquired his open-source project, Cloud Copilot, last month and folded it into a new free toolkit called Amphi.&lt;/p&gt;
&lt;p&gt;Every tool in Amphi follows the same philosophy: do one thing, do it well, run it in the browser or from the command line, and never send the policy data anywhere. IAM Expand takes a wildcard string like &lt;code&gt;s3:*et*ob*&lt;/code&gt; and shows exactly which actions it matches as you type it. It can also invert a policy — take an &lt;code&gt;allow, not action&lt;/code&gt; statement and spit back the actual list of what's allowed, pulled from AWS documentation that refreshes daily. IAM Convert turns a policy into Terraform, which Kerber built for the same reason as IAM Shrink: he was hand-converting policies too often. Chain the two together in a CI pipeline — shrink, then convert — and your Git history still shows what you actually intended to grant, even after the policy's been compressed for size.&lt;/p&gt;
&lt;p&gt;The policy tester is the one built for how developers already think. Paste in a policy, write expectations against it — &quot;this call should be denied,&quot; &quot;this call should be allowed&quot; — and the simulator tells you which statement matched and why. Edit the policy live and the results update as you type. Kerber described it as writing red-green tests for a permission set rather than guessing how it behaves.&lt;/p&gt;
&lt;p&gt;The most complex tool handles service control policies and resource control policies — organization-wide rules that can govern every request across hundreds of AWS accounts at once. Kerber's blunt about how well understood these are in practice: &quot;Nobody really understands them. They just think they do — including me.&quot; His answer is to run every condition in the policy against every other condition and generate a full truth table. One example he pulled up produced 108 unique permutations, each one showing exactly which combination of conditions leads to an allow or a deny.&lt;/p&gt;
&lt;p&gt;Then there's the pair built for scale: IAM Collect pulls every policy type from every account you point it at — Kerber said some users run the free version across 7,000 to 8,000 AWS accounts at a time — and IAM Lens queries that data to determine who can actually do what. Ask it who can access a specific S3 bucket, and it runs roughly 20,000 simulations to return every principal with access, along with which objects they can access. Ask the reverse — what can this one role do — and it merges every attached policy, guardrail, and resource policy into a single answer. Kerber's framing for why that matters: &quot;If an auditor asks you to prove who has access to a bucket with driver's license photos in it, this does that. And it scales. And it's free.&quot;&lt;/p&gt;
&lt;p&gt;I asked what reachability mapping is actually telling an engineer. His answer was more direct than most vendor pitches: it's showing risk. &quot;The first question is who can do it. The second question is, should they? The delta between those two is risk — and it's not a matter of if that gets exploited. It's a matter of when.&quot;&lt;/p&gt;
&lt;p&gt;Not everything got a confident answer, and that's worth noting on its own. Asked where Amphi overlaps with Permit.io or Sysdig, Kerber said flatly he doesn't know either tool well enough to compare. He couldn't name a customer. And while Act's commercial platform already covers Azure and GCP alongside AWS, he wouldn't commit to whether the open-source tools ever will — &quot;I don't know one way or another whether we'll pursue that.&quot;&lt;/p&gt;
&lt;p&gt;The commercial boundary is at least clear. The open-source project stays AGPL: free to run inside your own company, off-limits if you're building a product to resell. Act's paid platform is the bigger, multi-cloud version — AWS, Azure, GCP, identity, network — and Kerber says acquiring Cloud Copilot rather than building it in-house came down to time. The tool was already validated at enterprise scale, the edge cases were already worked out, and in security, speed matters more than pride of authorship. He also says open-source contributions have picked up since the acquisition, not slowed down — Cloud Copilot went from a one-person side project to something with real backing.&lt;/p&gt;
&lt;p&gt;His closing point is the one to sit with: the industry's been on a least-privilege journey for years, but the urgency just went up. &quot;An attacker's going to get in, and they're going to exploit every access path you haven't closed.&quot; Free tooling that shows you exactly where those paths are is a reasonable bet regardless of which platform eventually wins.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26162/act-securitys-amphi-shrinks-a-30-000-character-aws-policy-down-to-7-000</guid>
<pubDate>Thu, 03 Sep 2026 18:00:00 +0000</pubDate>
</item>
<item>
<title>S3 vs Supabase Storage vs Cloudinary (2026 Guide)</title>
<link>https://coderlegion.com/25886/s3-vs-supabase-storage-vs-cloudinary-2026-guide</link>
<description>&lt;p&gt;You're building a Next.js app that needs to store files — user avatars, product photos, PDFs, whatever — and you hit the same wall every developer hits: &lt;strong&gt;where do the actual files go?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;For most apps, storing large user-uploaded files directly in Postgres isn't the best fit. Object storage is designed for this workload and keeps your database focused on relational data. So you're left picking between three names that show up in almost every tutorial: &lt;strong&gt;AWS S3&lt;/strong&gt;, &lt;strong&gt;Supabase Storage&lt;/strong&gt;, and &lt;strong&gt;Cloudinary&lt;/strong&gt;. They all &quot;store files,&quot; but they solve genuinely different problems, and picking the wrong one means either fighting your tools for months or paying for features you'll never touch.&lt;/p&gt;
&lt;p&gt;By the end of this guide, you'll know exactly what each one is actually built for, how to wire up a basic upload with real code for all three, what they cost, and which one fits your specific project — not just whichever one showed up first in your search results.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;quick-answer&quot;&gt;Quick Answer&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Choose AWS S3&lt;/strong&gt; if you want inexpensive, highly flexible object storage with a huge ecosystem and full AWS control.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Choose Supabase Storage&lt;/strong&gt; if you're already using Supabase for your database and auth — it plugs directly into the same Row Level Security model you already know.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Choose Cloudinary&lt;/strong&gt; if images and videos are a core part of your product and you want transformations (resizing, cropping, format conversion, transcoding) handled automatically, with almost no backend code.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If none of those single-sentence answers settled it, keep reading — the &quot;why&quot; behind each one is where the real decision lives.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-this-comparison-is-tricky&quot;&gt;Why This Comparison Is Tricky&lt;/h3&gt;
&lt;p&gt;Most &quot;S3 vs X&quot; posts compare feature checklists side by side and call it a day. The problem is these three tools aren't really competing for the same job:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;S3&lt;/strong&gt; is raw object storage. It stores bytes under a key. It has no idea what an &quot;image&quot; is — resizing, format conversion, and CDN delivery are all things &lt;em&gt;you&lt;/em&gt; have to bolt on separately (usually CloudFront).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Supabase Storage&lt;/strong&gt; is an object-storage service with an S3-compatible API, wrapped in Supabase's API, authentication, and Postgres RLS model.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cloudinary&lt;/strong&gt; is a media pipeline first, storage second. Its actual product is on-the-fly image and video transformation — resize, crop, convert to WebP/AVIF, transcode video, remove backgrounds — all driven by URL parameters, with storage as the thing that makes that possible.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Keep that distinction in mind through the rest of this guide: you're not just picking &quot;where files live,&quot; you're picking how much of the surrounding work (delivery, optimization, access control) you want to own yourself.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;at-a-glance&quot;&gt;At a Glance&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;AWS S3 &lt;/th&gt;&lt;th&gt;Supabase Storage &lt;/th&gt;&lt;th&gt;Cloudinary&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;What it actually is&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Raw object storage &lt;/td&gt;&lt;td&gt;Object storage + Supabase Auth/RLS integration &lt;/td&gt;&lt;td&gt;Media transformation pipeline&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Access control model&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;IAM policies + bucket policies &lt;/td&gt;&lt;td&gt;Row Level Security (SQL policies) &lt;/td&gt;&lt;td&gt;Signed uploads / upload presets&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Built-in image transforms&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;None — add an image-processing/optimization layer separately &lt;/td&gt;&lt;td&gt;Available in Paid Plans &lt;/td&gt;&lt;td&gt;Extensive (resize, crop, AI background removal, format negotiation)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Video handling&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;None — you'd wire up a separate transcoding service &lt;/td&gt;&lt;td&gt;Not a core feature &lt;/td&gt;&lt;td&gt;First-class — transcoding, adaptive streaming, a built-in video player&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;CDN included&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;No — add CloudFront separately &lt;/td&gt;&lt;td&gt;Yes, via the Supabase CDN &lt;/td&gt;&lt;td&gt;Yes, built in&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Best paired with&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Any stack, especially if you already use AWS &lt;/td&gt;&lt;td&gt;A Supabase-backed app (Postgres + Auth) &lt;/td&gt;&lt;td&gt;Any stack where images/video are core UX&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Free tier&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;12-month intro tier only, otherwise pay-as-you-go &lt;/td&gt;&lt;td&gt;~1 GB storage, 5 GB egress (check current limits) &lt;/td&gt;&lt;td&gt;25 credits/month (1 credit = 1 GB storage, 1 GB bandwidth, or 1K transformations)&amp;lt;br/&amp;gt; credits are shared across these resources&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Pricing model&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Storage + requests + data transfer, billed separately (see below) &lt;/td&gt;&lt;td&gt;Flat plan tiers + usage overages &lt;/td&gt;&lt;td&gt;Credit-based, fixed tiers&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Pricing on all three changes often enough that any number printed in a blog post is a snapshot, not a promise. Treat the figures above as ballpark, and always check each provider's live pricing page before committing budget to one.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;aws-s3-raw-cheap-and-you-own-everything&quot;&gt;AWS S3: Raw, Cheap, and You Own Everything&lt;/h3&gt;
&lt;p&gt;Amazon S3 (Simple Storage Service) is the original cloud object store — files go in under a &quot;key&quot; (basically a path string), and you get them back out the same way. No opinions about what the file &lt;em&gt;is&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Where S3 wins:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;One of the cheapest raw storage per GB at real scale, especially once you're past a few hundred GB&lt;/li&gt;
&lt;li&gt;Full control — bucket policies, lifecycle rules (auto-delete or archive old files), versioning, cross-region replication&lt;/li&gt;
&lt;li&gt;The de facto standard — every CDN, backup tool, and CMS speaks S3's API, including S3-compatible alternatives like Cloudflare R2 and Backblaze B2&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Where S3 struggles:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Zero built-in image processing. Want a thumbnail? You'll need a separate image-processing layer, such as a Lambda-based workflow, an image optimization service, or a framework-level optimizer.&lt;/li&gt;
&lt;li&gt;No CDN out of the box — S3 alone serves from one region. You need CloudFront (or another CDN) in front of it for real-world performance.&lt;/li&gt;
&lt;li&gt;More setup: IAM users, bucket policies, CORS config, and (for direct browser uploads) presigned URLs are all things you configure by hand.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;s3-pricing-is-more-than-per-gb-stored-&quot;&gt;S3 Pricing Is More Than &quot;Per GB Stored&quot;&lt;/h4&gt;
&lt;p&gt;This matters enough for a &quot;which is cheapest&quot; comparison that it deserves its own callout: &lt;strong&gt;S3's bill is not just storage.&lt;/strong&gt; You're billed separately for several things at once:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Storage&lt;/strong&gt; — per GB-month, and it varies by storage class (Standard, Infrequent Access, Glacier, etc.)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Requests&lt;/strong&gt; — &lt;code&gt;PUT&lt;/code&gt;/&lt;code&gt;COPY&lt;/code&gt;/&lt;code&gt;POST&lt;/code&gt;/&lt;code&gt;LIST&lt;/code&gt; requests cost more per-request than &lt;code&gt;GET&lt;/code&gt; requests, and both are metered&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data transfer out&lt;/strong&gt; — serving files to the internet (not through a CDN) is billed per GB and is usually the line item that surprises people&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Retrieval fees&lt;/strong&gt; — if you move infrequently-accessed files to a cheaper storage class, pulling them back out costs extra&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lifecycle transitions&lt;/strong&gt; — moving objects between storage classes automatically (e.g. Standard → Glacier after 90 days) also incurs a small per-object request cost&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A storage-only comparison (S3 vs. Supabase's per-GB rate vs. Cloudinary's credits) misses this. An app that serves thumbnails directly from S3 with heavy traffic and no CDN in front can rack up request and transfer charges that dwarf the storage bill itself — which is exactly why pairing S3 with CloudFront isn't just a performance decision, it's often a cost one too (CloudFront's cached data transfer is typically cheaper than S3's direct transfer-out rate).&lt;/p&gt;
&lt;h4 id=&quot;a-minimal-s3-upload-in-next-js-16&quot;&gt;A Minimal S3 Upload in Next.js 16&lt;/h4&gt;
&lt;p&gt;This uses the &lt;strong&gt;presigned URL pattern&lt;/strong&gt; — your server never touches the file bytes, it just asks S3 for permission and hands the browser a temporary signed URL to upload directly.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/s3-client.ts
import { S3Client } from &quot;@aws-sdk/client-s3&quot;;

export const s3Client = new S3Client({
  region: process.env.AWS_REGION!,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/presigned-url/route.ts
import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { PutObjectCommand } from &quot;@aws-sdk/client-s3&quot;;
import { getSignedUrl } from &quot;@aws-sdk/s3-request-presigner&quot;;
import { s3Client } from &quot;@/lib/s3-client&quot;;
import { randomUUID } from &quot;crypto&quot;;
import { auth } from &quot;@/lib/auth&quot;; // your auth solution of choice

const ALLOWED_TYPES = [&quot;image/jpeg&quot;, &quot;image/png&quot;, &quot;image/webp&quot;];
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB

export async function POST(request: NextRequest) {
  // 1. Authentication — reject anonymous requests before doing anything else
  const session = await auth();
  if (!session?.user) {
    return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });
  }

  const { fileName, fileType, fileSize } = await request.json();

  // 2. MIME allowlist — reject anything you don't explicitly support
  if (!ALLOWED_TYPES.includes(fileType)) {
    return NextResponse.json({ error: &quot;Unsupported file type&quot; }, { status: 400 });
  }

  // 3. Max file size — a best-effort check against the size the client reports.
  // A presigned PUT can't fully enforce this on its own; see the note below.
  if (typeof fileSize !== &quot;number&quot; || fileSize &amp;gt; MAX_FILE_SIZE) {
    return NextResponse.json({ error: &quot;File too large&quot; }, { status: 400 });
  }

  // 4. Filename sanitization — never trust the client's filename for anything
  // beyond picking a safe extension. Strip everything else.
  const safeExtension =
    fileName?.split(&quot;.&quot;).pop()?.replace(/[^a-z0-9]/gi, &quot;&quot;).slice(0, 10) || &quot;bin&quot;;

  // 5. Server-generated key — the client never chooses where its file lands.
  // Scoping by the authenticated user's id is also your authorization boundary:
  // even if someone tampers with the request, they can only ever get a
  // presigned URL scoped to their own prefix.
  const key = `uploads/${session.user.id}/${randomUUID()}.${safeExtension}`;

  const command = new PutObjectCommand({
    Bucket: process.env.S3_BUCKET_NAME!,
    Key: key,
    ContentType: fileType,
  });

  const uploadUrl = await getSignedUrl(s3Client, command, { expiresIn: 60 });

  return NextResponse.json({ uploadUrl, key });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; your server never sees the file bytes — it just signs a temporary permission slip — but it &lt;em&gt;does&lt;/em&gt; gate who gets that slip (authentication), what they're allowed to upload (MIME allowlist), roughly how big it can be, and exactly where it lands (a server-generated key under the caller's own user id, never a client-supplied path). The browser then does a plain &lt;code&gt;fetch(uploadUrl, { method: &quot;PUT&quot;, body: file })&lt;/code&gt; straight to S3, keeping large files off your server entirely — which matters a lot on serverless platforms with body-size limits.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;This example is still a demonstration, not a drop-in production upload endpoint.&lt;/strong&gt; A few things it deliberately leaves out, worth knowing about before you ship:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Authoritative size enforcement&lt;/strong&gt; — the &lt;code&gt;fileSize&lt;/code&gt; check above only validates what the client &lt;em&gt;claims&lt;/em&gt; its file is. A presigned &lt;code&gt;PutObjectCommand&lt;/code&gt; doesn't enforce a byte limit by itself. For a hard limit, use a &lt;strong&gt;presigned POST&lt;/strong&gt; with a &lt;code&gt;content-length-range&lt;/code&gt; condition instead of a presigned PUT, or verify the object's real size after upload (via an S3 event notification or a scheduled cleanup job) and delete anything oversized.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Checksum/content validation&lt;/strong&gt; — for anything security-sensitive, pass a &lt;code&gt;ChecksumSHA256&lt;/code&gt; to &lt;code&gt;PutObjectCommand&lt;/code&gt; so S3 rejects an upload whose bytes don't match what the client claimed, and consider scanning uploaded files (e.g. with a virus-scanning Lambda trigger) before treating them as safe to serve.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rate limiting&lt;/strong&gt; — nothing above stops one authenticated user from requesting thousands of presigned URLs per minute. Put a rate limiter (Upstash Ratelimit, a simple sliding-window check in your database, or your edge/CDN's built-in limiting) in front of this route in production.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Making the bucket public &quot;to make things easier.&quot; None of the common upload/display patterns need a public bucket — presigned URLs grant scoped, temporary access without exposing the bucket itself. Leave &lt;strong&gt;Block all public access&lt;/strong&gt; on unless you have a specific, narrow reason to open one prefix.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;supabase-storage-s3-underneath-postgres-on-top&quot;&gt;Supabase Storage: S3 Underneath, Postgres on Top&lt;/h3&gt;
&lt;p&gt;Supabase Storage provides an S3-compatible object-storage interface and integrates it with &lt;strong&gt;Supabase Auth&lt;/strong&gt; and &lt;strong&gt;Postgres RLS&lt;/strong&gt; — every bucket can be protected by &lt;strong&gt;Row Level Security policies&lt;/strong&gt;, the same SQL-based authorization system you're already using for your database tables if you're on Supabase.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Where Supabase Storage wins:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If you're already using Supabase Auth + Postgres, storage access rules live in the &lt;em&gt;same&lt;/em&gt; mental model — a policy like &quot;a user can only read their own files&quot; is one &lt;code&gt;create policy&lt;/code&gt; statement, not a separate IAM configuration&lt;/li&gt;
&lt;li&gt;Simple, batteries-included API (&lt;code&gt;upload&lt;/code&gt;, &lt;code&gt;download&lt;/code&gt;, &lt;code&gt;createSignedUrl&lt;/code&gt;) that works identically from the browser or the server&lt;/li&gt;
&lt;li&gt;Offers on-the-fly image transformations included via a query-param API, so you don't always need a separate CDN layer for simple resizing, although availability and usage depend on your plan&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Where Supabase Storage struggles:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Currently supports automatic WebP optimization but its transformation system is considerably narrower than Cloudinary's&lt;/li&gt;
&lt;li&gt;Free-tier storage and egress are modest — fine for an MVP, tight for a media-heavy app&lt;/li&gt;
&lt;li&gt;You're tied to Supabase's infrastructure choices; less low-level control than raw S3&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;a-minimal-supabase-storage-upload&quot;&gt;A Minimal Supabase Storage Upload&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install @supabase/supabase-js @supabase/ssr
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/supabase/client.ts
import { createBrowserClient } from &quot;@supabase/ssr&quot;;

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/avatar-uploader.tsx
&quot;use client&quot;;

import { createClient } from &quot;@/lib/supabase/client&quot;;

export function AvatarUploader() {
  const supabase = createClient();

  const handleUpload = async (e: React.ChangeEvent&amp;lt;HTMLInputElement&amp;gt;) =&amp;gt; {
    const file = e.target.files?.[0];
    if (!file) return;

    // Get the current user from the session — never take a userId as a
    // prop or form value. The path below is only as trustworthy as this.
    const {
      data: { user },
    } = await supabase.auth.getUser();

    if (!user) return;

    const filePath = `${user.id}/${Date.now()}-${file.name}`;

    const { error } = await supabase.storage
      .from(&quot;avatars&quot;)
      .upload(filePath, file, { upsert: false });

    if (error) {
      console.error(&quot;Upload failed:&quot;, error.message);
      return;
    }

    // For a private bucket, generate a temporary signed URL to display it
    const { data: signed } = await supabase.storage
      .from(&quot;avatars&quot;)
      .createSignedUrl(filePath, 60 * 60); // 1 hour

    console.log(&quot;Signed URL:&quot;, signed?.signedUrl);
  };

  return &amp;lt;input type=&quot;file&quot; accept=&quot;image/*&quot; onChange={handleUpload} /&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;.storage.from(&quot;avatars&quot;).upload(...)&lt;/code&gt; uploads directly through the Supabase client — no separate presigned-URL round trip needed, because auth and authorization already ride along with the client's session. &lt;code&gt;createSignedUrl&lt;/code&gt; mirrors S3's presigned-URL pattern, but scoped through the bucket's own RLS policies instead of IAM.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Only let a user manage files inside their own folder
create policy &quot;Users can upload their own avatar&quot;
on storage.objects
for insert
to authenticated
with check (
  bucket_id = 'avatars'
  and (select auth.uid())::text = (storage.foldername(name))[1]
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;storage.foldername(name)&lt;/code&gt; splits the object path (e.g. &lt;code&gt;user-id/photo.jpg&lt;/code&gt;) into segments, so this policy checks that the folder name matches the uploader's own &lt;code&gt;auth.uid()&lt;/code&gt;. The authorization model uses Postgres RLS, just like your database tables.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Important — don't mistake a client-supplied path for real security.&lt;/strong&gt; In the component above, &lt;code&gt;user.id&lt;/code&gt; comes from &lt;code&gt;supabase.auth.getUser()&lt;/code&gt;, which reads the &lt;em&gt;server-verified&lt;/em&gt; session — that part is trustworthy. But the resulting &lt;code&gt;filePath&lt;/code&gt; string is still just something the client constructs and sends along with the upload request. What actually makes this safe is the &lt;strong&gt;RLS policy&lt;/strong&gt;, which independently re-checks &lt;code&gt;(select auth.uid())&lt;/code&gt; against the path on Supabase's side, every time, regardless of what the client sends.&lt;/p&gt;
&lt;p&gt;This distinction matters: if you ever accept a user id as a raw prop, a hidden form field, or anything else that isn't derived from the verified session — or if RLS on the bucket is ever disabled or misconfigured — nothing stops a client from writing &lt;code&gt;someone-elses-id/file.jpg&lt;/code&gt; into the path and having the request succeed. The policy is the actual security boundary here, not the fact that the code &quot;usually&quot; builds the path from the right value.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Writing RLS policies by hand for every bucket pattern (owner-only, team-shared, admin override) gets repetitive fast. If you're already on Supabase, a &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/tools/rls-policy-generator&quot;&gt;Supabase RLS Policy Generator&lt;/a&gt; can produce the strict, performance-optimized version of these policies — including the &lt;code&gt;(select auth.uid())&lt;/code&gt; wrapper used above — for your specific access pattern.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;cloudinary-a-media-pipeline-for-images-and-video&quot;&gt;Cloudinary: A Media Pipeline for Images &lt;em&gt;and&lt;/em&gt; Video&lt;/h3&gt;
&lt;p&gt;Cloudinary's whole pitch is different from the other two: you're not really buying storage, you're buying &lt;strong&gt;transformation-on-delivery&lt;/strong&gt; — and it's easy to think of Cloudinary as &quot;an image tool,&quot; but that undersells it. Video is a first-class part of the product too: transcoding, adaptive bitrate streaming, and a dedicated video player component (&lt;code&gt;CldVideoPlayer&lt;/code&gt;) ship alongside the image tooling, not as an afterthought. Upload one asset once, and every size, crop, and format you'll ever need — image or video — gets generated from URL parameters, with no separate resize job and no manual WebP conversion.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Where Cloudinary wins:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Transformations are wildly capable across both media types: resize, crop, auto-format (automatically selects an appropriate optimized format for the requesting browser), AI background removal, text/image overlays for images; transcoding and adaptive streaming for video&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;next-cloudinary&lt;/code&gt; SDK gives you drop-in components — &lt;code&gt;&amp;lt;CldImage&amp;gt;&lt;/code&gt; wraps &lt;code&gt;next/image&lt;/code&gt;, &lt;code&gt;&amp;lt;CldVideoPlayer&amp;gt;&lt;/code&gt; handles video — with almost no configuration&lt;/li&gt;
&lt;li&gt;Built-in CDN delivery for both, so there's no separate CloudFront-equivalent to wire up&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Where Cloudinary struggles:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Free tier (25 credits/month, where 1 credit ≈ 1 GB storage, 1 GB bandwidth, &lt;em&gt;or&lt;/em&gt; 1,000 transformations) disappears fast on anything with real traffic — video in particular eats through credits quickly since files are larger&lt;/li&gt;
&lt;li&gt;Pricing is credit-based across three different resource types at once, which makes forecasting cost harder than a flat per-GB rate&lt;/li&gt;
&lt;li&gt;It's optimized for media-heavy applications; if most of your workload is arbitrary documents and backups, general-purpose object storage is usually a more natural fit&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;a-minimal-cloudinary-upload-display-in-next-js-16&quot;&gt;A Minimal Cloudinary Upload + Display in Next.js 16&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install next-cloudinary cloudinary
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/sign-cloudinary-params/route.ts
import { v2 as cloudinary } from &quot;cloudinary&quot;;
import { NextResponse } from &quot;next/server&quot;;
import { auth } from &quot;@/lib/auth&quot;; // your auth solution of choice

cloudinary.config({
  cloud_name: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
  api_key: process.env.CLOUDINARY_API_KEY,
  api_secret: process.env.CLOUDINARY_API_SECRET,
});

// The widget generates its own paramsToSign and sends them here — never
// sign that object blindly. Only a small, known set of keys is allowed
// through; anything else gets the whole request rejected.
const ALLOWED_PARAM_KEYS = new Set([&quot;timestamp&quot;, &quot;folder&quot;, &quot;upload_preset&quot;, &quot;source&quot;]);

export async function POST(request: Request) {
  const session = await auth();
  if (!session?.user) {
    return NextResponse.json({ error: &quot;Unauthorized&quot; }, { status: 401 });
  }

  const { paramsToSign } = await request.json();
  const keys = Object.keys(paramsToSign ?? {});
  const unexpectedKeys = keys.filter((key) =&amp;gt; !ALLOWED_PARAM_KEYS.has(key));

  if (unexpectedKeys.length &amp;gt; 0) {
    return NextResponse.json(
      { error: `Unexpected upload parameters: ${unexpectedKeys.join(&quot;, &quot;)}` },
      { status: 400 }
    );
  }

  const signature = cloudinary.utils.api_sign_request(
    paramsToSign,
    process.env.CLOUDINARY_API_SECRET!
  );

  return NextResponse.json({ signature });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; the widget builds &lt;code&gt;paramsToSign&lt;/code&gt; on its own based on how it's configured, and sends that exact object to your endpoint to be signed — so you can't simply substitute your own parameters the way you might for a normal API request, or the signature won't match what the widget actually uploads with. What you &lt;em&gt;can&lt;/em&gt; do, and what this endpoint does, is refuse to sign anything that isn't on a small allowlist of expected keys, and require a logged-in session before generating a signature at all. This stops the endpoint from being used to sign arbitrary transformation instructions, moderation overrides, or unexpected metadata that a modified client request might try to sneak in. Once signed, the upload still goes directly from the browser to Cloudinary — your &lt;code&gt;CLOUDINARY_API_SECRET&lt;/code&gt; never reaches the client.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Signed uploads narrow &lt;em&gt;who&lt;/em&gt; can request a signature, but the file itself — its folder, allowed formats, and max size — is still best constrained by an &lt;strong&gt;upload preset&lt;/strong&gt; configured in the Cloudinary dashboard (Settings → Upload → Upload presets), since a preset's restrictions are enforced by Cloudinary itself and can't be bypassed by tampering with the request the way a loosely-validated &lt;code&gt;paramsToSign&lt;/code&gt; object could be.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/cloudinary-uploader.tsx
&quot;use client&quot;;

import { useState } from &quot;react&quot;;
import { CldUploadWidget, CldImage } from &quot;next-cloudinary&quot;;

export function CloudinaryUploader() {
  const [publicId, setPublicId] = useState&amp;lt;string | null&amp;gt;(null);

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;CldUploadWidget
        signatureEndpoint=&quot;/api/sign-cloudinary-params&quot;
        options={{ folder: &quot;uploads&quot; }}
        onSuccess={(result) =&amp;gt; {
          if (result.info &amp;amp;&amp;amp; typeof result.info === &quot;object&quot;) {
            setPublicId(result.info.public_id);
          }
        }}
      &amp;gt;
        {({ open }) =&amp;gt; (
          &amp;lt;button onClick={() =&amp;gt; open()}&amp;gt;Upload an image&amp;lt;/button&amp;gt;
        )}
      &amp;lt;/CldUploadWidget&amp;gt;

      {publicId &amp;amp;&amp;amp; (
        &amp;lt;CldImage
          src={publicId}
          width={600}
          height={400}
          crop=&quot;fill&quot;
          alt=&quot;Uploaded image&quot;
        /&amp;gt;
      )}
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;&amp;lt;CldImage&amp;gt;&lt;/code&gt; renders the uploaded asset with a &lt;code&gt;crop=&quot;fill&quot;&lt;/code&gt; transformation applied automatically, on top of &lt;code&gt;next/image&lt;/code&gt;'s own optimization. Swap this component for &lt;code&gt;&amp;lt;CldVideoPlayer&amp;gt;&lt;/code&gt; and the same signed-upload flow works for video uploads, using the same signing endpoint.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Using an &lt;strong&gt;unsigned&lt;/strong&gt; upload preset (no &lt;code&gt;signatureEndpoint&lt;/code&gt;, just a public preset name) for anything beyond a quick prototype. Anyone who knows the preset name can initiate uploads subject to the restrictions configured on that preset — fine for a personal demo, risky for anything public-facing.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;media-optimization-who-actually-handles-it-&quot;&gt;Media Optimization: Who Actually Handles It?&lt;/h3&gt;
&lt;p&gt;This is usually the deciding factor in practice, so it's worth calling out directly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;S3&lt;/strong&gt; does nothing for you, for images or video. You either accept unoptimized originals, or build your own pipeline (Lambda@Edge, a CloudFront Function, a dedicated transcoding service, or routing S3 through Cloudinary/imgix as a second layer).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Supabase Storage&lt;/strong&gt; offers a transformation API (resize + quality via query params) that works with a &lt;a rel=&quot;nofollow&quot; href=&quot;https://supabase.com/docs/guides/storage/serving/image-transformations#nextjs-loader&quot;&gt;custom Next.js image loader&lt;/a&gt; — available only in Paid Plans, and images only; there's no equivalent video pipeline.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cloudinary&lt;/strong&gt; is purpose-built for exactly this, for both media types. Automatic format negotiation (AVIF/WebP per browser), on-the-fly cropping, &lt;code&gt;&amp;lt;CldImage&amp;gt;&lt;/code&gt;'s tight &lt;code&gt;next/image&lt;/code&gt; integration, and adaptive-bitrate video streaming mean you write the least code here.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If your app's core UX depends on fast, well-optimized media at scale — a marketplace, a photo-heavy blog, a portfolio builder, or a product with video uploads (course platforms, social apps, UGC video) — that alone can justify Cloudinary's cost. If media is incidental — a profile picture here, a document there — S3 or Supabase Storage is plenty.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;security-models-compared&quot;&gt;Security Models Compared&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;How access is controlled&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;S3&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;IAM policies (who can call the API) + bucket policies (what's publicly readable) + presigned URLs for temporary, scoped access&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Supabase Storage&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Row Level Security policies written in SQL, evaluated per-request against the logged-in user — the same system protecting your database tables&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Cloudinary&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Signed uploads (server signs the request, ideally after validating it) or unsigned upload presets (public, rate-limited by preset config)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The practical difference: S3 and Cloudinary both support &lt;strong&gt;signed requests for scoped upload/access workflows&lt;/strong&gt;, while Supabase emphasizes &lt;strong&gt;identity-aware RLS policies&lt;/strong&gt;. Supabase Storage centers on &lt;strong&gt;who is asking&lt;/strong&gt;, evaluated through Postgres RLS policies. If your app already has rich, row-level permission logic in Postgres, Supabase Storage lets you reuse that thinking instead of maintaining a parallel IAM policy language — but in all three, a client-constructed path or parameter is never the actual security boundary on its own; the server-side check (RLS, IAM, or a whitelist) is.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;can-you-mix-them-&quot;&gt;Can You Mix Them?&lt;/h3&gt;
&lt;p&gt;Yes, and plenty of real apps do. A common pattern:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;S3&lt;/strong&gt; (or Cloudflare R2, an S3-compatible alternative) for cheap, high-volume storage of originals — documents, raw uploads, backups&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cloudinary&lt;/strong&gt; in front of just the &lt;em&gt;media&lt;/em&gt; subset (images and video), either by uploading directly to Cloudinary or using its &lt;a rel=&quot;nofollow&quot; href=&quot;https://cloudinary.com/documentation/fetch_remote_images&quot;&gt;remote fetch&lt;/a&gt; feature to transform images that already live in S3&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Supabase Storage&lt;/strong&gt; for anything tightly coupled to your Supabase-authenticated user data, where RLS access rules matter more than transformation power&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There's no rule that says &quot;pick exactly one.&quot; Reach for the tool that fits &lt;em&gt;that specific type of file&lt;/em&gt;, not a single storage layer for your entire app by default.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;decision-matrix&quot;&gt;Decision Matrix&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Your situation &lt;/th&gt;&lt;th&gt;Pick&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Already deep in AWS infrastructure &lt;/td&gt;&lt;td&gt;S3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Need the cheapest storage at real scale, and can account for request/transfer costs &lt;/td&gt;&lt;td&gt;S3 / R2 depending on egress profile&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Already using Supabase for auth + database &lt;/td&gt;&lt;td&gt;Supabase Storage&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Want row-level access rules without a separate IAM setup &lt;/td&gt;&lt;td&gt;Supabase Storage&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Images/video are core to your product's UX &lt;/td&gt;&lt;td&gt;Cloudinary&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Want automatic format optimization with near-zero setup &lt;/td&gt;&lt;td&gt;Cloudinary&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Storing mostly non-image files (PDFs, CSVs, backups) &lt;/td&gt;&lt;td&gt;S3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Small MVP, want the fastest path to &quot;it works&quot; &lt;/td&gt;&lt;td&gt;Supabase Storage if the app already uses Supabase;&amp;lt;br/&amp;gt;Cloudinary if images or video are central.&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;frequently-asked-questions&quot;&gt;Frequently Asked Questions&lt;/h3&gt;
&lt;h4 id=&quot;is-supabase-storage-actually-built-on-s3-&quot;&gt;Is Supabase Storage actually built on S3?&lt;/h4&gt;
&lt;p&gt;Supabase Storage is S3-compatible and exposes an S3 protocol endpoint, so tools that speak the S3 API can interact with it. The important distinction is that Supabase adds its own API, authentication integration, and Postgres RLS model.&lt;/p&gt;
&lt;h4 id=&quot;do-i-need-cloudinary-if-i-m-already-using-next-image-&quot;&gt;Do I need Cloudinary if I'm already using next/image?&lt;/h4&gt;
&lt;p&gt;Not necessarily. &lt;code&gt;next/image&lt;/code&gt; already handles resizing, format conversion, and lazy loading for images you serve from S3 or Supabase Storage — it just needs the source domain allowlisted in &lt;code&gt;remotePatterns&lt;/code&gt;. Reach for Cloudinary specifically when you need transformations &lt;code&gt;next/image&lt;/code&gt; doesn't do itself, like AI background removal, video transcoding, or on-the-fly cropping driven by user input.&lt;/p&gt;
&lt;h4 id=&quot;which-one-is-cheapest-for-a-small-side-project-&quot;&gt;Which one is cheapest for a small side project?&lt;/h4&gt;
&lt;p&gt;For a small MVP, Supabase Storage is attractive if you're already using Supabase, while Cloudinary is attractive when image or video transformations are central. S3 can also be inexpensive, but remember it's billed across storage, requests, and data transfer separately — factor in all three before assuming it's the cheapest option for your specific traffic pattern.&lt;/p&gt;
&lt;h4 id=&quot;is-a-presigned-s3-upload-url-alone-enough-security-&quot;&gt;Is a presigned S3 upload URL alone enough security?&lt;/h4&gt;
&lt;p&gt;No. A presigned URL controls &lt;em&gt;whether&lt;/em&gt; an upload to a specific key is allowed within its expiry window — it doesn't validate file type, size, or content on its own beyond what you sign into the request. Pair it with server-side checks (auth, MIME allowlist, size limits) before issuing the URL, as shown in this guide's example, and treat anything beyond that (checksums, virus scanning, rate limiting) as genuinely needed for production, not optional polish.&lt;/p&gt;
&lt;h4 id=&quot;can-i-switch-between-these-later-without-a-full-rewrite-&quot;&gt;Can I switch between these later without a full rewrite?&lt;/h4&gt;
&lt;p&gt;Keeping storage access behind a small abstraction makes migration easier. The files themselves may be easy to migrate; the harder part is usually recreating URLs, access-control rules, transformations, and metadata.&lt;/p&gt;
&lt;h4 id=&quot;does-cloudinary-replace-the-need-for-a-cdn-&quot;&gt;Does Cloudinary replace the need for a CDN?&lt;/h4&gt;
&lt;p&gt;Yes — Cloudinary delivers every asset through its own CDN by default, so you don't need to add CloudFront or another CDN in front of it the way you would with raw S3.&lt;/p&gt;
&lt;h4 id=&quot;what-about-cloudflare-r2-as-another-alternative-&quot;&gt;What about Cloudflare R2 as another alternative?&lt;/h4&gt;
&lt;p&gt;R2's S3-compatible API makes many application patterns—such as presigned URLs and S3 SDK usage—very similar to S3, although its pricing and access-control model differ.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-1&quot;&gt;Wrapping Up&lt;/h3&gt;
&lt;p&gt;None of these three is a universally &quot;better&quot; choice — they're solving different halves of the same problem. S3 gives you the cheapest, most flexible raw storage and expects you to build the rest, including the parts of its own billing model (requests, transfer, retrieval) that are easy to overlook. Supabase Storage trades some of that flexibility for tight integration with the auth and database system you're probably already using — just remember that a client-supplied path is never the actual security boundary; the RLS policy is. Cloudinary trades storage cost for a transformation pipeline — for images &lt;em&gt;and&lt;/em&gt; video — that would otherwise take real engineering time to build yourself.&lt;/p&gt;
&lt;p&gt;Start by asking what you're actually storing and who needs to see it — that answer usually points to one of these three faster than a feature-by-feature comparison ever will.&lt;/p&gt;
&lt;h3 id=&quot;continue-learning&quot;&gt;Continue Learning&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/s3-file-uploads-nextjs-16&quot;&gt;Next.js 16 AWS S3 File Upload Guide: Presigned URLs, Proxy &amp;amp; Multipart&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/s3-display-images-nextjs-16-guide&quot;&gt;How to Display Images from S3 in Next.js 16 (2026 Guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/nextjs-16-supabase-todo-app&quot;&gt;Build a Todo App with Next.js 16 and Supabase (2026 Guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/image-optimization-nextjs-16&quot;&gt;Image Optimization in Next.js 16: The Complete 2026 Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/supabase-auth-vs-firebase-auth&quot;&gt;Supabase Auth vs Firebase Auth: Which One Should You Choose in 2026?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;free-developer-tools&quot;&gt;Free Developer Tools&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/tools/rls-policy-generator&quot;&gt;Supabase RLS Policy Generator&lt;/a&gt; — generate strict, pattern-specific Row Level Security policies for your storage buckets, including the owner-only pattern shown in this guide.&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25886/s3-vs-supabase-storage-vs-cloudinary-2026-guide</guid>
<pubDate>Mon, 31 Aug 2026 03:19:01 +0000</pubDate>
</item>
<item>
<title>Developer Stories: Jaafar on Building Reliable Systems for Arabic Newsrooms</title>
<link>https://coderlegion.com/25201/developer-stories-jaafar-on-building-reliable-systems-for-arabic-newsrooms</link>
<description>&lt;h2 id=&quot;developer-stories-jaafar-on-building-reliable-systems-for-arabic-newsrooms&quot;&gt;Developer Stories: Jaafar on Building Reliable Systems for Arabic Newsrooms&lt;/h2&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Developer Stories&lt;/strong&gt; is a CoderLegion series highlighting the real journeys, challenges, lessons, and experiences of developers building software in the real world.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;For this edition, we spoke with &lt;strong&gt;Jaafar&lt;/strong&gt;, founder of ManTek Technologies in Dubai, about his journey from studying IT engineering and AI at Damascus University to working with AWS, WordPress, and high-traffic publishing systems.&lt;/p&gt;
&lt;p&gt;Rather than focusing on a traditional career timeline, this conversation explores something developers often learn only through experience: &lt;strong&gt;the difference between systems that appear to work and systems that can actually be trusted.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;From silent failures and Arabic-language challenges to scaling WordPress and building NUZ, Jaafar shares lessons learned from working with production systems where mistakes can become visible to thousands—or millions—of users.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;from-damascus-university-to-mantek&quot;&gt;From Damascus University to ManTek&lt;/h3&gt;
&lt;p&gt;Jaafar studied IT engineering with a focus on AI, which he describes as involving a lot of statistics and not much of what he eventually ended up doing professionally.&lt;/p&gt;
&lt;p&gt;His path into technology and media took him to the UAE, where he joined &lt;strong&gt;Al Khaleej Newspaper&lt;/strong&gt;.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Publishing has a rhythm you do not appreciate from outside: it never pauses, the deadlines are real, and when something is slow or wrong it is wrong in public.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That experience led to work with a television channel in Dubai Media City, where the systems became considerably deeper and the scale considerably larger.&lt;/p&gt;
&lt;p&gt;AWS was already part of the technology stack when he arrived.&lt;/p&gt;
&lt;p&gt;Rather than learning cloud technology through tutorials or isolated projects, he learned it while working on systems carrying real traffic.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;AWS was already the stack when I arrived there, so I learned it the useful way, on a system that was already carrying real traffic.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;For Jaafar, that became one of the biggest additions to his career.&lt;/p&gt;
&lt;p&gt;WordPress came later, and interestingly, the reason wasn't primarily technical.&lt;/p&gt;
&lt;p&gt;Management had decided to move away from depending on in-house systems.&lt;/p&gt;
&lt;p&gt;Bespoke platforms can be expensive to build and maintain, while also creating a significant learning curve whenever a new developer joins the team.&lt;/p&gt;
&lt;p&gt;WordPress offered something different: a platform developers already knew and could extend.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;I think that reasoning is underrated.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Eventually, ManTek Technologies grew out of Jaafar having enough experience to be useful on his own, combined with a backlog of ideas he had been carrying around for years.&lt;/p&gt;
&lt;p&gt;At some point, building those ideas became more interesting than leaving them unbuilt.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;when-systems-fail-quietly&quot;&gt;When Systems Fail Quietly&lt;/h3&gt;
&lt;p&gt;One of the recurring themes in Jaafar's work is &lt;strong&gt;silent failure&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;A system that crashes is usually obvious.&lt;/p&gt;
&lt;p&gt;A system that confidently gives you the wrong answer can be much more dangerous.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Because a failing check tells you it failed. A blind one hands you a clean, confident, wrong answer and lets you act on it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;One example came while auditing structured data on his own website.&lt;/p&gt;
&lt;p&gt;A profile URL appeared to be returning a &quot;Page not found&quot; response. After checking it in a real browser and running automated checks for common bot-blocking behaviour, everything appeared normal.&lt;/p&gt;
&lt;p&gt;So he removed the URL from twenty-two pages and began preparing the commit message.&lt;/p&gt;
&lt;p&gt;Then he discovered the problem.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The profile actually existed.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The platform was simply serving logged-out visitors a page saying that it did not.&lt;/p&gt;
&lt;p&gt;Nothing had timed out. Nothing had crashed. The check had not reported an error.&lt;/p&gt;
&lt;p&gt;It had accurately reported the page it had been given—even though that page itself was misleading.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;The instrument had not errored. It had truthfully reported on the page it was given, which happened to be a page about not finding things.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Once he recognised the pattern, he found five more examples in the same week.&lt;/p&gt;
&lt;p&gt;There was an API field answering a different question than the one he intended to ask. A list endpoint disagreed with its own item endpoint. An analytics beacon could not be detected with &lt;code&gt;curl&lt;/code&gt; for two independent reasons.&lt;/p&gt;
&lt;p&gt;There were also editors that accepted a save and appeared to complete the operation while nothing actually reached disk.&lt;/p&gt;
&lt;p&gt;That last failure was particularly painful.&lt;/p&gt;
&lt;p&gt;A fully reviewed Arabic translation—forty-eight changed lines—existed nowhere except inside a browser tab.&lt;/p&gt;
&lt;p&gt;The important lesson wasn't that these problems were technically difficult.&lt;/p&gt;
&lt;p&gt;They weren't.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;The danger is not that these are hard to fix. Every one was trivial to fix. The danger is that nothing asks you to look.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mantek.io/insights/the-404-that-wasnt&quot;&gt;The 404 That Wasn't&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;building-for-arabic&quot;&gt;Building for Arabic&lt;/h3&gt;
&lt;p&gt;One of the biggest challenges developers can underestimate when building for Arabic is that many assumptions in software were never explicitly written down.&lt;/p&gt;
&lt;p&gt;That means when those assumptions break, the system may not necessarily tell you.&lt;/p&gt;
&lt;p&gt;Jaafar highlights several examples.&lt;/p&gt;
&lt;h4 id=&quot;arabic-urls-and-byte-limits&quot;&gt;Arabic URLs and Byte Limits&lt;/h4&gt;
&lt;p&gt;WordPress caps a new URL slug at 200 &lt;strong&gt;bytes&lt;/strong&gt; in core through &lt;code&gt;utf8_uri_encode( $title, 200 )&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;For English, that budget roughly corresponds to 200 characters.&lt;/p&gt;
&lt;p&gt;Arabic is different.&lt;/p&gt;
&lt;p&gt;Because Arabic characters can require approximately six bytes after percent-encoding, the same limit can translate to roughly thirty-two characters.&lt;/p&gt;
&lt;p&gt;A normal Arabic headline can exceed that easily.&lt;/p&gt;
&lt;p&gt;The result can be a URL silently truncated in the middle of a word.&lt;/p&gt;
&lt;p&gt;Even more concerning, this can affect URLs that have already been published.&lt;/p&gt;
&lt;h4 id=&quot;search&quot;&gt;Search&lt;/h4&gt;
&lt;p&gt;Arabic search introduces another class of problems.&lt;/p&gt;
&lt;p&gt;WordPress search uses &lt;code&gt;LIKE '%term%'&lt;/code&gt; over the post table, comparing raw bytes.&lt;/p&gt;
&lt;p&gt;But Arabic contains optional diacritics, multiple written forms of letters such as alef and hamza, and characters such as &lt;strong&gt;tatweel&lt;/strong&gt;, which can stretch a word without changing its meaning to a reader.&lt;/p&gt;
&lt;p&gt;On a real archive containing 250,000 articles, Jaafar compared a search for:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;مُحَمَّد&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The results were striking:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Search across 250,000 articles  &lt;/th&gt;&lt;th&gt;   Results &lt;/th&gt;&lt;th&gt;  Time&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;WordPress                       &lt;/td&gt;&lt;td&gt;     &lt;strong&gt;1&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; 4.8 s&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;With normalisation and an index &lt;/td&gt;&lt;td&gt;&lt;strong&gt;28,238&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;0.55 s&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;One result instead of more than twenty-eight thousand.&lt;/p&gt;
&lt;p&gt;And again, there was no obvious error.&lt;/p&gt;
&lt;p&gt;The editors experiencing the problem didn't necessarily report that search was broken.&lt;/p&gt;
&lt;p&gt;They reported that it was slow.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Slow is the part you can feel.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;rendering-arabic&quot;&gt;Rendering Arabic&lt;/h4&gt;
&lt;p&gt;The challenges don't stop at encoding and search.&lt;/p&gt;
&lt;p&gt;Jaafar also encountered an issue while generating images containing Arabic text.&lt;/p&gt;
&lt;p&gt;He kept anchoring the text to the wrong edge.&lt;/p&gt;
&lt;p&gt;The relevant attribute was &lt;code&gt;text-anchor&lt;/code&gt;, where &lt;code&gt;start&lt;/code&gt; does not necessarily mean &quot;left&quot;. It means the start of the reading direction—which, for Arabic, is the right.&lt;/p&gt;
&lt;p&gt;The library was behaving correctly.&lt;/p&gt;
&lt;p&gt;His intuition was wrong.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;The general lesson: if you build for a language you do not read, your tests will share your assumptions. Test with real content from real users.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mantek.io/insights/wordpress-arabic-search&quot;&gt;WordPress Arabic Search&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;scaling-wordpress&quot;&gt;Scaling WordPress&lt;/h3&gt;
&lt;p&gt;Jaafar has worked with genuinely large Arabic newsroom traffic, and his lessons about scaling WordPress are less about simply throwing more resources at the application.&lt;/p&gt;
&lt;p&gt;He identifies three major lessons.&lt;/p&gt;
&lt;h4 id=&quot;the-spike-is-rarely-the-interesting-problem&quot;&gt;The spike is rarely the interesting problem&lt;/h4&gt;
&lt;p&gt;A 20x traffic surge on a news story can sound terrifying.&lt;/p&gt;
&lt;p&gt;But much of that traffic consists of identical anonymous requests that can be handled by caching.&lt;/p&gt;
&lt;p&gt;The more interesting problems are the requests that &lt;strong&gt;cannot&lt;/strong&gt; be cached.&lt;/p&gt;
&lt;p&gt;Search.&lt;/p&gt;
&lt;p&gt;Logged-in editorial traffic.&lt;/p&gt;
&lt;p&gt;And the write path that still needs to publish content while everything else is happening.&lt;/p&gt;
&lt;p&gt;Jaafar also makes an important clarification about the often-mentioned 50,000 concurrent-user figure:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;It is load-tested, not an observed peak.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The test demonstrates that the cacheable path can hold up.&lt;/p&gt;
&lt;p&gt;It says much less about the newsroom itself—and the newsroom is the part that keeps you awake.&lt;/p&gt;
&lt;h4 id=&quot;background-jobs-fail-quietly&quot;&gt;Background jobs fail quietly&lt;/h4&gt;
&lt;p&gt;WordPress cron is another potential weak point.&lt;/p&gt;
&lt;p&gt;Because cron is triggered by traffic, putting WordPress behind a load balancer can result in cron running on every server simultaneously—or on none of them.&lt;/p&gt;
&lt;p&gt;Scheduled posts can fail to publish without necessarily producing an obvious alarm.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;Nothing happened&quot; is not an event.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This is why one of Jaafar's open-source plugins elects a single cron runner across a fleet and raises an alert when it stalls.&lt;/p&gt;
&lt;h4 id=&quot;move-work-away-from-wordpress&quot;&gt;Move work away from WordPress&lt;/h4&gt;
&lt;p&gt;Media processing is a good example.&lt;/p&gt;
&lt;p&gt;Serving and resizing images directly from the application means the application is doing work that doesn't necessarily belong there.&lt;/p&gt;
&lt;p&gt;Moving that work to object storage and an edge layer means the origin server becomes less sensitive to traffic spikes.&lt;/p&gt;
&lt;p&gt;After five years of working with large-scale Arabic newsroom traffic, Jaafar sees a consistent pattern:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Uptime comes from having less of the system in the request path, not from tuning what is in it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-importance-of-observability&quot;&gt;The Importance of Observability&lt;/h3&gt;
&lt;p&gt;For Jaafar, observability is slightly different from simply having logging and monitoring.&lt;/p&gt;
&lt;p&gt;A system can have both and still fail silently.&lt;/p&gt;
&lt;p&gt;The question he cares about is:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Can a check distinguish &quot;this is fine&quot; from &quot;I cannot see this&quot;?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Many systems cannot.&lt;/p&gt;
&lt;p&gt;An uptime monitor hitting a homepage can tell you that the homepage renders.&lt;/p&gt;
&lt;p&gt;It cannot tell you whether scheduled posts were published, whether email actually left the system, or whether search returned the correct records.&lt;/p&gt;
&lt;p&gt;He experienced this distinction on his own website when two analytics counters for the same site and period disagreed by roughly two orders of magnitude.&lt;/p&gt;
&lt;p&gt;Neither system was broken.&lt;/p&gt;
&lt;p&gt;Neither was lying.&lt;/p&gt;
&lt;p&gt;They were simply answering different questions.&lt;/p&gt;
&lt;p&gt;One counted every request reaching the edge, including bots.&lt;/p&gt;
&lt;p&gt;The other counted browsers that actually executed the measurement script.&lt;/p&gt;
&lt;p&gt;Only one represented the number of people reading the site.&lt;/p&gt;
&lt;p&gt;Using the wrong number would have overstated readership by approximately eighty times.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Before you trust a number, know what it counts. That is observability too, and it is the part nobody writes runbooks for.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;His approach is therefore to alert on outcomes users would actually notice rather than simply monitoring whichever signal is easiest to collect.&lt;/p&gt;
&lt;p&gt;Failures should be loud by design.&lt;/p&gt;
&lt;p&gt;One of his plugins, for example, refuses to send mail when it is misconfigured rather than silently falling through to a transport that appears healthy but delivers nothing.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Refusing is friendlier than pretending.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;open-source-and-real-world-problems&quot;&gt;Open Source and Real-World Problems&lt;/h3&gt;
&lt;p&gt;Jaafar has now released five open-source projects, each connected to the real-world problem that led to it.&lt;/p&gt;
&lt;p&gt;For him, the connection between the diagnosis and the fix is important.&lt;/p&gt;
&lt;p&gt;The fixes themselves are often small.&lt;/p&gt;
&lt;p&gt;For example, &lt;code&gt;wp-arabic-slug-schema-guard&lt;/code&gt; addresses the byte-budget problem around Arabic WordPress slugs.&lt;/p&gt;
&lt;p&gt;But the valuable part isn't just the code.&lt;/p&gt;
&lt;p&gt;It is understanding &lt;strong&gt;why&lt;/strong&gt; the problem exists.&lt;/p&gt;
&lt;p&gt;WordPress truncates at 200 bytes.&lt;/p&gt;
&lt;p&gt;Arabic can consume approximately six bytes per character.&lt;/p&gt;
&lt;p&gt;And published slugs can potentially be rewritten underneath an existing site.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;A fix without that context is a snippet nobody trusts. The context without a fix is a complaint. Publishing both together is the only version that is actually useful to the next person.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The plugins are deliberately implemented as must-use plugins rather than ordinary plugins.&lt;/p&gt;
&lt;p&gt;The reason is that infrastructure such as cron election and mail transport should not be something that can simply be switched off from an administrator screen.&lt;/p&gt;
&lt;p&gt;There was even a lesson in the packaging of the plugins themselves.&lt;/p&gt;
&lt;p&gt;Several plugins declared:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;&quot;type&quot;: &quot;wordpress-muplugin&quot;&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;so that Composer could install them into the correct directory.&lt;/p&gt;
&lt;p&gt;But two of them did so without requiring &lt;code&gt;composer/installers&lt;/code&gt;, the package responsible for interpreting that type.&lt;/p&gt;
&lt;p&gt;In projects that already included the dependency, everything worked.&lt;/p&gt;
&lt;p&gt;Elsewhere, Composer reported a successful installation but placed the plugin in &lt;code&gt;vendor/&lt;/code&gt;, where WordPress never loaded it.&lt;/p&gt;
&lt;p&gt;The installation succeeded.&lt;/p&gt;
&lt;p&gt;The plugin simply did nothing.&lt;/p&gt;
&lt;p&gt;Which was, ironically, exactly the kind of silent failure the plugins were designed to prevent.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;What I learned from releasing them: the environment is the bug, almost every time.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Across six rounds of adversarial testing on one of the projects, every bug discovered involved either a check that could not fail or a silent wrong answer.&lt;/p&gt;
&lt;p&gt;And none of those bugs were found simply by reading the code.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/mantekio&quot;&gt;ManTek Technologies on GitHub&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;building-nuz&quot;&gt;Building NUZ&lt;/h3&gt;
&lt;p&gt;Jaafar is currently developing &lt;strong&gt;NUZ&lt;/strong&gt;, a cloud-native publishing platform aimed at high-traffic newsrooms.&lt;/p&gt;
&lt;p&gt;He is careful to clarify its current status:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;NUZ is pre-release. I am building the core, and there are no external partners on it yet.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That distinction matters.&lt;/p&gt;
&lt;p&gt;The project is not being presented as a platform already running with external partners. It is still being built.&lt;/p&gt;
&lt;p&gt;The problem NUZ is intended to address is the way many publishing platforms approach scale.&lt;/p&gt;
&lt;p&gt;A newsroom might begin with a CMS designed for a blog.&lt;/p&gt;
&lt;p&gt;Then caching is added.&lt;/p&gt;
&lt;p&gt;Then a CDN.&lt;/p&gt;
&lt;p&gt;Then a media pipeline.&lt;/p&gt;
&lt;p&gt;Then a search layer.&lt;/p&gt;
&lt;p&gt;Each addition introduces another component and another potential failure mode.&lt;/p&gt;
&lt;p&gt;Eventually, the newsroom is maintaining an architecture that nobody originally designed as a whole.&lt;/p&gt;
&lt;p&gt;NUZ starts from a different assumption:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;The traffic is spiky, the content is Arabic, and the editorial team publishes continuously while all of that is true.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;engineering-in-the-real-world&quot;&gt;Engineering in the Real World&lt;/h3&gt;
&lt;p&gt;When working with production systems, clever solutions are not always the best solutions.&lt;/p&gt;
&lt;p&gt;Jaafar learned this through experience.&lt;/p&gt;
&lt;p&gt;One early fix involved copying a core function, changing one line, and shipping it.&lt;/p&gt;
&lt;p&gt;It worked.&lt;/p&gt;
&lt;p&gt;Then the underlying platform changed that function three times across three releases.&lt;/p&gt;
&lt;p&gt;His copy quietly diverged from the original implementation.&lt;/p&gt;
&lt;p&gt;Eventually, the bug that returned looked nothing like the problem he had originally fixed.&lt;/p&gt;
&lt;p&gt;The better solution was to use a documented filter and replace only the transport.&lt;/p&gt;
&lt;p&gt;No fork.&lt;/p&gt;
&lt;p&gt;No duplicated implementation.&lt;/p&gt;
&lt;p&gt;No code that needed to be kept in sync with the platform.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;When the platform gives you a seam, take the seam. Never fork.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The broader lesson is simple:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Prefer the boring integration point that the platform promises to keep working over the clever one that happens to work today.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;advice-for-developers&quot;&gt;Advice for Developers&lt;/h3&gt;
&lt;p&gt;When asked what habit developers should develop to become better at building reliable systems, Jaafar offered a surprisingly simple answer.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Before you accept that a check found nothing, point it at something you know is there.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The idea is straightforward.&lt;/p&gt;
&lt;p&gt;If you are checking whether a value appears in a file, first test the check against a file where you know the value exists.&lt;/p&gt;
&lt;p&gt;If you are verifying that a record is absent, first query a record you know exists.&lt;/p&gt;
&lt;p&gt;The goal is to establish that the check itself is capable of finding what it is supposed to find.&lt;/p&gt;
&lt;p&gt;Because sometimes the most dangerous result isn't an error.&lt;/p&gt;
&lt;p&gt;It is a perfectly clean &lt;strong&gt;&quot;nothing found.&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;final-thoughts-1&quot;&gt;Final Thoughts&lt;/h3&gt;
&lt;p&gt;Jaafar's journey is a good example of how real engineering experience is often built in places that tutorials don't cover.&lt;/p&gt;
&lt;p&gt;It is built in production systems.&lt;/p&gt;
&lt;p&gt;In traffic spikes.&lt;/p&gt;
&lt;p&gt;In unexpected platform behaviour.&lt;/p&gt;
&lt;p&gt;In multilingual content.&lt;/p&gt;
&lt;p&gt;In failed background jobs.&lt;/p&gt;
&lt;p&gt;In analytics numbers that answer the wrong question.&lt;/p&gt;
&lt;p&gt;And especially in the quiet failures that look like success.&lt;/p&gt;
&lt;p&gt;His approach is ultimately less about making systems look impressive and more about making them &lt;strong&gt;honest&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;A system should tell you when it doesn't know.&lt;/p&gt;
&lt;p&gt;A check should prove that it can actually see what it claims to see.&lt;/p&gt;
&lt;p&gt;And when a platform provides a supported way to extend it, use that instead of building something that will quietly drift away from the original system.&lt;/p&gt;
&lt;p&gt;For developers working with real users and real production systems, those lessons can be worth far more than another tutorial.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;about-developer-stories&quot;&gt;About Developer Stories&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Developer Stories&lt;/strong&gt; is a CoderLegion series featuring developers from our community and exploring the real journeys behind the code—the challenges they have faced, lessons they have learned, projects they are building, and experiences that can help other developers grow.&lt;/p&gt;
&lt;p&gt;If you have a developer journey, engineering lesson, project, or experience you'd like to share with the CoderLegion community, we'd love to hear from you.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25201/developer-stories-jaafar-on-building-reliable-systems-for-arabic-newsrooms</guid>
<pubDate>Mon, 24 Aug 2026 01:25:35 +0000</pubDate>
</item>
<item>
<title>Setting Up Your AWS Builder ID &amp; Connecting to Skill Builder</title>
<link>https://coderlegion.com/25166/setting-up-your-aws-builder-id-connecting-to-skill-builder</link>
<description>&lt;p&gt;An &lt;strong&gt;AWS Builder ID&lt;/strong&gt; is your personal profile used to access AWS developer and learning tools like the AWS Builder Center, Amazon Q Developer, and AWS Skill Builder without a paid corporate AWS account.&lt;/p&gt;
&lt;h3 id=&quot;phase-1-how-to-create-an-aws-builder-id&quot;&gt;Phase 1: How to Create an AWS Builder ID&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/552qd5gcu9j7rmg44doh.jpeg&quot; alt=&quot;AWS Builder Center sign-in landing page&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;step-1-create-your-aws-builder-id&quot;&gt;Step 1: Create Your AWS Builder ID&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;em&gt;Go to the Sign-In Page:&lt;/em&gt; Head over to the &lt;a rel=&quot;nofollow&quot; href=&quot;https://bit.ly/4vXfC9l&quot;&gt;AWS Builder Center&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;em&gt;Click Sign In / Register:&lt;/em&gt; Select the option to sign in or create an &lt;strong&gt;AWS Builder ID&lt;/strong&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Choose Your Sign-Up Method:&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/1yq2s897evnk3qpm8x4l.jpeg&quot; alt=&quot;Choosing a sign-up method with Google, Apple, GitHub, or Email&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;em&gt;Option A (Fastest):&lt;/em&gt; Choose &lt;strong&gt;Continue with Google&lt;/strong&gt;, &lt;strong&gt;Apple&lt;/strong&gt;, or &lt;strong&gt;GitHub&lt;/strong&gt; to link an existing provider.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;em&gt;Option B (Email):&lt;/em&gt; Enter your personal email address and click &lt;strong&gt;Next&lt;/strong&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;4. Verify Your Email:&lt;/em&gt; Check your inbox for a verification code sent by AWS. Enter the code on the screen and click &lt;strong&gt;Verify&lt;/strong&gt;. (Do this if it gives u an error)&lt;/p&gt;
&lt;h4 id=&quot;step-2-complete-your-profile-setup&quot;&gt;Step 2: Complete Your Profile Setup&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Complete your profile by choosing ur alias(username); it will be used as ur builder ID too, and choose a location.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/n9dd9o6geyg5ckx2mdqd.jpeg&quot; alt=&quot;Setting up profile alias and location in AWS Builder Center&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Select &lt;strong&gt;&quot;Yes, I am a student&quot;&lt;/strong&gt; when prompted to configure your student-specific profile path.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/dprywla32e5aupw9y587.jpeg&quot; alt=&quot;Selecting student status prompt in profile setup&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Fill out your remaining personal details, upload a profile photo, and claim your custom Builder Center alias!&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/8eqs1gx9honi4feppj52.jpeg&quot; alt=&quot;Completing personal profile details and uploading a photo&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;step-3-optional-but-highly-recommended-verify-your-student-status-amp-unlock-pro-features-&quot;&gt;Step 3 (Optional but highly recommended): Verify Your Student Status &amp;amp; Unlock Pro Features 💥&lt;/h4&gt;
&lt;p&gt;Want to get the most out of your cloud journey? You can verify your student status using &lt;strong&gt;SheerID&lt;/strong&gt; to gain access to Pro features and rewards for a full year!&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;How to do it:&lt;/strong&gt; Navigate to the student verification section within your Builder Center profile or click on the one on your home page.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;What you'll provide:&lt;/strong&gt; Basic information like your school name and school-issued email or documentation.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/45v6urilkhgs7fg54d4c.jpeg&quot; alt=&quot;SheerID student verification form screen&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/o02tdtfm2rkrzqwi3xw6.jpeg&quot; alt=&quot;Verifying student status for AWS student perks&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The Reward:&lt;/strong&gt; Unlocks massive perks like an annual Skill Builder subscription, giving you hands-on labs, 900+ digital courses, and certification preparation tools completely free!&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;breaking-down-the-aws-builder-center-sidebar&quot;&gt;&lt;mark class=&quot;bg-yellow-200 dark:bg-yellow-500/30&quot;&gt;Breaking Down the AWS Builder Center Sidebar&lt;/mark&gt;&lt;/h3&gt;
&lt;h4 id=&quot;1-learn&quot;&gt;1. Learn&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Topics:&lt;/strong&gt; Explore specialized technical domains and modern cloud architectures (such as Generative AI, DevOps, Security, and Serverless).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Students:&lt;/strong&gt; Dedicated pathways, starter content, and foundational resources structured specifically for academic learners.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;2-build&quot;&gt;2. Build&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Capabilities:&lt;/strong&gt; Guides, documentation, and architecture patterns detailing how to implement specific AWS services.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Toolbox:&lt;/strong&gt; Developer tools, Software Development Kits (SDKs), Command Line Interface (CLI) integrations, and deployment kits.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Workshops:&lt;/strong&gt; Hands-on, step-by-step tutorials designed to help you build real, production-style projects.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;3-connect&quot;&gt;3. Connect&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Events:&lt;/strong&gt; Information on virtual and local webinars, hackathons, and global AWS community meetups.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Spaces:&lt;/strong&gt; Topic-based discussion forums (like the Student Builders or AI &amp;amp; ML Scholar spaces) where you can share code, ask questions, and collaborate.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;4-community&quot;&gt;4. Community&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Heroes:&lt;/strong&gt; Recognized global experts who share deep AWS knowledge and open-source projects.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Community Builders:&lt;/strong&gt; Technical enthusiasts who publish content, write guides, and mentor peers.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;User Groups:&lt;/strong&gt; Regional, self-starter cloud user networks meeting worldwide.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Student Builder Groups:&lt;/strong&gt; Campus-based student clubs designed to connect peers, host events, and build practical cloud projects together.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;5-wishlist-amp-explore-aws&quot;&gt;5. Wishlist &amp;amp; Explore AWS&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Wishlist:&lt;/strong&gt; A feedback platform where builders can submit feature requests, suggest tool upgrades, and vote on ideas to directly influence the AWS product roadmap.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Explore AWS / Skill Builder:&lt;/strong&gt; Quick links to support articles, open-source projects, startup resources, and the AWS Skill Builder training ecosystem.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;-want-the-full-complete-guide-&quot;&gt;💡 Want the Full complete guide?&lt;/h4&gt;
&lt;blockquote&gt;&lt;p&gt;Read the complete, interactive guide complete with all screenshots, quick tips, and extra community resources directly on my official Hashnode blog: &lt;br&gt;
👉 &lt;a rel=&quot;nofollow&quot; href=&quot;https://shakiran.hashnode.dev/setting-up-your-aws-builder-id-connecting-to-skill-builder&quot;&gt;&lt;strong&gt;Read the Full Article on Hashnode&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25166/setting-up-your-aws-builder-id-connecting-to-skill-builder</guid>
<pubDate>Sun, 23 Aug 2026 09:10:40 +0000</pubDate>
</item>
<item>
<title>AWS Is Betting $500M That Students Will Learn Cloud By Building. The Payoff Is Still Unproven.</title>
<link>https://coderlegion.com/25032/aws-is-betting-500m-that-students-will-learn-cloud-by-building-the-payoff-still-unproven</link>
<description>&lt;p&gt;Every few years, a major cloud provider makes a play for the next generation of developers. AWS tried it before with AWS Educate, a dedicated academic program that eventually shut down. This week, the company is trying again, with more money and a different theory of what went wrong the first time.&lt;/p&gt;
&lt;p&gt;AWS announced a commitment of more than $500 million in resources aimed at university students worldwide, delivered through a new Student Rewards program inside AWS Builder Center. Verified students can earn up to $579 in value: a year of premium AWS Skill Builder access (a $449 value covering 900-plus courses and hands-on labs), $30 in AWS Credits, and a foundational certification exam voucher worth $100. I asked Rick Suttles, Senior Manager, Product &amp;amp; Engineering for Builder Experience at AWS, to walk through what's actually new here, and where the open questions still sit.&lt;/p&gt;
&lt;h3 id=&quot;what-aws-says-it-learned-from-educate&quot;&gt;What AWS says it learned from Educate&lt;/h3&gt;
&lt;p&gt;The most telling answer in the whole exchange is the one about the program AWS already tried and abandoned. &quot;Educate was a separate, siloed experience,&quot; Suttles said. Student Rewards, by contrast, lives inside AWS Builder Center, the same community working developers already use. Students build alongside professionals, using the same tools, in the same space, rather than in a walled-off academic sandbox. AWS also cut the application process students previously had to clear. Now it's straightforward: verify enrollment, start building.&lt;/p&gt;
&lt;p&gt;Whether that structural change actually produces better outcomes than Educate did is impossible to know yet. But it's a specific, testable claim, not a case of AWS simply doing the same thing again with a bigger number attached.&lt;/p&gt;
&lt;h3 id=&quot;the-credential-doesn-t-expire-but-aws-won-t-promise-a-job&quot;&gt;The credential doesn't expire, but AWS won't promise a job&lt;/h3&gt;
&lt;p&gt;Suttles was clear that whatever a student earns during the 12 months, certifications and completed projects, stays theirs after the program access ends. That's a real, concrete commitment. What AWS wouldn't commit to is what happens after: renewal paths beyond the first year are still being worked out, and Suttles said the company will share more &quot;as we learn how students are using the resources.&quot;&lt;/p&gt;
&lt;p&gt;The bigger gap is on outcomes. Asked directly whether AWS has data on how many students who complete the certification path actually land cloud or AI roles, Suttles didn't have numbers to share, since this cohort is just getting underway. Asked what number AWS would point to a year from now as proof the program worked, he named &quot;students building things&quot; as the leading indicator, with certifications as a secondary signal, on the theory that career outcomes follow from real project work rather than credentials alone. It's a reasonable bet, but it's a bet. There's no employer commitment on record treating this specific credential as a guaranteed hiring signal beyond the general observation that AWS certifications are already recognized in the industry.&lt;/p&gt;
&lt;p&gt;Worth being straightforward about that gap: this is a brand-new program, and AWS not having placement data yet is expected, not damning. But it does mean the actual return on time invested is still an open question for any student deciding whether to commit to it.&lt;/p&gt;
&lt;h3 id=&quot;global-access-with-the-exclusions-spelled-out-plainly&quot;&gt;Global access, with the exclusions spelled out plainly&lt;/h3&gt;
&lt;p&gt;On equity, the answers were more concrete. Eligibility isn't tiered by institution type. Community colleges and technical schools get the same access as research universities, and no prior AWS relationship is required. AWS also pointed to more than 1,000 existing Student Builder Groups spanning over 60 countries, many at schools outside the research-university tier, with the option for students to start a new group if their campus doesn't have one.&lt;/p&gt;
&lt;p&gt;Internationally, Suttles said the reward value is identical everywhere verification is available, which currently excludes only sanctioned regions, with China, Hong Kong, and Taiwan verification coming soon. That's a cleaner answer than most global tech benefit programs manage to give, where regional pricing or partial access is often buried in fine print.&lt;/p&gt;
&lt;h3 id=&quot;the-single-vendor-question-answered-directly&quot;&gt;The single-vendor question, answered directly&lt;/h3&gt;
&lt;p&gt;The most obvious criticism of any vendor-run student program is that it trains people to use one company's tools rather than teaching transferable skills. Suttles didn't dodge it: his argument is that cloud fundamentals, distributed architecture, infrastructure as code, AI services, transfer across environments regardless of which vendor's console a student learned them in first. That's a defensible position, though it's also exactly what every cloud vendor running a program like this would say. Worth judging for yourself once you see what the coursework actually covers.&lt;/p&gt;
&lt;p&gt;On that front, the AI-specific material isn't just cloud fundamentals with a new label. Skill Builder includes dedicated courses on generative AI, prompt engineering, and model training, alongside services like Amazon Bedrock and Amazon SageMaker, on top of the standard networking, security, compute, and storage curriculum.&lt;/p&gt;
&lt;h3 id=&quot;a-wrinkle-worth-noting-the-content-requirement&quot;&gt;A wrinkle worth noting: the content requirement&lt;/h3&gt;
&lt;p&gt;Part of how students unlock the full reward value is by publishing technical content, project write-ups and tutorials, on Builder Center and engaging with the broader community. Suttles framed this as reinforcing a student's own learning while building a public portfolio and a network of fellow builders. It's also, functionally, a way for AWS to generate a steady stream of community-authored technical content at essentially no cost, which isn't a criticism so much as worth naming plainly for what it is.&lt;/p&gt;
&lt;h3 id=&quot;what-this-actually-means-if-you-re-deciding-whether-it-s-worth-a-student-s-time&quot;&gt;What this actually means if you're deciding whether it's worth a student's time&lt;/h3&gt;
&lt;p&gt;The structural changes from Educate are real and specific enough to take seriously: integration into a live professional community, no application gate, global parity, no institutional gatekeeping. The open question is whether &quot;students building things&quot; as a success metric actually translates into the career outcomes AWS is implicitly promising. Right now, that's a bet AWS is asking students to take alongside them, backed by real resources but not yet by real placement data.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25032/aws-is-betting-500m-that-students-will-learn-cloud-by-building-the-payoff-still-unproven</guid>
<pubDate>Fri, 21 Aug 2026 00:04:25 +0000</pubDate>
</item>
<item>
<title>Measuring whether semantic chunking actually helps, on one fixed corpus</title>
<link>https://coderlegion.com/24840/measuring-whether-semantic-chunking-actually-helps-on-one-fixed-corpus</link>
<description>&lt;p&gt;Everyone repeats that semantic chunking beats fixed-size chunking for RAG. I wanted to know if that was true for my corpus, or just a thing people say. So I built the comparison into EvalBench: one corpus, one query set, three chunking strategies, and a single precision number I could read off a leaderboard.&lt;/p&gt;
&lt;p&gt;The result I care about here is not a benchmark score. It is the setup. Getting a fair comparison between chunkers is harder than writing the chunkers, because almost everything that makes retrieval look good or bad has nothing to do with how you split the text.&lt;/p&gt;
&lt;h3 id=&quot;the-trap-comparing-chunkers-by-comparing-pipelines&quot;&gt;The trap: comparing chunkers by comparing pipelines&lt;/h3&gt;
&lt;p&gt;If you swap the chunker and also swap the embedder, or the corpus, or the top-k, you have not measured the chunker. You have measured a different pipeline. Most &quot;semantic wins&quot; posts do exactly this, and you cannot tell how much of the win is the split versus everything else that moved with it.&lt;/p&gt;
&lt;p&gt;So in EvalBench the only thing that varies is the split. A RAG run is identified by an &lt;code&gt;embedder::chunk_strategy&lt;/code&gt; string, parsed apart before anything runs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def parse_pipeline_model(model: str) -&amp;gt; tuple[str, ChunkStrategy]:
    &quot;&quot;&quot;Parse the exact ``embedder::chunk_strategy`` row identifier.&quot;&quot;&quot;
    ...
    if strategy not in _ALLOWED_STRATEGIES:
        raise ValueError(f&quot;unknown chunk strategy {strategy!r}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Same embedder, same 200-document corpus, same 15 queries across finance, legal, medical, physics, and software. The strategy is the one knob. Everything downstream is shared code.&lt;/p&gt;
&lt;h3 id=&quot;the-three-strategies&quot;&gt;The three strategies&lt;/h3&gt;
&lt;p&gt;There are three, not two. The interesting comparison is not fixed versus semantic, it is fixed versus recursive versus semantic, because recursive is what most people actually ship.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;fixed_512&lt;/code&gt; is the dumb one. Whitespace-tokenize the whole document, cut it into 512-token windows with a 64-token overlap, done. It respects nothing about the text.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def _window_token_groups(tokens, window=512, overlap=64):
    groups, start = [], 0
    while start &amp;lt; len(tokens):
        end = min(start + window, len(tokens))
        groups.append(list(tokens[start:end]))
        if end == len(tokens):
            break
        start = end - overlap
    return groups
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;recursive&lt;/code&gt; splits on structure first. Try paragraph breaks, then single newlines, then sentence boundaries, and only fall back to a hard token cut when a piece is still too big. Then it repacks those units into bounded chunks. This is the LangChain-style splitter, and it is a real baseline, not a strawman.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;semantic&lt;/code&gt; is the one people get excited about. Embed every sentence, walk them in order, and start a new chunk when the meaning drifts. Drift is measured as cosine similarity between adjacent sentences dropping below a threshold:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;low_similarity = (
    len(current_sentences) &amp;gt;= 3
    and cosine_similarity(vectors[index - 1], vectors[index]) &amp;lt; 0.65
)
too_large = len(current_tokens) + len(sentence_tokens) &amp;gt; 512
if current_tokens and (too_large or low_similarity):
    flush()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two things in that snippet matter and neither is obvious from the marketing. The &lt;code&gt;&amp;gt;= 3&lt;/code&gt; guard means a chunk needs at least three sentences before a similarity drop is allowed to break it, so one weird sentence early on does not shatter a chunk into fragments. And the &lt;code&gt;too_large&lt;/code&gt; check means semantic still has a hard 512-token ceiling. So it is not really &quot;split on meaning.&quot; It splits on meaning until it hits the same 512-token window fixed_512 uses, then cuts. That ceiling is what keeps the comparison fair. If semantic could emit arbitrarily long chunks, it would win on precision for a boring reason: bigger chunks catch more gold documents.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/zxz69xrontp8cy934w5b.png&quot; alt=&quot;Does semantic chunking actually help? A fair RAG test&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-metric-has-to-be-simple-enough-to-trust&quot;&gt;The metric has to be simple enough to trust&lt;/h3&gt;
&lt;p&gt;I score every strategy on context precision. Take the top 10 ranked chunks, count how many come from a document in the gold set, divide:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;retrieved_chunks = ranked_chunks[:10]
context_precision = (
    sum(chunk.doc_id in gold_doc_ids for chunk in retrieved_chunks)
    / len(retrieved_chunks)
    if retrieved_chunks
    else 0.0
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is deliberately crude. It counts a chunk as relevant if its source document is relevant, not if the chunk text itself answers the query. A stricter metric would grade chunk content, but then I would be measuring my grader as much as my chunker, and I already have a separate faithfulness judge for content quality. For a chunker comparison, &quot;did we pull chunks from the right documents&quot; is the question, and document-level precision answers it without dragging an LLM into the measurement.&lt;/p&gt;
&lt;p&gt;Every metric is clamped to [0, 1] with a hard failure if it lands outside, so a broken chunker that emits duplicate or malformed chunks throws instead of quietly publishing a nonsense score:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def _clamp_metric(value):
    tolerance = 1e-12
    if not math.isfinite(value) or value &amp;lt; -tolerance or value &amp;gt; 1.0 + tolerance:
        raise ValueError(...)
    return float(min(1.0, max(0.0, value)))
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;what-broke-and-what-i-would-do-differently&quot;&gt;What broke, and what I would do differently&lt;/h3&gt;
&lt;p&gt;The gold labels are per document, not per chunk. That is the real limitation here. If a relevant document is 40 chunks long and only 2 of those chunks actually answer the query, my precision metric happily credits all 40 as relevant when they surface. So this setup answers &quot;does this strategy retrieve from the right documents,&quot; and it is blind to whether it retrieves the right passage inside them. For a first pass on a 200-document corpus that is a reasonable trade, but it means a chunker that fragments a relevant document into many pieces can look better than one that pulls a single tight passage, purely because more of its chunks share a gold doc_id. Chunk-level labels would fix this and are the obvious next step.&lt;/p&gt;
&lt;p&gt;The other thing I would change is the sample size before trusting any ranking. 15 queries, 3 per domain, is enough to see a large effect and nowhere near enough to call a small one. EvalBench already publishes Wilson intervals next to every mean for exactly this reason, so the leaderboard shows the uncertainty instead of hiding it. If two strategies land within each other's intervals at n=15, the correct read is &quot;no measured difference,&quot; not &quot;semantic wins by a hair.&quot; I would rather report a wide interval than a confident wrong number.&lt;/p&gt;
&lt;h3 id=&quot;takeaway-2&quot;&gt;Takeaway&lt;/h3&gt;
&lt;p&gt;If you want to know whether semantic chunking helps, hold the embedder, corpus, queries, and token ceiling fixed, and vary only the split. Then read the precision with its confidence interval attached. A comparison where three things moved at once tells you nothing about the one you changed.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24840/measuring-whether-semantic-chunking-actually-helps-on-one-fixed-corpus</guid>
<pubDate>Tue, 18 Aug 2026 16:08:46 +0000</pubDate>
</item>
<item>
<title>Deploying a Microservices E-Commerce Application on AWS Using Shell Scripting</title>
<link>https://coderlegion.com/24086/deploying-a-microservices-e-commerce-application-on-aws-using-shell-scripting</link>
<description>&lt;p&gt;&lt;strong&gt;This article was originally published on Hashnode and is being republished here.&lt;/strong&gt; &lt;/p&gt;
&lt;p&gt;It covers deploying a microservices-based e-commerce application on AWS using Shell Scripting, including server setup, service configuration, and application deployment.&lt;/p&gt;
&lt;p&gt;Over the past few days, I learned Shell Scripting and decided to apply everything I had learned by building a complete project from scratch to gain real hands-on experience.&lt;/p&gt;
&lt;p&gt;My goal wasn't just to deploy the RoboShop e-commerce application on AWS using Shell Scripting. I also wanted to understand what happens behind the scenes—how services start, how they communicate with each other, how to automate repetitive deployment tasks, and most importantly, how to troubleshoot issues when things go wrong.&lt;/p&gt;
&lt;p&gt;Throughout this project, I learned how to debug deployment issues by analyzing HTTP status codes, checking Nginx logs, application logs, and database logs, identifying whether the problem was in the upstream or downstream service, and writing proper deployment logs to make troubleshooting easier.&lt;/p&gt;
&lt;p&gt;More than the automation itself, the debugging experience gave me strong hands-on knowledge of application deployment, troubleshooting, and real-world infrastructure automation.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;What is the RoboShop E-Commerce Project?&lt;/strong&gt;&lt;br&gt;
RoboShop is a microservices-based e-commerce application consisting of around 10 services, including Frontend, Catalogue, User, Cart, Shipping, Payment, MongoDB, MySQL, Redis, RabbitMQ, and Dispatch. Each service has its own responsibility and communicates with other services to complete the order flow.&lt;/p&gt;
&lt;p&gt;This project is a great way to understand how multiple services are deployed, connected, and managed in a real-world environment.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Application Components&lt;/strong&gt;&lt;br&gt;
The RoboShop application is built using multiple technologies, with each service having a specific responsibility.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Service&lt;/strong&gt; and       &lt;strong&gt;Technology&lt;/strong&gt;&lt;br&gt;
Mongodb      (NoSQL Database)&lt;br&gt;
Catalogue      (Node.js)&lt;br&gt;
Redis              (Caching Server)&lt;br&gt;
User              (Node.js)&lt;br&gt;
Cart              (Node.js)&lt;br&gt;
Mysql              (SQL Database)&lt;br&gt;
Shipping      (Java)&lt;br&gt;
Rabbitmq      (Message Broker)&lt;br&gt;
Payment      (Python)&lt;br&gt;
Dispatch      (Go (Golang))&lt;br&gt;
Frontend      (Nginx (Reverse Proxy))&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;How Requests Flow&lt;/strong&gt;&lt;br&gt;
The Frontend uses Nginx as a reverse proxy. Instead of clients communicating directly with backend services, every request first reaches Nginx. Based on the requested URL, Nginx forwards the request to the appropriate backend service.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;location /api/catalogue/ {
    proxy_pass http://catalogue:8080/;
}

location /api/user/ {
    proxy_pass http://user:8080/;
}

location /api/cart/ {
    proxy_pass http://cart:8080/;
}

location /api/shipping/ {
    proxy_pass http://shipping:8080/;
}

location /api/payment/ {
    proxy_pass http://payment:8080/;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Using a reverse proxy provides several benefits:&lt;/p&gt;
&lt;p&gt;Clients communicate only with Nginx, not directly with backend services.&lt;/p&gt;
&lt;p&gt;The actual backend server details remain hidden.&lt;/p&gt;
&lt;p&gt;Nginx routes requests to the correct service based on the URL.&lt;/p&gt;
&lt;p&gt;It also makes it easier to manage load balancing, SSL termination, and routing from a single entry point.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Deployment Approach&lt;/strong&gt;&lt;br&gt;
Although the services are built using different technologies such as Node.js, Java, and Python, the deployment process is almost the same for every service.&lt;/p&gt;
&lt;p&gt;The deployment flow includes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Installing the required runtime&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Creating the application user and directory&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Downloading and extracting the application&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Installing application dependencies&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Configuring a systemd service&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Loading the database schema (where required)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Starting and enabling the service&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Since this workflow was common across almost every service, I moved all the reusable functions into a single common.sh file and reused it across all deployment scripts. This reduced duplicate code and made the scripts much easier to maintain.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;What I Automated&lt;/strong&gt;&lt;br&gt;
During this project, I automated the following tasks:&lt;/p&gt;
&lt;p&gt;✅ Validated every command using Linux exit codes (0 = Success, non-zero = Failure) to stop execution immediately if a step failed.&lt;/p&gt;
&lt;p&gt;✅ Implemented idempotency, allowing the scripts to be executed multiple times safely.&lt;/p&gt;
&lt;p&gt;✅ Moved all reusable deployment logic into a common common.sh file.&lt;/p&gt;
&lt;p&gt;✅ Automated application deployment for all services, including runtime installation, dependency installation, and systemd configuration.&lt;/p&gt;
&lt;p&gt;✅ Automated EC2 instance creation using AWS CLI.&lt;/p&gt;
&lt;p&gt;✅ Automated Route53 DNS record creation using AWS CLI.&lt;/p&gt;
&lt;p&gt;✅ Generated detailed log files for every deployment step to simplify troubleshooting.&lt;/p&gt;
&lt;p&gt;✅ Performed root user validation before executing privileged operations.&lt;/p&gt;
&lt;p&gt;Instead of repeating the same steps for every server, I automated the entire deployment using Shell Scripting. This made deployments consistent, repeatable, and much faster.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Debugging Challenges&lt;/strong&gt;&lt;br&gt;
This was the most interesting part of the project and where I learned the most. Writing the scripts was only one part, Troubleshooting the issues taught me how to debug deployments by following logs and tracing requests step by step instead of guessing.&lt;/p&gt;
&lt;p&gt;After deploying the application, I expected everything to work. However, when I opened the application, the catalogue products were not loading.&lt;/p&gt;
&lt;p&gt;Here's how I approached the issue:&lt;/p&gt;
&lt;p&gt;Checked the browser's HTTP status code and found a 504 Gateway Timeout.&lt;/p&gt;
&lt;p&gt;Looked at the Nginx access.log on the frontend and found that&lt;br&gt;
=&amp;gt; Nginx forwarded the request to the upstream (backend) service.&lt;/p&gt;
&lt;p&gt;=&amp;gt;The upstream service did not return a response within the configured timeout. (waiting for something to get from database)&lt;/p&gt;
&lt;p&gt;Verified that the Catalogue service was running using systemctl status&lt;/p&gt;
&lt;p&gt;Checked the Catalogue service logs using: journalctl -u catalogue -f&lt;/p&gt;
&lt;p&gt;The logs showed that the Catalogue service couldn't connect to MongoDB.&lt;/p&gt;
&lt;p&gt;Logged into the MongoDB server and found that the MongoDB service was stopped.&lt;/p&gt;
&lt;p&gt;Started the MongoDB service, and the Catalogue service immediately began working.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;💡What I learned:&lt;/strong&gt;&lt;br&gt;
This issue taught me not to jump to conclusions or restart services blindly. Instead, I learned to follow the request flow—from the browser, to Nginx, to the application logs, and finally to the database—to identify the actual root cause.&lt;/p&gt;
&lt;p&gt;This debugging process gave me much more confidence in troubleshooting real deployment issues and understanding how different services interact in a microservices environment.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;🚀 What's Next?&lt;/strong&gt;&lt;br&gt;
In the next part of this project, I'll automate the application configuration using Ansible, a configuration management tool. This will help eliminate manual server configuration and make deployments even more consistent and scalable.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;🎉 Final Thoughts&lt;/strong&gt;&lt;br&gt;
This project helped me turn my Shell Scripting knowledge into practical experience. Along the way, I learned a lot by intentionally stopping services, analyzing logs, and troubleshooting the resulting issues.I gained valuable hands-on experience in automation, deployment, and debugging. I'll continue building more projects to strengthen my practical skills and gain even more real-world experience.&lt;/p&gt;
&lt;p&gt;If you're interested in exploring the implementation, feel free to check out the complete project source code from below.&lt;br&gt;
&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/pavankumar0816/shell-roboshop-common&quot;&gt;https://github.com/pavankumar0816/shell-roboshop-common&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;I am including a few screenshots of the deployed application:&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=14644443864324811477&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=13835948379371085014&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=1458508592828395889&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=18310175374989591968&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=10844257763862837480&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=9979187544920098741&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24086/deploying-a-microservices-e-commerce-application-on-aws-using-shell-scripting</guid>
<pubDate>Tue, 04 Aug 2026 10:45:47 +0000</pubDate>
</item>
<item>
<title>What happens when your migration agent dies at file 7 of 12</title>
<link>https://coderlegion.com/23893/what-happens-when-your-migration-agent-dies-at-file-7-of-12</link>
<description>&lt;p&gt;A migration agent spends real money and real minutes per run. It clones a repo, installs its deps, plans a file order, then rewrites files one at a time, running the target's test suite after each one. On a mid-sized repo that is a lot of Bedrock calls and a lot of wall-clock time.&lt;/p&gt;
&lt;p&gt;So the question I had to answer early: what happens when the process dies at file 7 of 12?&lt;/p&gt;
&lt;p&gt;If the answer is &quot;start over,&quot; the agent is a toy. You have already paid for six migrated files and a passing test suite on each, and a spot reclamation or a redeploy throws all of it away. Worse, the API side runs on Lambda, which has a hard 15-minute ceiling. A real migration blows past that. So the long-horizon work cannot live where the request comes in, and the thing doing the work has to survive being killed.&lt;/p&gt;
&lt;p&gt;Here is how RepoModernizer handles it, and the bug that resume introduced that I did not see coming.&lt;/p&gt;
&lt;h3 id=&quot;the-split-api-on-lambda-worker-on-fargate&quot;&gt;The split: API on Lambda, worker on Fargate&lt;/h3&gt;
&lt;p&gt;The API is a FastAPI app behind API Gateway, running on Lambda through Mangum. It takes the request, writes nothing important, and drops a message on SQS. That is it. It never runs the migration.&lt;/p&gt;
&lt;p&gt;An SQS consumer Lambda reads the message and calls &lt;code&gt;ecs.run_task&lt;/code&gt; to launch a one-shot Fargate task. The Fargate task is the worker. It runs the LangGraph pipeline to completion (or until it dies) and then exits. Nothing stays warm. Idle cost is zero because there is nothing idle.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# consumer_handler.py — Lambda that turns an SQS message into a Fargate run
_ecs.run_task(
    cluster=os.environ[&quot;ECS_CLUSTER&quot;],
    taskDefinition=os.environ[&quot;ECS_TASK_DEFINITION&quot;],
    launchType=&quot;FARGATE&quot;,
    overrides={
        &quot;containerOverrides&quot;: [{
            &quot;name&quot;: &quot;worker&quot;,
            &quot;environment&quot;: [
                {&quot;name&quot;: str(k).upper(), &quot;value&quot;: str(v)}
                for k, v in body.items() if v is not None
            ],
        }]
    },
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The whole reason the worker is on Fargate and not Lambda is that 15-minute cap. Lambda is fine for the request. It is not fine for a job that might run half an hour.&lt;/p&gt;
&lt;h3 id=&quot;two-kinds-of-durable-in-two-different-places&quot;&gt;Two kinds of durable, in two different places&lt;/h3&gt;
&lt;p&gt;The worker has two things it cannot afford to lose when it dies: the repo it is editing, and the graph state that says how far it got. These live in two different stores, on purpose.&lt;/p&gt;
&lt;p&gt;The repo lives on &lt;strong&gt;EFS&lt;/strong&gt;, mounted into the Fargate task at &lt;code&gt;/mnt/workspace&lt;/code&gt;. The task clones the target repo there, makes a branch, and every diff it applies lands on that filesystem. When a new task picks up the same job, the files are already sitting there in the state the last run left them.&lt;/p&gt;
&lt;p&gt;The graph state lives in &lt;strong&gt;DynamoDB&lt;/strong&gt;. Every node the LangGraph pipeline finishes gets written as a checkpoint. That is what lets a fresh process know it already ingested, already installed deps, already migrated files 0 through 6, and should resume at file 7.&lt;/p&gt;
&lt;p&gt;Splitting them matters. DynamoDB holds the plan, the cursor, and the per-file status. EFS holds the actual bytes. Neither one alone is enough to resume. You need the state to know where you were and the filesystem to have the work you already did.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/fe2xxn255bx7uuczzuhg.png&quot; alt=&quot;How RepoModernizer survives a killed worker mid-run with a custom DynamoDB checkpointer, EFS workspace, and an idempotent resume fix.&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-checkpointer-is-a-real-langgraph-saver-not-a-side-table&quot;&gt;The checkpointer is a real LangGraph saver, not a side table&lt;/h3&gt;
&lt;p&gt;LangGraph has a checkpointer interface, &lt;code&gt;BaseCheckpointSaver&lt;/code&gt;. The out-of-the-box options are an in-memory saver (useless across processes) and integrations you may or may not want to run. I wrote one backed by DynamoDB directly, because DynamoDB was already in the stack and I wanted single-table access patterns I controlled.&lt;/p&gt;
&lt;p&gt;The core is &lt;code&gt;put&lt;/code&gt; and &lt;code&gt;get_tuple&lt;/code&gt;. &lt;code&gt;put&lt;/code&gt; serializes the checkpoint with LangGraph's own &lt;code&gt;JsonPlusSerializer&lt;/code&gt; and writes it under a per-task partition key:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def put(self, config, checkpoint, metadata, new_versions):
    thread_id = config[&quot;configurable&quot;][&quot;thread_id&quot;]
    checkpoint_id = checkpoint[&quot;id&quot;]
    parent_id = config[&quot;configurable&quot;].get(&quot;checkpoint_id&quot;)
    self._put_blob(
        f&quot;TASK#{thread_id}&quot;, f&quot;CKPT#{checkpoint_id}&quot;, (checkpoint, metadata),
        extra={&quot;parent_checkpoint_id&quot;: parent_id or &quot;&quot;},
    )
    return {&quot;configurable&quot;: {
        &quot;thread_id&quot;: thread_id,
        &quot;checkpoint_ns&quot;: config[&quot;configurable&quot;].get(&quot;checkpoint_ns&quot;, &quot;&quot;),
        &quot;checkpoint_id&quot;: checkpoint_id,
    }}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;get_tuple&lt;/code&gt; does the reverse. Given a task id and no specific checkpoint, it queries the latest one:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;resp = self._table.query(
    KeyConditionExpression=Key(&quot;PK&quot;).eq(f&quot;TASK#{thread_id}&quot;)
        &amp;amp; Key(&quot;SK&quot;).begins_with(&quot;CKPT#&quot;),
    ScanIndexForward=False, Limit=1,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ScanIndexForward=False, Limit=1&lt;/code&gt; gives the most recent checkpoint for that task. On cold start the worker builds the graph with this checkpointer attached, and LangGraph replays from the last saved node. Every checkpoint carries a 14-day TTL so old task state ages out of the table on its own.&lt;/p&gt;
&lt;p&gt;The graph build is where the checkpointer gets wired in:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;graph = build_graph(deps, checkpointer=checkpointer)
config = {&quot;configurable&quot;: {&quot;thread_id&quot;: task_id}}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;thread_id&lt;/code&gt; is the task id. That single value ties a resuming process back to everything the previous process wrote.&lt;/p&gt;
&lt;h3 id=&quot;what-resume-actually-does-and-what-it-does-not&quot;&gt;What &quot;resume&quot; actually does, and what it does not&lt;/h3&gt;
&lt;p&gt;I want to be precise here, because it is easy to oversell this.&lt;/p&gt;
&lt;p&gt;Resume works at the node level, not the byte level inside a file. If the worker dies while migrating file 7, it does not continue from the middle of file 7. On the next run, the loop restarts the &lt;code&gt;migrate_file&lt;/code&gt; node for the current cursor from the top: it re-reads the file off EFS, asks the model for fresh content, applies the diff, and reruns tests. Files 0 through 6 are already marked done in the checkpointed state, so it does not touch them. It picks up at the current cursor and moves forward.&lt;/p&gt;
&lt;p&gt;So &quot;resumes mid-migration&quot; is true in the sense that matters (you do not redo six files of completed, test-passing work), and false in the sense that a single interrupted file gets redone from scratch. That is the honest version.&lt;/p&gt;
&lt;h3 id=&quot;the-bug-resume-done-twice-opens-the-same-pr-six-times&quot;&gt;The bug: resume, done twice, opens the same PR six times&lt;/h3&gt;
&lt;p&gt;The pipeline has a human-in-the-loop gate. When the planner scores a file's risk above a threshold, the &lt;code&gt;migrate_file&lt;/code&gt; node calls LangGraph's &lt;code&gt;interrupt()&lt;/code&gt; with the diff, and the run pauses. A human approves or rejects. Approval comes back in as a separate action that resumes the graph with &lt;code&gt;Command(resume=...)&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Here is the thing I missed. That &lt;code&gt;migrate_file&lt;/code&gt; node re-executes from its top on every resume. Re-reads the model, re-applies, re-runs tests. That is fine when there is genuinely a pending interrupt to satisfy. It is not fine when the resume action fires twice.&lt;/p&gt;
&lt;p&gt;And it fires twice more often than you would think. SQS can deliver a message more than once. A user can click approve again while a slow Fargate cold start has not yet updated the checkpoint, so the UI still shows a pending decision. Either way, a second resume lands for a task whose interrupt was already cleared by the first one.&lt;/p&gt;
&lt;p&gt;Calling &lt;code&gt;invoke()&lt;/code&gt; again does not no-op. It runs the node fresh, produces a new diff, applies it, and drives the run all the way to finalize again, which opens a pull request. I found this live: &lt;strong&gt;one approve click produced six to nine identical commits on the PR.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The first fix was to check whether there is actually a pending interrupt before resuming:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;snapshot = graph.get_state(config)
has_pending_interrupt = any(t.interrupts for t in snapshot.tasks)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That helped. It did not fix it. Two approve calls sent about a second apart still produced one extra commit, not zero. The reason is that &lt;code&gt;get_state&lt;/code&gt; then &lt;code&gt;invoke&lt;/code&gt; is check-then-act. Two callers landing in the same instant both read &lt;code&gt;has_pending_interrupt = True&lt;/code&gt; before either one resumes. Classic race.&lt;/p&gt;
&lt;p&gt;The real fix is atomicity, and DynamoDB is the only thing in this system that offers it. I added a &lt;code&gt;try_claim&lt;/code&gt; that does a conditional put: the write only succeeds if the item does not already exist.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def try_claim(self, task_id: str, key: str) -&amp;gt; bool:
    try:
        self._table.put_item(
            Item={&quot;PK&quot;: f&quot;TASK#{task_id}&quot;, &quot;SK&quot;: f&quot;CLAIM#{key}&quot;,
                  &quot;ttl&quot;: int(time.time()) + _TTL_SECONDS},
            ConditionExpression=&quot;attribute_not_exists(PK)&quot;,
        )
        return True
    except ClientError as exc:
        if exc.response[&quot;Error&quot;][&quot;Code&quot;] == &quot;ConditionalCheckFailedException&quot;:
            return False
        raise
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The key is the checkpoint id being resumed. Only the first caller to claim that specific checkpoint proceeds to &lt;code&gt;invoke&lt;/code&gt;. The second gets &lt;code&gt;False&lt;/code&gt; and returns the existing state without re-running anything.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;claimed = checkpointer.try_claim(task_id, f&quot;resume:{checkpoint_id}&quot;)
if has_pending_interrupt and claimed:
    result = graph.invoke(Command(resume={...}), config=config)
else:
    result = snapshot.values
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There is a second guard on the far side too, at finalize: before opening a PR, check whether this task already has a PR URL recorded, and bail if it does. Belt and suspenders, because the cost of a duplicate PR is annoying and public.&lt;/p&gt;
&lt;h3 id=&quot;the-other-thing-that-broke-git-efs-and-root&quot;&gt;The other thing that broke: git, EFS, and root&lt;/h3&gt;
&lt;p&gt;Two smaller landmines, both worth knowing if you mount EFS into a container that runs git.&lt;/p&gt;
&lt;p&gt;The EFS access point pins every file to uid 1000 no matter who writes it. The container runs as root. Git 2.35.2 and up refuses to operate on a repo it sees as owned by a different user (that is the CVE-2022-24765 dubious-ownership check), so the first git command after the clone blows up. Fix is one line at worker startup:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;subprocess.run([&quot;git&quot;, &quot;config&quot;, &quot;--global&quot;, &quot;--add&quot;, &quot;safe.directory&quot;, &quot;*&quot;], check=True)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The second one showed up only on JS repos. The diff step normalizes the target file to end in a newline before diffing. &lt;code&gt;git apply&lt;/code&gt; matches context against real bytes, and &lt;code&gt;difflib&lt;/code&gt; never emits the &quot;No newline at end of file&quot; marker, so a file without a trailing newline fails to apply cleanly. Python source almost always ends in a newline, so my Python fixtures never caught it. Hand-written JS and JSON often do not, and it broke against a real JS repo.&lt;/p&gt;
&lt;h3 id=&quot;what-i-would-do-differently&quot;&gt;What I would do differently&lt;/h3&gt;
&lt;p&gt;The duplicate-commit bug is the interesting one, and the lesson is not &quot;add a claim.&quot; The lesson is that I built idempotency in the wrong layer first. I reached for a state check (&lt;code&gt;has_pending_interrupt&lt;/code&gt;) when the problem was concurrency, and a state check cannot solve concurrency. Only a conditional write can. If I were doing it again I would put the atomic claim in from the start, at every resume boundary, and treat the state check as a cheap early-out rather than the guarantee.&lt;/p&gt;
&lt;p&gt;The other thing: &lt;code&gt;migrate_file&lt;/code&gt; re-running from the top on resume is convenient but wasteful. It re-pays for a Bedrock call on a file it may have already generated. A cleaner design caches the generated content against the checkpoint so a resume that is really just a duplicate delivery costs nothing. I have not built that yet.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Durability is not one feature. It is durable state, durable storage, and idempotent resume, and the last one is the one that bit me.&lt;/strong&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23893/what-happens-when-your-migration-agent-dies-at-file-7-of-12</guid>
<pubDate>Fri, 31 Jul 2026 16:57:25 +0000</pubDate>
</item>
<item>
<title>Can't Hire a Security Team? A Practical Guide to Reducing Your AWS Exposure</title>
<link>https://coderlegion.com/23858/cant-hire-a-security-team-a-practical-guide-to-reducing-your-aws-exposure</link>
<description>&lt;p&gt;Many SaaS startups or small companies that utilize cloud technologies face a dilemma: whether to invest in cybersecurity and compliance now or delay until the company begins proving itself.&lt;/p&gt;
&lt;p&gt;More often than not, the company may lack a full-time employee or team who specialize in these types of questions due to financial factors. This is why SMBs usually delegate security to the sysadmin and compliance to engineering managers. Unfortunately, despite the fact that security assessments and compliance checks may seem simple and basic, they still require dedicated professionals to implement properly due to the depth of knowledge and experience required.&lt;/p&gt;
&lt;p&gt;According to research, many SMBs assume &lt;a rel=&quot;nofollow&quot; href=&quot;https://blog.mousa-cloud.com/posts/smb-common-mistake/&quot;&gt;&quot;We're Too Small to Be Targeted&quot;&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;As of 2026, one of the top concerns that SMBs face is cybersecurity incidents. A hurdle that SMBs face is that hiring a security team or a consulting company can cost anywhere from a few thousand US dollars up to hundreds of thousands per year, sometimes wiping out a startup's revenue during early development.&lt;/p&gt;
&lt;p&gt;A simple, practical approach to this problem is to adopt a security-by-design approach in your processes and workflows. This may seem a bit annoying at the beginning for your teams, but it will save you a lot of headaches in the long term and reduce the amount of rework required later on when you finally hire a consulting company to address any significant gaps or risks.&lt;/p&gt;
&lt;h3 id=&quot;web-applications-security&quot;&gt;Web Applications Security&lt;/h3&gt;
&lt;p&gt;The web application is one of the top attack surfaces since this is what faces users or external parties via the cloud. There are many frameworks and standards, but Microsoft's SDLC is one of the most well-known processes for securing web applications. If you're a SaaS startup, then it's highly recommended to first secure the front line before beginning to talk about cloud security. Other standards worth checking out are the OWASP Top 10 for web applications.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=5522321585454849108&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;aws-security-checklist&quot;&gt;AWS Security Checklist&lt;/h3&gt;
&lt;p&gt;Now, once you have got the front line secured, the next logical step is to secure the infrastructure in the cloud.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Enforce MFA on privileged users.&lt;/li&gt;
&lt;li&gt;Stick to the principle of least privilege.&lt;/li&gt;
&lt;li&gt;Enable GuardDuty and monitor for anomalies.&lt;/li&gt;
&lt;li&gt;Enable CloudTrail to monitor and audit API operations as well as sensitive operations.&lt;/li&gt;
&lt;li&gt;If you use S3, make sure buckets are private by default, block public access, and enable encryption wherever possible.&lt;/li&gt;
&lt;li&gt;Assign specific roles with the least privileges needed to services or users.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The above is just a simple checklist that covers many of the essentials without talking to a consultant yet.&lt;/p&gt;
&lt;h3 id=&quot;when-to-ask-for-help&quot;&gt;When to Ask for Help&lt;/h3&gt;
&lt;p&gt;It is most effective to get professional advice from cloud consultants during the design phase and before going live. This is the most efficient strategy, as having a proper design will lead to much better outcomes and save your company a lot of money in refactoring.&lt;/p&gt;
&lt;p&gt;It is important to note that getting advice before going live with a new feature or ahead of an audit could save you from a lot of stress and trouble since you proactively remediate issues or problems before clients, auditors, or other parties find them.&lt;/p&gt;
&lt;h3 id=&quot;avoid-big-names&quot;&gt;Avoid Big Names&lt;/h3&gt;
&lt;p&gt;It is very tempting to contact well-known, reputable consulting companies for help as they usually are superior in expertise and are normally well equipped. However, this does come at a significant cost since big names entail overhead.&lt;/p&gt;
&lt;p&gt;You can get similar help at much more reasonable prices from less reputable companies. You could save between 50% and 70% in expenses if you get help from small consultants for relatively similar results.&lt;/p&gt;
&lt;p&gt;Many smaller consulting firms actually have experts who worked with big companies and know how they operate; the only difference is that you're paying for outcomes instead of brand.&lt;/p&gt;
&lt;h3 id=&quot;conclusion-1&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;By taking a hybrid approach, you can avoid a lot of cyber risks while still getting outcomes at a reasonable price. There is no need to sacrifice security when you can get it alongside your product efforts.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23858/cant-hire-a-security-team-a-practical-guide-to-reducing-your-aws-exposure</guid>
<pubDate>Fri, 31 Jul 2026 12:58:29 +0000</pubDate>
</item>
<item>
<title>One domain broke. I refused to rerun the other five.</title>
<link>https://coderlegion.com/23779/one-domain-broke-i-refused-to-rerun-the-other-five</link>
<description>&lt;p&gt;TerraformAgent turns a plain-English infrastructure request into validated Terraform. Six nodes: an orchestrator plans the domains, a researcher pulls AWS docs, a set of domain subagents write code in parallel, an aggregator stitches it together, a reviewer inspects it with multiple LLMs, and an evaluator runs the real &lt;code&gt;terraform fmt&lt;/code&gt; and &lt;code&gt;terraform validate&lt;/code&gt; before anything ships.&lt;/p&gt;
&lt;p&gt;The subagents run in parallel, one per domain. Networking writes &lt;code&gt;network.tf&lt;/code&gt;, compute writes &lt;code&gt;compute.tf&lt;/code&gt;, security writes &lt;code&gt;iam.tf&lt;/code&gt; and &lt;code&gt;kms.tf&lt;/code&gt;, and so on. On a typical request, four or five of them fire at once.&lt;/p&gt;
&lt;p&gt;Then the evaluator finds a problem in exactly one of them. A wildcard IAM action, an invalid reference in &lt;code&gt;network.tf&lt;/code&gt;, a validate failure that names a single file.&lt;/p&gt;
&lt;p&gt;The easy move is to loop the whole pipeline back to the start and regenerate everything. That works. It also throws away four passing domains to fix one broken one, and every domain is a separate LLM call. On a five-domain request, a naive full rerun costs five calls to fix a one-call problem, and it does that on every retry.&lt;/p&gt;
&lt;p&gt;I didn't want to pay for that. So the retry only reruns the domains that actually failed.&lt;/p&gt;
&lt;h3 id=&quot;the-pipeline-briefly&quot;&gt;The pipeline, briefly&lt;/h3&gt;
&lt;p&gt;The graph is linear up to the evaluator, then it branches. After the evaluator runs, one conditional edge decides whether to finish or route back into the domain subagents. When it routes back, the subagents don't rerun everything. They rerun only the domains that got flagged.&lt;/p&gt;
&lt;h3 id=&quot;the-key-decision-attribute-the-failure-to-a-domain&quot;&gt;The key decision: attribute the failure to a domain&lt;/h3&gt;
&lt;p&gt;The whole thing hinges on one question. When the evaluator or reviewer reports a problem, which domain owns it?&lt;/p&gt;
&lt;p&gt;I answer that by string-matching filenames. Each domain owns a known set of files:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;DOMAIN_FILES: Dict[str, List[str]] = {
    &quot;networking&quot;: [&quot;network.tf&quot;],
    &quot;compute&quot;: [&quot;compute.tf&quot;],
    &quot;storage&quot;: [&quot;storage.tf&quot;],
    &quot;database&quot;: [&quot;database.tf&quot;],
    &quot;security&quot;: [&quot;iam.tf&quot;, &quot;kms.tf&quot;],
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When something fails, &lt;code&gt;compute_domains_to_retry&lt;/code&gt; scans the combined text of the review feedback, the &lt;code&gt;fmt&lt;/code&gt; output, and the &lt;code&gt;validate&lt;/code&gt; output for any of those filenames. Every filename it finds maps back to the domain that owns it, and only those domains get rerun.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def compute_domains_to_retry(
    review_feedback: str, eval_results: Dict[str, Any], active_domains: List[str]
) -&amp;gt; List[str]:
    is_blocking = &quot;BLOCKING_ERROR&quot; in review_feedback
    fmt_failed = eval_results.get(&quot;terraform_fmt_pass&quot;) is False
    validate_failed = eval_results.get(&quot;terraform_validate_pass&quot;) is False

    if not (is_blocking or fmt_failed or validate_failed):
        return []

    combined_text = &quot;\n&quot;.join([
        review_feedback,
        eval_results.get(&quot;terraform_fmt_output&quot;, &quot;&quot;),
        eval_results.get(&quot;terraform_validate_output&quot;, &quot;&quot;),
    ])

    known_files = [f for files in DOMAIN_FILES.values() for f in files]
    flagged_files = [f for f in known_files if f in combined_text]
    domains_to_retry = sorted({file_to_domain(f) for f in flagged_files if file_to_domain(f)})

    if not domains_to_retry:
        domains_to_retry = sorted(active_domains)

    return domains_to_retry
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If validate fails with &lt;code&gt;Error in network.tf: invalid reference&lt;/code&gt;, that returns &lt;code&gt;[&quot;networking&quot;]&lt;/code&gt; and nothing else reruns. If the reviewer writes &lt;code&gt;BLOCKING_ERROR: iam.tf has a wildcard action&lt;/code&gt;, that returns &lt;code&gt;[&quot;security&quot;]&lt;/code&gt;. If both files show up, both domains come back.&lt;/p&gt;
&lt;p&gt;There's a deliberate fallback in the last three lines. If something failed but no filename was attributable, it reruns every active domain rather than skip the retry. I would rather pay for a full rerun than silently pass broken code because I couldn't parse a blame target.&lt;/p&gt;
&lt;h3 id=&quot;wiring-it-into-the-graph&quot;&gt;Wiring it into the graph&lt;/h3&gt;
&lt;p&gt;LangGraph does the routing with a conditional edge. The evaluator writes &lt;code&gt;domains_to_retry&lt;/code&gt; into state, and a router function reads it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def should_retry_or_finish(state: dict) -&amp;gt; str:
    if state.get(&quot;domains_to_retry&quot;):
        return &quot;domain_subagents&quot;
    return &quot;end&quot;

workflow.add_conditional_edges(
    &quot;evaluator&quot;,
    should_retry_or_finish,
    {&quot;domain_subagents&quot;: &quot;domain_subagents&quot;, &quot;end&quot;: END},
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/oys42prd1tipsipwi69w.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;When the subagents node runs on a retry, it reads that same list and only spins up workers for the flagged domains. Everything else keeps its previous output untouched:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;active_domains = state.get(&quot;active_domains&quot;, [])
domains_to_retry = state.get(&quot;domains_to_retry&quot;, [])
domains_this_pass = domains_to_retry if domains_to_retry else active_domains

existing_outputs = dict(state.get(&quot;domain_outputs&quot;, {}))

with ThreadPoolExecutor(max_workers=max(len(domains_this_pass), 1)) as pool:
    futures = {
        pool.submit(_run_domain_subagent, domain, state, feedback): domain
        for domain in domains_this_pass
    }
    for future in as_completed(futures):
        domain = futures[future]
        existing_outputs[domain] = future.result()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;existing_outputs&lt;/code&gt; starts as a copy of the previous pass, so the passing domains carry forward and only the flagged ones get overwritten. First pass runs everything, retry pass runs the subset.&lt;/p&gt;
&lt;h3 id=&quot;the-cap-and-why-it-s-there&quot;&gt;The cap, and why it's there&lt;/h3&gt;
&lt;p&gt;A retry loop with no ceiling is a way to burn money on a domain the model can't fix. The evaluator increments a counter and stops routing back after three:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;retry_count = state.get(&quot;retry_count&quot;, 0)
if domains_to_retry and retry_count &amp;lt; 3:
    next_status = AgentStatus.EVALUATING
    retry_count += 1
else:
    next_status = AgentStatus.COMPLETED
    domains_to_retry = []
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three is a guess, not a tuned number. It's enough for the model to recover from a bad reference or a wildcard it can see in the feedback, and low enough that a domain it fundamentally can't fix doesn't spin forever.&lt;/p&gt;
&lt;h3 id=&quot;what-i-d-do-differently&quot;&gt;What I'd do differently&lt;/h3&gt;
&lt;p&gt;The attribution is string matching, and that's the weak point. It only works when the failure names a file that maps to a domain. A cross-file error, a validate message that doesn't mention a filename, or a provider-level error with no file in it all fall through to the &quot;rerun everything&quot; branch. When that happens, selective retry quietly becomes a full rerun and I've paid for the routing logic without getting the savings.&lt;/p&gt;
&lt;p&gt;I have real test coverage on the happy paths. &lt;code&gt;test_validate_failure_with_file_in_output&lt;/code&gt; confirms a &lt;code&gt;network.tf&lt;/code&gt; error returns only &lt;code&gt;[&quot;networking&quot;]&lt;/code&gt;, and &lt;code&gt;test_blocking_with_no_attributable_file_retries_all_active&lt;/code&gt; confirms the fallback reruns everything. What I don't have is a way to attribute a failure that doesn't name a file. The honest next step is to make the subagents emit structured findings tagged with their domain, so attribution reads a field instead of grepping a string. That would shrink the fallback-to-everything case, which is the only case where this design earns nothing.&lt;/p&gt;
&lt;h3 id=&quot;takeaway-3&quot;&gt;Takeaway&lt;/h3&gt;
&lt;p&gt;Retry the thing that failed, not everything around it, and accept that the whole saving depends on being able to name what failed.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23779/one-domain-broke-i-refused-to-rerun-the-other-five</guid>
<pubDate>Wed, 29 Jul 2026 22:37:12 +0000</pubDate>
</item>
<item>
<title>EKS Auto Mode: What It Actually Changes (and What It Doesn’t)</title>
<link>https://coderlegion.com/23253/eks-auto-mode-what-it-actually-changes-and-what-it-doesnt</link>
<description>
&lt;a href=&quot;https://alexandre-vazquez.com/eks-auto-mode/&quot; title=&quot;EKS Auto Mode: What It Actually Changes (and What It Doesn&amp;rsquo;t)&quot; rel=&quot;nofollow&quot;&gt;&lt;img width=&quot;1536&quot; height=&quot;1024&quot; src=&quot;https://mlk5tkvgfeoa.i.optimole.com/cb:s8WW.68/w:1536/h:1024/q:mauto/f:best/https://alexandre-vazquez.com/wp-content/uploads/2026/07/eks.avif&quot; class=&quot;webfeedsFeaturedVisual wp-post-image&quot; alt=&quot;EKS Auto Mode: What It Actually Changes (and What It Doesn't)&quot; style=&quot;display: block; margin: auto; margin-bottom: 5px;max-width: 100%;&quot; title=&quot;EKS Auto Mode: What It Actually Changes (and What It Doesn't) 10&quot;&gt;&lt;/a&gt;
&lt;hr class=&quot;wp-block-separator has-alpha-channel-opacity&quot;&gt;



&lt;h2 class=&quot;wp-block-heading&quot;&gt;What EKS Auto Mode is&lt;/h2&gt;



&lt;p&gt;EKS Auto Mode, generally available on &lt;a href=&quot;https://aws.amazon.com/blogs/aws/streamline-kubernetes-cluster-management-with-new-amazon-eks-auto-mode/&quot; target=&quot;_blank&quot; rel=&quot;noopener nofollow&quot;&gt;December 1, 2024&lt;/a&gt;, shifts more Kubernetes infrastructure responsibility from you to AWS. You still run an EKS cluster in your AWS account, and your workloads still use the Kubernetes API, but AWS takes over much of the compute, storage, networking, node lifecycle, and core add-on management that platform teams usually wire together themselves.&lt;/p&gt;



&lt;p&gt;For compute, Auto Mode uses &lt;a rel=&quot;nofollow&quot; href=&quot;https://alexandre-vazquez.com/cluster-autoscaler-vs-karpenter/&quot;&gt;Karpenter-style provisioning&lt;/a&gt; under the hood. When pods are unschedulable, Auto Mode provisions nodes that fit the workload&amp;rsquo;s requirements: instance family, size, architecture, capacity type, and availability zone. When capacity is no longer useful, it can consolidate and terminate nodes.&lt;/p&gt;



&lt;p&gt;The important framing is this: Auto Mode is not &amp;ldquo;EKS without nodes.&amp;rdquo; It is EKS where AWS manages the node lifecycle more aggressively. You own the workloads, their scheduling requirements, their disruption behavior, and the operational consequences of those choices. AWS owns more of the infrastructure plumbing.&lt;/p&gt;



&lt;hr class=&quot;wp-block-separator has-alpha-channel-opacity&quot;&gt;



&lt;h2 class=&quot;wp-block-heading&quot;&gt;What it replaces&lt;/h2&gt;



&lt;p&gt;Before Auto Mode, running EKS in production usually meant choosing and operating several layers yourself:&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;Managed node groups:&lt;/strong&gt; You chose instance types, defined scaling ranges, managed AMI updates, handled node draining, and configured Cluster Autoscaler or another scaling mechanism.&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;Self-managed Karpenter:&lt;/strong&gt; More flexible than managed node groups, but you owned the Karpenter controller, IAM, NodePools, EC2NodeClasses, disruption settings, upgrades, and failure modes.&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;Fargate:&lt;/strong&gt; AWS-managed compute per pod, with no node management, but no DaemonSets, a narrower workload compatibility envelope, and a different cost model.&lt;/p&gt;



&lt;p&gt;EKS Auto Mode replaces a large part of that platform assembly with a managed model: declare workload intent and high-level compute constraints; AWS provisions and manages the EC2 instances behind it.&lt;/p&gt;



&lt;hr class=&quot;wp-block-separator has-alpha-channel-opacity&quot;&gt;



&lt;h2 class=&quot;wp-block-heading&quot;&gt;How it works in practice&lt;/h2&gt;



&lt;p&gt;You create or update an EKS cluster with Auto Mode enabled. The default setup can use AWS-managed built-in node pools. If you need more control, you create a &lt;code&gt;NodeClass&lt;/code&gt; for Auto Mode infrastructure settings and a Karpenter &lt;code&gt;NodePool&lt;/code&gt; for workload-facing scheduling constraints.&lt;/p&gt;



&lt;pre class=&quot;wp-block-code&quot;&gt;&lt;code&gt;# NodeClass: EKS Auto Mode infrastructure settings for managed EC2 nodes.
apiVersion: eks.amazonaws.com/v1
kind: NodeClass
metadata:
  name: private-compute
spec:
  subnetSelectorTerms:
    - tags:
        kubernetes.io/role/internal-elb: &quot;1&quot;
  securityGroupSelectorTerms:
    - tags:
        aws:eks:cluster-name: prod-eks
  ephemeralStorage:
    size: &quot;100Gi&quot;&lt;/code&gt;&lt;/pre&gt;



&lt;pre class=&quot;wp-block-code&quot;&gt;&lt;code&gt;# NodePool: workload-facing constraints for nodes that Auto Mode may provision.
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: general-purpose
spec:
  template:
    spec:
      nodeClassRef:
        group: eks.amazonaws.com
        kind: NodeClass
        name: private-compute
      requirements:
        - key: karpenter.sh/capacity-type
          operator: In
          values: [&quot;on-demand&quot;, &quot;spot&quot;]
        - key: kubernetes.io/arch
          operator: In
          values: [&quot;amd64&quot;, &quot;arm64&quot;]
        - key: eks.amazonaws.com/instance-category
          operator: In
          values: [&quot;c&quot;, &quot;m&quot;, &quot;r&quot;]
  limits:
    cpu: &quot;1000&quot;
    memory: 1000Gi
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 1m&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Those API groups are the Auto Mode-specific split documented by AWS: &lt;code&gt;apiVersion: eks.amazonaws.com/v1&lt;/code&gt;, &lt;code&gt;kind: NodeClass&lt;/code&gt; for Auto Mode node infrastructure, and &lt;code&gt;apiVersion: karpenter.sh/v1&lt;/code&gt;, &lt;code&gt;kind: NodePool&lt;/code&gt; for scheduling and capacity constraints.&lt;/p&gt;



&lt;p&gt;The &lt;code&gt;NodeClass&lt;/code&gt; is where you express AWS infrastructure placement and node-level defaults. The &lt;code&gt;NodePool&lt;/code&gt; is where you express what kind of capacity is acceptable for workloads. Do not copy a self-managed Karpenter &lt;code&gt;EC2NodeClass&lt;/code&gt; into Auto Mode; Auto Mode uses its own &lt;code&gt;NodeClass&lt;/code&gt; API.&lt;/p&gt;



&lt;p&gt;Auto Mode provisions nodes when pods are pending, consolidates when nodes are underutilized, and replaces nodes during maintenance or scale-down. AMI and node lifecycle updates are handled by AWS. AWS says Auto Mode AMIs are generally released weekly with CVE and security fixes, and Auto Mode nodes have a maximum lifetime of 21 days, which you can reduce. Your application still has to tolerate the disruption: a bad &lt;code&gt;PodDisruptionBudget&lt;/code&gt;, strict affinity rule, or singleton stateful workload can still block or degrade a replacement.&lt;/p&gt;



&lt;p&gt;Built-in components are managed differently than in a classic EKS build. AWS lists pod networking, service networking, cluster DNS, autoscaling, block storage, load balancer controller, Pod Identity agent, and node monitoring agent as Auto Mode capabilities. With Auto Mode compute, common add-ons such as Amazon VPC CNI, kube-proxy, CoreDNS, Amazon EBS CSI Driver, and EKS Pod Identity Agent become redundant for Auto Mode nodes, and the relevant controllers can run on AWS-owned infrastructure rather than as visible pods in your account. You can still install AWS Load Balancer Controller in an Auto Mode cluster when you need both models during migration, but AWS does not support directly migrating existing load balancers from AWS Load Balancer Controller to Auto Mode; use &lt;code&gt;IngressClass&lt;/code&gt; or &lt;code&gt;loadBalancerClass&lt;/code&gt; boundaries and plan blue-green migration. Treat this as a change in ownership, not as a reason to skip validation.&lt;/p&gt;



&lt;p&gt;The workload support matrix is broader than Fargate, but not identical to self-managed nodes:&lt;/p&gt;



&lt;figure class=&quot;wp-block-table&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Capability&lt;/th&gt;&lt;th&gt;Auto Mode status&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;EC2 Spot&lt;/td&gt;&lt;td&gt;Supported through &lt;code&gt;karpenter.sh/capacity-type&lt;/code&gt; requirements such as &lt;code&gt;spot&lt;/code&gt;, &lt;code&gt;on-demand&lt;/code&gt;, and &lt;code&gt;reserved&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Graviton / arm64&lt;/td&gt;&lt;td&gt;Supported through &lt;code&gt;kubernetes.io/arch: arm64&lt;/code&gt; and supported Graviton instance families&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;GPU / accelerators&lt;/td&gt;&lt;td&gt;Supported for documented accelerated families; Auto Mode manages NVIDIA, Trainium, and Inferentia drivers/device plugins for supported instance types&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Windows nodes&lt;/td&gt;&lt;td&gt;Not supported&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;DaemonSets&lt;/td&gt;&lt;td&gt;Supported as Kubernetes DaemonSets, but host-level assumptions must be validated against locked-down managed nodes&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/figure&gt;



&lt;hr class=&quot;wp-block-separator has-alpha-channel-opacity&quot;&gt;



&lt;h2 class=&quot;wp-block-heading&quot;&gt;What you gain&lt;/h2&gt;



&lt;p&gt;&lt;strong&gt;Reduced operational surface.&lt;/strong&gt; Node group management, AMI lifecycle, Cluster Autoscaler tuning, Karpenter controller upgrades, and a chunk of add-on wiring move out of your day-to-day scope.&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;Better provisioning shape by default.&lt;/strong&gt; Dynamic provisioning is usually a better fit than fixed node group shapes. You get nodes that more closely match actual pod requirements instead of trying to pre-plan a small set of instance types.&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;Automatic node patching.&lt;/strong&gt; AWS manages the node image and replacement flow. That reduces toil, but it also means your workloads need disruption policies that let AWS replace nodes safely.&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;Faster cluster bootstrapping.&lt;/strong&gt; A new Auto Mode cluster can get to a usable production baseline faster than a hand-assembled EKS cluster with node groups, autoscaling, networking add-ons, storage drivers, and load balancer controllers.&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;Native Spot integration.&lt;/strong&gt; Auto Mode can use Spot capacity through NodePool requirements, but you still need workload-level interruption tolerance: replicas, budgets, graceful shutdown, and queue semantics where relevant.&lt;/p&gt;



&lt;hr class=&quot;wp-block-separator has-alpha-channel-opacity&quot;&gt;



&lt;h2 class=&quot;wp-block-heading&quot;&gt;What you give up&lt;/h2&gt;



&lt;p&gt;&lt;strong&gt;Node-level access.&lt;/strong&gt; Auto Mode nodes are intentionally locked down compared with traditional self-managed nodes. If your incident response process assumes SSH, SSM, manual package inspection, or ad hoc host changes, it needs to change.&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;Custom AMIs.&lt;/strong&gt; You cannot treat the node image as your own artifact. AWS determines the operating system and AMI for Auto Mode managed instances; you cannot directly access the instance or install software on it. If your organization requires internally built, hardened, or certified AMIs, Auto Mode is likely blocked.&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;Unrestricted host agents.&lt;/strong&gt; Kubernetes DaemonSets are supported, but they are the sharp edge. Some node agents work; others do not. Anything that assumes privileged host access, custom kernel modules, hostPath writes, IMDS access without &lt;code&gt;hostNetwork&lt;/code&gt;, or low-level runtime integration needs a proof of compatibility.&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;Less tuning surface.&lt;/strong&gt; You give up direct control over kubelet flags, container runtime configuration, bootstrap scripts, and arbitrary node setup. That is the point of the product, but it is also the boundary.&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;Different cost visibility.&lt;/strong&gt; Managed node groups make capacity easier to reason about because you chose it up front. Auto Mode changes capacity dynamically, so cost control moves toward budgets, labels, reports, and &lt;a rel=&quot;nofollow&quot; href=&quot;https://alexandre-vazquez.com/2026-05-kubernetes-resource-requests-limits/&quot;&gt;workload-level resource hygiene&lt;/a&gt;.&lt;/p&gt;



&lt;hr class=&quot;wp-block-separator has-alpha-channel-opacity&quot;&gt;



&lt;h2 class=&quot;wp-block-heading&quot;&gt;EKS Auto Mode vs managed node groups vs Fargate&lt;/h2&gt;



&lt;figure class=&quot;wp-block-table&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Auto Mode&lt;/th&gt;&lt;th&gt;Managed Node Groups&lt;/th&gt;&lt;th&gt;Fargate&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Node management&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;AWS manages node lifecycle&lt;/td&gt;&lt;td&gt;Shared: AWS manages the group primitive, you manage capacity shape and many updates&lt;/td&gt;&lt;td&gt;AWS-managed per-pod compute&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;AMI updates&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Automatic through AWS-managed node replacement&lt;/td&gt;&lt;td&gt;You schedule and operate rolling updates&lt;/td&gt;&lt;td&gt;N/A&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Instance selection&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Dynamic through NodePools&lt;/td&gt;&lt;td&gt;You choose instance types and scaling ranges&lt;/td&gt;&lt;td&gt;Not exposed&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Custom AMIs&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;No; AWS determines the AMI&lt;/td&gt;&lt;td&gt;Yes&lt;/td&gt;&lt;td&gt;No&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;DaemonSets&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Supported, but validate host-access assumptions&lt;/td&gt;&lt;td&gt;Yes&lt;/td&gt;&lt;td&gt;No&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;SSH / node access&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Restricted&lt;/td&gt;&lt;td&gt;Usually available if you enable it&lt;/td&gt;&lt;td&gt;No&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Spot support&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Yes, through capacity-type requirements&lt;/td&gt;&lt;td&gt;Yes, with node group or Karpenter design&lt;/td&gt;&lt;td&gt;No; Amazon EKS does not support Fargate Spot&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Cost model&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;EKS control plane + EC2 + EKS Auto Mode fee&lt;/td&gt;&lt;td&gt;EKS control plane + EC2&lt;/td&gt;&lt;td&gt;EKS control plane + Fargate pod pricing&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Operational burden&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Low for nodes, medium for workload compatibility&lt;/td&gt;&lt;td&gt;Medium&lt;/td&gt;&lt;td&gt;Low for nodes, medium for compatibility&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Right for&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Default candidate for teams that do not need node customization&lt;/td&gt;&lt;td&gt;Regulated/custom node environments and mature platform teams&lt;/td&gt;&lt;td&gt;Workloads that fit Fargate&amp;rsquo;s restrictions and want per-pod isolation&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Hidden constraints / gotchas&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;AWS controls node image and lifecycle; DaemonSets, privileged pods, hostPath, PDBs, topology rules, and node agents can block migration&lt;/td&gt;&lt;td&gt;You still own AMI drift, autoscaler tuning, disruption handling, and capacity fragmentation&lt;/td&gt;&lt;td&gt;No DaemonSets, limited host-level integrations, different networking/storage constraints, and less flexibility for mixed workload shapes&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/figure&gt;



&lt;hr class=&quot;wp-block-separator has-alpha-channel-opacity&quot;&gt;



&lt;h2 class=&quot;wp-block-heading&quot;&gt;The Auto Mode pricing&lt;/h2&gt;



&lt;p&gt;Do not model Auto Mode as &amp;ldquo;EC2 plus a generic percentage&amp;rdquo; unless you have pulled the actual rate for your region and instance mix. The official structure is:&lt;/p&gt;



&lt;pre class=&quot;wp-block-code&quot;&gt;&lt;code&gt;Total EKS Auto Mode cluster cost =
  EKS control plane cost
  + normal EC2 cost for instances launched and managed by Auto Mode
  + EKS Auto Mode management fee on those managed EC2 instances
  + normal surrounding AWS costs: EBS, load balancers, data transfer, CloudWatch, etc.

EKS Auto Mode management fee =
  sum of Auto Mode-managed instance runtime
  x the regional EKS Auto Mode management rate for each EC2 instance type&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The EKS Auto Mode fee is applied to the EC2 instances that Auto Mode launches and manages. It is billed in addition to the normal EC2 charge and in addition to the EKS control plane charge. AWS bills the Auto Mode fee per second with a one-minute minimum, and the charge is independent of whether the underlying EC2 capacity is On-Demand, Spot, covered by Reserved Instances, or covered by Compute Savings Plans.&lt;/p&gt;



&lt;p&gt;Do not treat the fee as a contractual flat percentage. The official pricing page describes it as a management fee that varies by EC2 instance type, and AWS pricing data is regional. The public pricing example for US West (Oregon) shows &lt;code&gt;c6a.2xlarge&lt;/code&gt; at $0.306/hour for EC2 plus $0.03672/hour for Auto Mode, &lt;code&gt;c6a.4xlarge&lt;/code&gt; at $0.612/hour plus $0.07344/hour, &lt;code&gt;m5a.2xlarge&lt;/code&gt; at $0.344/hour plus $0.04128/hour, and &lt;code&gt;m5a.xlarge&lt;/code&gt; at $0.172/hour plus $0.02064/hour. Those examples equal 12% of the listed On-Demand EC2 rate, but the safe formula for real planning is: &lt;code&gt;sum(instance-hours by instance type and region x published Auto Mode management rate)&lt;/code&gt;.&lt;/p&gt;



&lt;p&gt;The practical cost question is not &amp;ldquo;is there a premium?&amp;rdquo; There is. The useful question is whether the premium is lower than the engineering time, incident risk, and opportunity cost of operating node lifecycle yourself.&lt;/p&gt;



&lt;p&gt;For small teams, the answer may be yes even if the raw bill increases. For high-scale, cost-sensitive platforms, the answer needs real data: compare current EC2 waste, bin-packing efficiency, Spot usage, interruption rate, and platform maintenance time against an Auto Mode pilot.&lt;/p&gt;



&lt;hr class=&quot;wp-block-separator has-alpha-channel-opacity&quot;&gt;



&lt;h2 class=&quot;wp-block-heading&quot;&gt;When to use EKS Auto Mode&lt;/h2&gt;



&lt;p&gt;&lt;strong&gt;Use Auto Mode if:&lt;/strong&gt;&lt;/p&gt;



&lt;ul class=&quot;wp-block-list&quot;&gt;

&lt;li&gt;You run EKS on AWS and do not have a hard requirement to manage nodes yourself&lt;/li&gt;


&lt;li&gt;You do not require custom AMIs or custom node bootstrap logic&lt;/li&gt;


&lt;li&gt;You want to reduce the operations surface for node lifecycle management&lt;/li&gt;


&lt;li&gt;You want Karpenter-like provisioning without operating Karpenter yourself&lt;/li&gt;


&lt;li&gt;Your workloads are mostly stateless or disruption-tolerant&lt;/li&gt;


&lt;li&gt;Your observability, security, and storage agents are compatible with Auto Mode&lt;/li&gt;

&lt;/ul&gt;



&lt;p&gt;&lt;strong&gt;Stick with managed node groups if:&lt;/strong&gt;&lt;/p&gt;



&lt;ul class=&quot;wp-block-list&quot;&gt;

&lt;li&gt;Your organization requires internally certified or hardened AMIs&lt;/li&gt;


&lt;li&gt;You need specific kernel configuration, kubelet flags, bootstrap scripts, or host packages&lt;/li&gt;


&lt;li&gt;You depend on privileged DaemonSets or host-level security tooling that Auto Mode cannot support&lt;/li&gt;


&lt;li&gt;You are in a regulated environment where the node image supply chain must be owned internally&lt;/li&gt;


&lt;li&gt;Your platform team already operates Karpenter well and values the extra control&lt;/li&gt;

&lt;/ul&gt;



&lt;p&gt;&lt;strong&gt;Use Fargate if:&lt;/strong&gt;&lt;/p&gt;



&lt;ul class=&quot;wp-block-list&quot;&gt;

&lt;li&gt;You specifically want per-pod compute isolation&lt;/li&gt;


&lt;li&gt;Your workload does not need DaemonSets or host-level integrations&lt;/li&gt;


&lt;li&gt;You accept Fargate&amp;rsquo;s scheduling, networking, storage, and observability constraints&lt;/li&gt;


&lt;li&gt;You want to avoid managing EC2 capacity entirely for a narrow class of workloads&lt;/li&gt;

&lt;/ul&gt;



&lt;hr class=&quot;wp-block-separator has-alpha-channel-opacity&quot;&gt;



&lt;h2 class=&quot;wp-block-heading&quot;&gt;Migration from managed node groups&lt;/h2&gt;



&lt;p&gt;Migrating an existing cluster to Auto Mode is supported, but it is not a one-command operational migration. AWS supports enabling Auto Mode on existing clusters, but you must update the cluster IAM role permissions and trust policy, enable compute, block storage, and load balancing capabilities together, and meet required add-on versions when those add-ons are installed. AWS also calls out unsupported direct migrations for EBS volumes from the standard EBS CSI provisioner to the Auto Mode EBS CSI provisioner, existing load balancers from AWS Load Balancer Controller to Auto Mode, and clusters using alternative CNIs or other unsupported networking configurations. A conservative path looks like this:&lt;/p&gt;



&lt;ol class=&quot;wp-block-list&quot;&gt;

&lt;li&gt;Enable Auto Mode on a non-production cluster running Kubernetes 1.29 or greater.&lt;/li&gt;


&lt;li&gt;Inventory workloads by scheduling assumptions: node selectors, affinities, tolerations, topology spread, PDBs, privileged mode, hostPath, local storage, and DaemonSet dependencies.&lt;/li&gt;


&lt;li&gt;Create or select the relevant Auto Mode &lt;code&gt;NodeClass&lt;/code&gt; and &lt;code&gt;NodePool&lt;/code&gt; resources.&lt;/li&gt;


&lt;li&gt;Move a low-risk namespace first by changing selectors, tolerations, or labels so pods land on Auto Mode nodes.&lt;/li&gt;


&lt;li&gt;Watch scheduling, replacement, load balancer behavior, persistent volume provisioning, logging, metrics, and security events.&lt;/li&gt;


&lt;li&gt;T&lt;/li&gt;&lt;/ol&gt;
</description>
<category>Other</category>
<guid isPermaLink="true">https://coderlegion.com/23253/eks-auto-mode-what-it-actually-changes-and-what-it-doesnt</guid>
<pubDate>Mon, 27 Jul 2026 10:00:00 +0000</pubDate>
</item>
<item>
<title>WordPress cron behind a load balancer: fixing 'Missed schedule'</title>
<link>https://coderlegion.com/23273/wordpress-cron-behind-a-load-balancer-fixing-missed-schedule</link>
<description>&lt;p&gt;A newsroom schedules its lead story for six in the morning. At 6:05 it is not on the homepage. Nobody did anything wrong: the post was written, scheduled, saved, and its publish time has come and gone. There is no error, no failed job, nothing in the log. The post just sits in the admin list with a small grey label beside it, &lt;strong&gt;Missed schedule&lt;/strong&gt;, as though it had quietly decided not to happen.&lt;/p&gt;
&lt;p&gt;This is the worst kind of bug, the completely silent kind. And on WordPress spread across more than one server, it is barely a bug at all. It is what the default setup does under load, and the fix nearly everyone reaches for makes it worse, in a way that is genuinely hard to see.&lt;/p&gt;
&lt;h3 id=&quot;wp-cron-is-not-cron&quot;&gt;wp-cron is not cron&lt;/h3&gt;
&lt;p&gt;The name lies. &lt;code&gt;wp-cron&lt;/code&gt; is not a system scheduler. On a default install, WordPress checks for due jobs on a normal page load: near the end of a request it fires a non-blocking loopback call to &lt;code&gt;wp-cron.php&lt;/code&gt;, which runs whatever is due. No visitors, nothing runs. A quiet site can sit for hours with nothing firing; a busy one runs its cron on the back of a random reader's page view.&lt;/p&gt;
&lt;p&gt;On one server that is merely untidy. Put it behind a load balancer and it starts to fail in ways you cannot see. The loopback goes to your own domain, which resolves to the load balancer, which hands it to some instance, not necessarily the one that fired it. If an instance cannot reach the public hostname, the loopback silently does nothing. You now have a scheduler whose reliability depends on reader traffic and internal networking, for jobs like publishing the morning's lead story.&lt;/p&gt;
&lt;h3 id=&quot;the-fix-everyone-gives-you-and-the-one-it-hides&quot;&gt;The fix everyone gives you, and the one it hides&lt;/h3&gt;
&lt;p&gt;Search this and every answer is the same two lines: switch the loopback off, and drive cron yourself.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;define( 'DISABLE_WP_CRON', true );
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;* * * * * wget -q -O - https://www.example.com/wp-cron.php?doing_wp_cron &amp;gt;/dev/null 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We ran exactly that. Then, because a crontab lives on one machine and our machines come and go, we moved the trigger to a Lambda that hit the site every minute, so nothing depended on a single box staying alive. It looked correct. The request went out every minute, on schedule, forever.&lt;/p&gt;
&lt;p&gt;And posts kept coming up &lt;strong&gt;Missed schedule&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.mantek.io/insights/wordpress-cron-load-balancer-diagram-1.png&quot; alt=&quot;Two ways to trigger cron, and why the HTTP one keeps missing&quot;&gt;&lt;br&gt;
&lt;em&gt;Both rows want the same thing: the due events run on time. The top row triggers cron with an HTTP request, so it inherits everything sitting in front of the origin, a cache that can answer the same fixed URL without ever running PHP, a load balancer that can hand it to a draining node, and a web request that dies on a stuck lock or a mid-run recycle. The bottom row runs cron in-process with WP-CLI, so none of that is in the path.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The mistake is buried in that one-liner, and it is the same mistake whether a crontab or a Lambda fires it: we were still triggering cron with an HTTP request. Everything wrong with that survives the move to Lambda, because it has nothing to do with who makes the request and everything to do with the request itself.&lt;/p&gt;
&lt;p&gt;Follow the path it takes. It hits your public URL, so it meets your CDN first. Our trigger was one fixed URL, &lt;code&gt;wp-cron.php?doing_wp_cron&lt;/code&gt;, the same string every minute, which is precisely what a cache exists to answer without troubling the origin. So a good share of those tidy every-minute hits were served from cache as an empty 200, and PHP never ran. The dashboards were green. Cron was not running.&lt;/p&gt;
&lt;p&gt;Past the cache it meets the load balancer, which sends it to whichever instance it likes, including one that is draining. If it does reach a live node, &lt;code&gt;wp-cron.php&lt;/code&gt; runs as an ordinary web request, under the web server's timeout, holding a lock other requests respect for sixty seconds. Let one heavy job overrun that lock, or let the instance recycle mid-run, and cron wedges or dies with the queue half done. The &lt;code&gt;publish_future_post&lt;/code&gt; event for the 6am story is somewhere in that unfinished tail.&lt;/p&gt;
&lt;p&gt;None of it surfaces as an error. The trigger fires, something returns 200, and the post sits at Missed schedule until an editor asks why.&lt;/p&gt;
&lt;h3 id=&quot;stop-hitting-a-url&quot;&gt;Stop hitting a URL&lt;/h3&gt;
&lt;p&gt;Here is the turn that fixed it, and it is smaller than it sounds: the reliable way to run cron makes no HTTP request at all.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;wp cron event run --due-now
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;WP-CLI boots WordPress in a normal PHP process on the machine and runs the due events straight against the database. No page, so no CDN to cache it. No loopback, so no load balancer to misroute it. It runs in the CLI, which has no web timeout, so a slow job finishes instead of being cut off. Every fragile link in the HTTP path is simply not in this one.&lt;/p&gt;
&lt;p&gt;That is the whole distance between &quot;keep hitting the server so cron stays alive&quot; and &quot;run cron&quot;. Hitting a page keeps it triggered. It does not keep it finishing, on time, and finishing is the only thing a scheduled post cares about.&lt;/p&gt;
&lt;p&gt;Which leaves one question, the one that turns this from a one-liner into a fleet problem: if cron now runs in-process on a machine, which machine?&lt;/p&gt;
&lt;h3 id=&quot;which-machine-&quot;&gt;Which machine?&lt;/h3&gt;
&lt;p&gt;Cron now runs in-process, which means it runs on whichever machine the crontab sits on. In a fleet, that innocent question is the whole problem, because there are only two obvious answers and both are wrong.&lt;/p&gt;
&lt;p&gt;Pin it to one machine and you are back where you started. That machine is cattle: a deploy replaces it, a scale-in retires it, a health check recycles it, and your one crontab leaves with it. Cron stops, silently, and the 6am story misses again. A single runner is a single point of failure wearing a tidier hat.&lt;/p&gt;
&lt;p&gt;So put it on every machine, bake it into the image, and let every node run cron. Now the opposite breaks. &lt;code&gt;wp cron event run&lt;/code&gt; on each node executes the due events locally, against the shared database, in the same minute. The event that publishes the lead story fires on all of them. Readers get the newsletter three times, the post publishes and re-publishes, the feed importer runs in triplicate. The transient lock core uses for the loopback does not save you here, because each CLI process is its own world and, unless your object cache is shared, they cannot even see each other's lock.&lt;/p&gt;
&lt;h3 id=&quot;run-everywhere-execute-once&quot;&gt;Run everywhere, execute once&lt;/h3&gt;
&lt;p&gt;The answer is to keep cron on every node, so there is no single box to lose, and put one shared lock in front of the run, so only one node does the work each minute.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.mantek.io/insights/wordpress-cron-load-balancer-diagram-2.png&quot; alt=&quot;One runner, elected by a shared lock&quot;&gt;&lt;br&gt;
&lt;em&gt;Every instance runs the trigger, so there is no single box to lose, but each run first tries to take a shared lock (a MySQL advisory lock, or a Redis key), and only the winner executes the due events. The runner records each successful pass, so a total stall is loud instead of silent.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Every node's crontab calls the same command every minute. The first thing it does is ask for a lock. Exactly one node gets it; the rest are turned away in a millisecond and exit having done nothing. The winner runs the due events and releases the lock. Next minute the race is run again, and it does not matter who wins.&lt;/p&gt;
&lt;p&gt;For the lock you already have the one shared thing you need: the database. Every instance talks to the same MySQL, so a MySQL advisory lock is a coordinator with nothing new to deploy:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT GET_LOCK('wp_fleet_cron', 0);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ask for it with a zero-second timeout, run cron only if you got it, release it when you are done. There is a second reason to prefer this over the transient lock WordPress uses: an advisory lock lives on the database connection, so if the runner is killed mid-job, the connection closes and the lock releases itself. The stuck-lock failure that wedges the transient one for sixty seconds cannot happen here. If you run Redis across the fleet, a &lt;code&gt;SET key value NX PX&lt;/code&gt; key does the same job; the point is that the lock is shared, not which store holds it.&lt;/p&gt;
&lt;h3 id=&quot;make-the-silence-loud&quot;&gt;Make the silence loud&lt;/h3&gt;
&lt;p&gt;One thing is still missing, and it is the thing that started all this. Elected and locked, cron runs correctly, right up until the day it does not: a bad deploy, a lock a broken database will not grant, a crontab that quietly stopped being installed. A scheduler that fails silently is the whole reason we are here.&lt;/p&gt;
&lt;p&gt;So the runner writes down every successful pass, the time and the node that did it. That one record turns the unanswerable question, &quot;is cron running?&quot;, into a check anything can make:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;wp fleet-cron status || send-me-an-alert
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It exits non-zero the moment the last run is older than it should be, so an outage pages you before an editor finds it. The missed 6am story stops being a mystery someone stumbles on and becomes an alert you already handled. It is the same rule that runs through &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mantek.io/insights/wordpress-email-ses&quot;&gt;our WordPress mail plugin&lt;/a&gt;: a failure you cannot see is worse than one that shouts.&lt;/p&gt;
&lt;h3 id=&quot;the-plugin&quot;&gt;The plugin&lt;/h3&gt;
&lt;p&gt;It is one file. Drop it into &lt;code&gt;wp-content/mu-plugins/&lt;/code&gt;, add one line to &lt;code&gt;wp-config.php&lt;/code&gt;, and run one command from system cron on every node:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;define( 'DISABLE_WP_CRON', true );
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;* * * * * cd /var/www/html &amp;amp;&amp;amp; wp fleet-cron run --quiet
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then hand the health check to your monitoring, so a stall reaches you rather than an editor:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;wp fleet-cron status || send-me-an-alert
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or install it with Composer:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;composer require mantekio/wp-fleet-cron
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One honest word on where it is in its life. It is &lt;strong&gt;new&lt;/strong&gt;, and it ships as &lt;code&gt;0.9.0&lt;/code&gt;. It has been exercised end to end against a real database and WP-CLI: a single runner elected out of five firing at once, a lock that frees itself when the holder is killed mid-job, the watchdog going loud the moment a run goes stale. What it has not yet had is months in a live multi-node fleet, so it is out in the open under the GPL, on GitHub and Packagist, being hardened in daylight rather than behind a curtain. The repository is &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/mantekio/wp-fleet-cron&quot;&gt;github.com/mantekio/wp-fleet-cron&lt;/a&gt;. Same loop as the pieces before it: the article explains the thinking, the repository is the working plugin, the package is the one-line install, and each points at the other two.&lt;/p&gt;
&lt;h3 id=&quot;known-limits&quot;&gt;Known limits&lt;/h3&gt;
&lt;p&gt;Being clear about what a tool does not do is part of the tool.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The lock assumes a single primary database.&lt;/strong&gt; That is the normal case, and it is the one thing every node in a fleet already shares. On a multi-primary topology, or behind a connection pooler that hands one session to different callers, swap in a Redis key (&lt;code&gt;SET key value NX PX&lt;/code&gt;); the plugin leaves a seam for it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It assumes you can run system cron on the nodes.&lt;/strong&gt; If your host forbids that, keep the loopback, but the watchdog still tells you the instant cron stops.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A long job serialises cron behind it.&lt;/strong&gt; The lock is held for the length of a run, so a job that overruns the minute makes the next tick skip rather than stack a second copy on top. That is deliberate, you never want two, but a genuinely heavy job wants its own queue, not a seat in the cron tick.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Some managed hosts already run cron correctly for a fleet.&lt;/strong&gt; Check what yours does before adding anything.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;Publishing a post on time is the most solved problem in WordPress, on one server. Across a fleet, on the morning a scheduled lead has to be live at six, it turns out to be a small distributed-systems problem in a CMS costume, and the fixes are the dull, correct ones: run cron where nothing can cache or misroute it, let exactly one node act, and make the silence loud the instant it falls. None of it is clever. All of it is the distance between a scheduler you hope is running and one you know is.&lt;/p&gt;
&lt;p&gt;That is the shape of running &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mantek.io/wordpress-aws&quot;&gt;WordPress on AWS&lt;/a&gt; for a newsroom: the real problems hide inside the boring infrastructure, and the work is making the boring parts unbreakable. If a scheduled post has ever quietly failed to appear, and you would rather it never did again, &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mantek.io/contact&quot;&gt;let's talk&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23273/wordpress-cron-behind-a-load-balancer-fixing-missed-schedule</guid>
<pubDate>Wed, 22 Jul 2026 17:43:41 +0000</pubDate>
</item>
<item>
<title>The variables my subagents referenced and nobody declared</title>
<link>https://coderlegion.com/23262/the-variables-my-subagents-referenced-and-nobody-declared</link>
<description>&lt;p&gt;&lt;code&gt;terraform validate&lt;/code&gt; failed on nearly every multi-file run with the same error class:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;Error: Reference to undeclared input variable

  on iam.tf line 14, in resource &quot;aws_iam_policy&quot; &quot;app_access&quot;:
  14:     Environment = var.environment

An input variable with the name &quot;environment&quot; has not been declared.
This variable can be declared with a variable &quot;environment&quot; {} block.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Same error on &lt;code&gt;var.project_name&lt;/code&gt;. Same error on &lt;code&gt;var.region&lt;/code&gt;. Same error on &lt;code&gt;var.account_id&lt;/code&gt;. Four names, referenced across storage.tf and iam.tf and database.tf, declared nowhere.&lt;/p&gt;
&lt;p&gt;I had not written any of those references. An LLM had, because I told it to.&lt;/p&gt;
&lt;h3 id=&quot;the-pipeline-briefly-1&quot;&gt;The pipeline, briefly&lt;/h3&gt;
&lt;p&gt;TerraformAgent is a 6-node LangGraph pipeline that turns a natural-language infrastructure request into validated Terraform: orchestrator → researcher → domain_subagents → aggregator → reviewer → evaluator, with a conditional edge from evaluator back to domain_subagents for targeted retries. The orchestrator picks domains deterministically, keyword-matching the task and mapping services through a static table, with &lt;code&gt;security&lt;/code&gt; always included. Each domain subagent generates its own files in parallel, the aggregator merges them into one directory, and the evaluator runs real &lt;code&gt;terraform fmt&lt;/code&gt; and &lt;code&gt;terraform validate&lt;/code&gt; against a provider cache baked into the container image.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/k4icz06jiw9mrni6v87f.png&quot; alt=&quot;The 6-node pipeline. The dashed edge is the evaluator's targeted retry back into domain_subagents.&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-line-that-did-it&quot;&gt;The line that did it&lt;/h3&gt;
&lt;p&gt;Every domain subagent prompt carried this requirements block:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;    Requirements:
    - Use proper HCL2 syntax
    - Add descriptive comments for each resource
    - Follow AWS best practices (tags, encryption, least privilege)
    - No hardcoded secrets — use variables or data sources
    - Reference other domains' resources by the conventional names given in the Research Context
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Line three is the problem. &quot;Follow AWS best practices (tags, encryption, least privilege).&quot;&lt;/p&gt;
&lt;p&gt;The subagents complied. Tagging a resource properly means an &lt;code&gt;Environment&lt;/code&gt; tag and a &lt;code&gt;Project&lt;/code&gt; tag, and any competent Terraform author reaches for &lt;code&gt;var.environment&lt;/code&gt; and &lt;code&gt;var.project_name&lt;/code&gt; to fill them. IAM policies need an account ID and a region in the ARN, so &lt;code&gt;var.account_id&lt;/code&gt; and &lt;code&gt;var.region&lt;/code&gt; showed up too. The model produced exactly the code a human would have written.&lt;/p&gt;
&lt;p&gt;A human would also have written the &lt;code&gt;variable&lt;/code&gt; blocks. The model could not, because of where it sits in the pipeline.&lt;/p&gt;
&lt;h3 id=&quot;why-the-architecture-hid-it&quot;&gt;Why the architecture hid it&lt;/h3&gt;
&lt;p&gt;A single-file generator never hits this. It declares the variable it uses, in the file it is writing, in the same pass.&lt;/p&gt;
&lt;p&gt;Generation here is split across parallel subagents that each own one domain's files:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;DOMAIN_FILES = {
    &quot;storage&quot;: [&quot;storage.tf&quot;],
    &quot;database&quot;: [&quot;database.tf&quot;],
    &quot;security&quot;: [&quot;iam.tf&quot;],
    ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The storage subagent owns &lt;code&gt;storage.tf&lt;/code&gt;. The security subagent owns &lt;code&gt;iam.tf&lt;/code&gt;. A &lt;code&gt;variable &quot;environment&quot;&lt;/code&gt; block belongs to neither. It belongs to the merged directory, and no subagent knows the merged directory exists.&lt;/p&gt;
&lt;p&gt;So every subagent's output was individually valid HCL. The aggregated directory was not. Splitting generation across agents created a shared namespace that no agent owned, and the failure lived entirely in that gap.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/rhf65j64mznbb91bbjw4.png&quot; alt=&quot;Shared files come from the orchestrator. Domain subagents only produce their own files.&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;fix-part-one-something-above-the-subagents-declares-it&quot;&gt;Fix, part one: something above the subagents declares it&lt;/h3&gt;
&lt;p&gt;There was already a precedent I had not recognized as a category. &lt;code&gt;versions.tf&lt;/code&gt; had this exact shape: a file no domain owns, generated once by the orchestrator, merged in by the aggregator.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def render_versions_tf() -&amp;gt; str:
    &quot;&quot;&quot;Render the shared versions.tf content from the known provider list.

    Generated once by the Orchestrator and merged in by the Aggregator, so
    no domain subagent needs to emit its own required_providers block.
    &quot;&quot;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Variables belong in the same bucket. So &lt;code&gt;render_variables_tf()&lt;/code&gt;, following that pattern:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def render_variables_tf() -&amp;gt; str:
    &quot;&quot;&quot;Render the shared variables.tf content every domain subagent is told
    to use for tagging (var.environment, var.project_name) plus the
    aws_caller_identity/aws_region data sources for account ID and region.

    Domain subagent prompts are instructed to follow AWS tagging best
    practices, which reliably produces references to these exact names —
    but no domain subagent declares them. Generated once by the
    Orchestrator and merged in by the Aggregator (same pattern as
    versions.tf), so `terraform validate` always finds them declared.
    &quot;&quot;&quot;
    return &quot;&quot;&quot;variable &quot;environment&quot; {
  description = &quot;Deployment environment name (e.g. dev, staging, prod)&quot;
  type        = string
  default     = &quot;dev&quot;
}

variable &quot;project_name&quot; {
  description = &quot;Project name used for resource naming and tagging&quot;
  type        = string
  default     = &quot;terraform-agent&quot;
}

data &quot;aws_caller_identity&quot; &quot;current&quot; {}

data &quot;aws_region&quot; &quot;current&quot; {}
&quot;&quot;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both variables carry defaults. That is not cosmetic. The evaluator runs &lt;code&gt;terraform validate&lt;/code&gt; with no tfvars file, so a variable without a default would trade one failure for another.&lt;/p&gt;
&lt;p&gt;The orchestrator generates it alongside versions.tf:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def orchestrator_node(state: dict) -&amp;gt; dict:
    task = state.get(&quot;task&quot;, &quot;&quot;)
    services = detect_aws_services(task)
    active_domains = sorted({service_to_domain(s) for s in services} | {&quot;security&quot;})
    return {
        &quot;active_domains&quot;: active_domains,
        &quot;shared_versions_file&quot;: render_versions_tf(),
        &quot;shared_variables_file&quot;: render_variables_tf(),
        &quot;status&quot;: AgentStatus.RESEARCHING,
        &quot;current_node&quot;: &quot;researcher&quot;,
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And the aggregator picks it up. Before:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;merged = {
    &quot;versions.tf&quot;: state.get(&quot;shared_versions_file&quot;, &quot;&quot;),
}
for domain_files in state.get(&quot;domain_outputs&quot;, {}).values():
    merged.update(domain_files)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;merged = {
    &quot;versions.tf&quot;: state.get(&quot;shared_versions_file&quot;, &quot;&quot;),
    &quot;variables.tf&quot;: state.get(&quot;shared_variables_file&quot;, &quot;&quot;),
}
for domain_files in state.get(&quot;domain_outputs&quot;, {}).values():
    merged.update(domain_files)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;fix-part-two-two-of-the-four-symbols-stopped-existing&quot;&gt;Fix, part two: two of the four symbols stopped existing&lt;/h3&gt;
&lt;p&gt;My instinct was to declare all four names. That instinct was half wrong.&lt;/p&gt;
&lt;p&gt;Account ID and region are not deployment inputs. They are facts about the credentials and provider config the run already has. Terraform will tell you both without being asked:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;data &quot;aws_caller_identity&quot; &quot;current&quot; {}

data &quot;aws_region&quot; &quot;current&quot; {}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So &lt;code&gt;var.account_id&lt;/code&gt; and &lt;code&gt;var.region&lt;/code&gt; did not get declarations. They stopped being referenced. Two of the four undeclared symbols went away by removing the need for the symbol rather than satisfying it.&lt;/p&gt;
&lt;p&gt;This is the part I keep coming back to. When something references a name that does not exist, declaring the name is the obvious move and often the worse one. Half this bug was solved by shrinking the namespace instead of filling it.&lt;/p&gt;
&lt;h3 id=&quot;fix-part-three-the-prompt-reads-the-declarations-instead-of-describing-them&quot;&gt;Fix, part three: the prompt reads the declarations instead of describing them&lt;/h3&gt;
&lt;p&gt;The first version of this fix hand-wrote the scope into the prompt string:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;    A shared variables.tf is already provided (do not redeclare it). It gives you:
    - var.environment (string, default &quot;dev&quot;)
    - var.project_name (string, default &quot;terraform-agent&quot;)
    - data.aws_caller_identity.current.account_id — use this, NOT var.account_id
    - data.aws_region.current.name — use this, NOT var.region
    Do not reference any other var.* name — anything else will fail terraform validate
    because it will not be declared anywhere.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That worked and it was wrong. Two places now knew the list of shared symbols: &lt;code&gt;render_variables_tf()&lt;/code&gt;, which declares them, and this string, which describes them. Nothing kept the two in agreement. Add a variable to the renderer and forget the prompt, and subagents never use it. Remove one from the renderer and leave it in the prompt, and you are back to undeclared references and the identical validate failure.&lt;/p&gt;
&lt;p&gt;So the prompt stopped describing the scope and started parsing it. &lt;code&gt;parse_shared_scope()&lt;/code&gt; pulls the declared names out of the rendered variables.tf:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;_VARIABLE_BLOCK_RE = re.compile(r'variable\s+&quot;([^&quot;]+)&quot;\s*\{([^}]*)\}', re.DOTALL)
_DATA_BLOCK_RE = re.compile(r'data\s+&quot;([^&quot;]+)&quot;\s+&quot;([^&quot;]+)&quot;\s*\{')
_DEFAULT_ATTR_RE = re.compile(r'default\s*=\s*&quot;?([^&quot;\n]*)&quot;?')


def parse_shared_scope(variables_tf: str) -&amp;gt; List[Dict[str, str]]:
    if not variables_tf:
        return []

    entries: List[Dict[str, str]] = []

    for name, body in _VARIABLE_BLOCK_RE.findall(variables_tf):
        default_match = _DEFAULT_ATTR_RE.search(body)
        default_value = default_match.group(1).strip() if default_match else &quot;&quot;
        entries.append({&quot;kind&quot;: &quot;variable&quot;, &quot;name&quot;: name, &quot;default&quot;: default_value})

    for data_type, data_name in _DATA_BLOCK_RE.findall(variables_tf):
        entries.append({&quot;kind&quot;: &quot;data&quot;, &quot;type&quot;: data_type, &quot;name&quot;: data_name})

    return entries
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And &lt;code&gt;render_domain_scope_note()&lt;/code&gt; builds the prompt text from that parse:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def render_domain_scope_note(variables_tf: str) -&amp;gt; str:
    scope = parse_shared_scope(variables_tf)
    if not scope:
        return &quot;&quot;

    lines = [
        &quot;A shared variables.tf is already provided (do not redeclare it). &quot;
        &quot;It gives you:&quot;
    ]
    for entry in scope:
        if entry[&quot;kind&quot;] == &quot;variable&quot;:
            default_note = f' (default &quot;{entry[&quot;default&quot;]}&quot;)' if entry[&quot;default&quot;] else &quot;&quot;
            lines.append(f'    - var.{entry[&quot;name&quot;]}{default_note}')
        else:
            attr = _DATA_SOURCE_PRIMARY_ATTRIBUTE.get(entry[&quot;type&quot;])
            ref = f'data.{entry[&quot;type&quot;]}.{entry[&quot;name&quot;]}' + (f&quot;.{attr}&quot; if attr else &quot;&quot;)
            lines.append(f&quot;    - {ref}&quot;)

    declared_var_names = &quot;, &quot;.join(
        f'var.{e[&quot;name&quot;]}' for e in scope if e[&quot;kind&quot;] == &quot;variable&quot;
    )
    lines.append(
        f&quot;    Do not reference any var.* name other than {declared_var_names or '(none declared)'} &quot;
        &quot;— anything else will fail terraform validate because it will not be declared anywhere.&quot;
    )
    return &quot;\n&quot;.join(lines)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The subagent prompt is now a single interpolation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def _domain_prompt(domain: str, state: dict, feedback: str) -&amp;gt; str:
    files = &quot;, &quot;.join(DOMAIN_FILES[domain])
    feedback_str = f&quot;\n[RETRY] Previous Reviewer/Evaluator feedback to fix: {feedback}&quot; if feedback else &quot;&quot;
    scope_note = render_domain_scope_note(state.get(&quot;shared_variables_file&quot;, &quot;&quot;))
    return f&quot;&quot;&quot;You are an expert DevOps Coder responsible for the '{domain}' domain of this infrastructure request.
    ...
    {scope_note}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One thing does not survive the parse. Which attribute you actually want off a data source is provider schema knowledge, not something the HCL declaration states, so that stays a lookup:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;_DATA_SOURCE_PRIMARY_ATTRIBUTE: Dict[str, str] = {
    &quot;aws_caller_identity&quot;: &quot;account_id&quot;,
    &quot;aws_region&quot;: &quot;name&quot;,
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A data source with no entry here still gets listed in the scope note, just without the attribute hint. That is the one piece of the contract still maintained by hand, and it degrades quietly rather than producing invalid code.&lt;/p&gt;
&lt;p&gt;Stating the consequence matters too. &quot;Do not reference other variables&quot; gets partial compliance. &quot;It will not be declared anywhere and validate will fail&quot; gets better compliance, because the model can reason about the outcome.&lt;/p&gt;
&lt;h3 id=&quot;the-second-bug-same-evaluator-run&quot;&gt;The second bug, same evaluator run&lt;/h3&gt;
&lt;p&gt;The reviewer flagged scope creep in the same pass. Subagents were emitting IAM roles, custom policies, and KMS keys nobody had asked for. A request for a DynamoDB table came back with a customer-managed KMS key and a key policy attached to it.&lt;/p&gt;
&lt;p&gt;Same source line. &quot;Least privilege&quot; and &quot;encryption&quot; read as instructions to build IAM resources and encryption resources, not as constraints on the resources actually requested. The subagent has no notion of scope beyond what the prompt gives it, and the prompt gave it a mandate.&lt;/p&gt;
&lt;p&gt;The requirements block now carries a boundary:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;    - Build ONLY the resources this request needs. Do not add IAM roles, policies,
      KMS keys, or other supporting resources unless the request requires them or
      the specific resource you're creating cannot function without them (e.g. an
      EC2 instance needing a security group). When AWS provides a sensible default
      (e.g. DynamoDB's AWS-owned encryption key), use the default instead of
      creating a custom resource for it.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One vague instruction, two unrelated failure modes. Undeclared references and unrequested resources have nothing in common except the line that caused them.&lt;/p&gt;
&lt;h3 id=&quot;verification&quot;&gt;Verification&lt;/h3&gt;
&lt;p&gt;I reproduced the error before fixing it. A DynamoDB table plus an IAM policy referencing all four names, written to a real directory, run through real &lt;code&gt;terraform init -backend=false&lt;/code&gt; and &lt;code&gt;terraform validate&lt;/code&gt;. Fails without variables.tf. Passes with it.&lt;/p&gt;
&lt;p&gt;Tests at three levels. Renderer content in &lt;code&gt;test_tools.py&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class TestRenderVariablesTf:
    def test_declares_environment_and_project_name(self):
        content = render_variables_tf()
        assert 'variable &quot;environment&quot;' in content
        assert 'variable &quot;project_name&quot;' in content

    def test_variables_have_defaults(self):
        content = render_variables_tf()
        # Every declared variable must have a default so subagent-generated
        # code that references it validates with no tfvars supplied.
        assert content.count('variable &quot;') == content.count(&quot;default&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The drift regression test, which is the one that made part three worth building. Append a variable to the rendered output and assert the prompt mentions it, with no hand-written change anywhere:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;    def test_scope_note_derived_from_same_parse_as_variables_tf(self):
        &quot;&quot;&quot;If a variable is added to render_variables_tf(), the scope note
        must mention it automatically with no hand-written change needed —
        this is the actual regression test for the prompt/renderer drift bug.&quot;&quot;&quot;
        synthetic_variables_tf = render_variables_tf() + '\nvariable &quot;extra_flag&quot; {\n  default = &quot;x&quot;\n}\n'
        note = render_domain_scope_note(synthetic_variables_tf)
        assert &quot;var.extra_flag&quot; in note
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And the wiring, in &lt;code&gt;test_nodes.py&lt;/code&gt;, asserting the prompt reflects state and does not carry a stale scope block when state is empty:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class TestDomainPrompt:
    def test_scope_note_reflects_state_variables_file(self):
        state = {&quot;task&quot;: &quot;x&quot;, &quot;shared_variables_file&quot;: render_variables_tf()}
        prompt = _domain_prompt(&quot;storage&quot;, state, feedback=&quot;&quot;)
        assert &quot;var.environment&quot; in prompt
        assert &quot;data.aws_caller_identity.current.account_id&quot; in prompt

    def test_missing_variables_file_produces_no_stale_scope_note(self):
        state = {&quot;task&quot;: &quot;x&quot;}
        prompt = _domain_prompt(&quot;storage&quot;, state, feedback=&quot;&quot;)
        assert &quot;var.environment&quot; not in prompt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That second one matters more than it looks. If &lt;code&gt;shared_variables_file&lt;/code&gt; is missing from state, the scope note is empty rather than advertising symbols that were never merged in.&lt;/p&gt;
&lt;h3 id=&quot;the-evaluator-caught-this-not-me&quot;&gt;The evaluator caught this, not me&lt;/h3&gt;
&lt;p&gt;Worth stating plainly. Nobody read the generated code and noticed a missing declaration. The evaluator ran &lt;code&gt;terraform validate&lt;/code&gt; and the run failed with a file, a line number, and a symbol name.&lt;/p&gt;
&lt;p&gt;A generator without a real validation gate ships confidently wrong output. The HCL was well-formed, commented, and idiomatic. It looked correct in review. An LLM judge asked &quot;is this good Terraform?&quot; would have said yes, because it is good Terraform, apart from being invalid. The reason I can describe this failure to the exact line is that something mechanical ran the actual tool.&lt;/p&gt;
&lt;p&gt;When you split code generation across parallel agents, any symbol that crosses an agent boundary has to be produced by something that sits above all of them, and described to them by reading what it produced.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23262/the-variables-my-subagents-referenced-and-nobody-declared</guid>
<pubDate>Wed, 22 Jul 2026 16:19:53 +0000</pubDate>
</item>
<item>
<title>My eval leaderboard published a negative retry count</title>
<link>https://coderlegion.com/23095/my-eval-leaderboard-published-a-negative-retry-count</link>
<description>&lt;p&gt;I was looking at the EvalBench dashboard a few weeks ago and one cell stopped me:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;Retries to valid
0.125
95% CI -0.035 – 0.285
n=40
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A negative number of retries. You cannot retry a request negative-point-oh-three-five times. The mean was fine. The interval was nonsense, and it was nonsense in a way that pointed straight at how I was computing every number on the page.&lt;/p&gt;
&lt;p&gt;EvalBench is a multi-provider LLM eval platform I built. Three suites run against OpenAI and Anthropic models across five domains: structured-output reliability, latency and cost with judge scoring, and RAG retrieval quality. Every suite emits the same &lt;code&gt;MetricRecord&lt;/code&gt; shape, and the runner aggregates those records into leaderboard rows.&lt;/p&gt;
&lt;p&gt;The interesting part was not the arithmetic. It was that a frontend formatting string had been quietly making a statistical decision for months.&lt;/p&gt;
&lt;h3 id=&quot;why-i-stopped-publishing-bare-means&quot;&gt;Why I stopped publishing bare means&lt;/h3&gt;
&lt;p&gt;Here is the structured-output suite, 40 samples per model:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Model &lt;/th&gt;&lt;th&gt;Schema valid &lt;/th&gt;&lt;th&gt;First-attempt valid&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;anthropic/claude-sonnet-4-5 &lt;/td&gt;&lt;td&gt;85% &lt;/td&gt;&lt;td&gt;80%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;openai/gpt-4o &lt;/td&gt;&lt;td&gt;72.5% &lt;/td&gt;&lt;td&gt;72.5%&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;A 12.5 point gap. If I publish just those two numbers, every reader concludes Sonnet is meaningfully better at schema adherence.&lt;/p&gt;
&lt;p&gt;Now the same table with intervals:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Model &lt;/th&gt;&lt;th&gt;Schema valid &lt;/th&gt;&lt;th&gt;95% CI&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;anthropic/claude-sonnet-4-5 &lt;/td&gt;&lt;td&gt;85% &lt;/td&gt;&lt;td&gt;70.9% – 92.9%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;openai/gpt-4o &lt;/td&gt;&lt;td&gt;72.5% &lt;/td&gt;&lt;td&gt;57.2% – 83.9%&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/eyfriuy2vszciqfmz5mq.png&quot; alt=&quot;Schema validity at n=40. The intervals overlap, so the 12.5-point gap is not a detectable difference.&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/soetf9r4yirnt6znnm6t.png&quot; alt=&quot;The same suite as a distribution. A single mean hides all of this.&quot;&gt;&lt;/p&gt;
&lt;p&gt;The intervals overlap across roughly eleven points. At n=40 I cannot tell these models apart on this metric. The honest reading is &quot;no detectable difference,&quot; not &quot;Sonnet wins by 12.5.&quot;&lt;/p&gt;
&lt;p&gt;That is the whole reason the interval math exists. A mean with no sample size and no interval is a claim disguised as a measurement.&lt;/p&gt;
&lt;h3 id=&quot;three-metric-types-three-different-intervals&quot;&gt;Three metric types, three different intervals&lt;/h3&gt;
&lt;p&gt;The mistake I see in eval tooling is applying one interval formula to every column. Proportions, unbounded counts, and tail latencies have different distributions, and a single formula gets at least two of them wrong.&lt;/p&gt;
&lt;h4 id=&quot;proportions-get-wilson-not-normal-approximation&quot;&gt;Proportions get Wilson, not normal approximation&lt;/h4&gt;
&lt;p&gt;Schema validity is a proportion. The textbook normal approximation, &lt;code&gt;p ± 1.96 * sqrt(p(1-p)/n)&lt;/code&gt;, breaks badly near 0 and 1. At p=1.0 it produces a zero-width interval, which claims perfect certainty from 40 samples.&lt;/p&gt;
&lt;p&gt;Wilson does not do that:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def wilson_interval(values: Sequence[float]) -&amp;gt; Estimate:
    &quot;&quot;&quot;Return the 95% Wilson interval for binary or fractional successes.&quot;&quot;&quot;
    n = len(values)
    if n == 0:
        return _empty_estimate()

    z = 1.96
    mean = sum(values) / n
    denominator = 1 + z**2 / n
    center = (mean + z**2 / (2 * n)) / denominator
    half = (
        z
        * math.sqrt((mean * (1 - mean) + z**2 / (4 * n)) / n)
        / denominator
    )
    return Estimate(
        mean=mean,
        n=n,
        ci_low=max(0.0, center - half),
        ci_high=min(1.0, center + half),
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Four perfect scores gives you &lt;code&gt;[0.510, 1.0]&lt;/code&gt;, not &lt;code&gt;[1.0, 1.0]&lt;/code&gt;. Four failures gives &lt;code&gt;[0.0, 0.490]&lt;/code&gt;. The interval stays inside [0,1] and stays wide when n is small.&lt;/p&gt;
&lt;p&gt;I pinned those exact values in a test rather than asserting loose properties:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;@pytest.mark.parametrize(
    (&quot;values&quot;, &quot;mean&quot;, &quot;ci_low&quot;, &quot;ci_high&quot;),
    [
        ([1.0, 1.0, 1.0, 1.0], 1.0, 0.5100999795960008, 1.0),
        ([0.0, 0.0, 0.0, 0.0], 0.0, 0.0, 0.48990002040399916),
        ([1.0, 0.0, 1.0, 0.0], 0.5, 0.15003570882017148, 0.8499642911798285),
        ([1.0, 0.5], 0.75, 0.19786250921045673, 0.9733234672343529),
    ],
)
def test_wilson_interval_matches_hand_computed_values(
    values: list[float], mean: float, ci_low: float, ci_high: float
) -&amp;gt; None:
    estimate = runner_module.wilson_interval(values)
    assert estimate.ci_low == pytest.approx(ci_low)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Hand-computed constants catch algebra typos that a property assertion like &lt;code&gt;ci_low &amp;lt;= mean&lt;/code&gt; will happily let through.&lt;/p&gt;
&lt;h4 id=&quot;p95-latency-gets-an-order-statistic&quot;&gt;p95 latency gets an order statistic&lt;/h4&gt;
&lt;p&gt;p95 latency is the one people get most wrong. A normal interval around a 95th percentile assumes the latency distribution is symmetric, and provider latency is not remotely symmetric. It has a long right tail from retries and queueing.&lt;/p&gt;
&lt;p&gt;So p95 does not get a parametric interval at all. It gets a nearest-rank estimate with bounds pulled from the sorted sample:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def percentile_interval(values: Sequence[float], q: float) -&amp;gt; Estimate:
    &quot;&quot;&quot;Return a nearest-rank percentile with a binomial order-statistic CI.&quot;&quot;&quot;
    n = len(values)
    if n == 0:
        return _empty_estimate()

    ordered = sorted(values)
    estimate_index = min(n - 1, max(0, math.ceil(q * n) - 1))
    estimate = ordered[estimate_index]
    if n == 1:
        return Estimate(mean=estimate, n=n, ci_low=estimate, ci_high=estimate)

    lower_index = min(n - 1, max(0, _binomial_quantile(n, q, 0.025)))
    upper_index = min(n - 1, max(0, _binomial_quantile(n, q, 0.975)))
    return Estimate(
        mean=estimate,
        n=n,
        ci_low=ordered[lower_index],
        ci_high=ordered[upper_index],
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The count of observations below the 95th percentile follows a binomial distribution. Take the 2.5th and 97.5th quantiles of that binomial, use them as indices into the sorted latencies, and the bounds are actual observed latencies. No distribution assumption, and the interval cannot land somewhere the data never went.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;_binomial_quantile&lt;/code&gt; builds the PMF by recurrence from the mode outward rather than calling &lt;code&gt;math.comb&lt;/code&gt;, because binomial coefficients at n in the hundreds overflow into float garbage:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;mode = min(n, max(0, math.floor((n + 1) * probability)))
masses = [0.0] * (n + 1)
masses[mode] = 1.0

failure_odds = (1.0 - probability) / probability
for successes in range(mode, 0, -1):
    masses[successes - 1] = (
        masses[successes] * successes / (n - successes + 1) * failure_odds
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Starting at the mode with mass 1.0 and walking outward keeps every ratio near unity. The masses get normalized by their own sum at the end, so the unnormalized start does not matter.&lt;/p&gt;
&lt;h3 id=&quot;the-bug&quot;&gt;The bug&lt;/h3&gt;
&lt;p&gt;Two of those three formulas were right. The routing between them was not.&lt;/p&gt;
&lt;p&gt;Here is what &lt;code&gt;aggregate_records&lt;/code&gt; used to do:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;proportion_metrics = {
    metadata.get(&quot;key&quot;)
    for metadata in suite.display_metrics
    if metadata.get(&quot;format&quot;) == &quot;percent&quot;
}

interval = (
    wilson_interval(values)
    if metric_key in proportion_metrics
    else normal_mean_interval(values)
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Interval selection keyed off the display format string. And &lt;code&gt;retries_to_valid&lt;/code&gt; was declared like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;{
    &quot;key&quot;: &quot;retries_to_valid&quot;,
    &quot;label&quot;: &quot;Retries to valid&quot;,
    &quot;format&quot;: &quot;number&quot;,
    &quot;higher_is_better&quot;: False,
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Format is &lt;code&gt;&quot;number&quot;&lt;/code&gt;, so it missed &lt;code&gt;proportion_metrics&lt;/code&gt; and fell through to &lt;code&gt;normal_mean_interval&lt;/code&gt;, which has no clamps:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;standard_error = statistics.stdev(values) / math.sqrt(n)
half = 1.96 * standard_error
return Estimate(mean=mean, n=n, ci_low=mean - half, ci_high=mean + half)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Mean 0.125, mostly zeros with a few ones, standard deviation large relative to the mean. &lt;code&gt;mean - half&lt;/code&gt; goes below zero and nothing stops it.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;normal_mean_interval&lt;/code&gt; is correct for what it is. The failure is that I made a statistical decision depend on a presentation field. &lt;code&gt;&quot;percent&quot;&lt;/code&gt; is a formatting instruction for the frontend. I overloaded it into &quot;this quantity is a bounded proportion,&quot; and the two meanings drifted apart the moment I added a metric that was a bounded count rather than a percentage.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/pgm0f7j0ba4hmmp5fftv.png&quot; alt=&quot;The published interval for a quantity that cannot go below zero.&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;retries_to_valid&lt;/code&gt; is bounded below at zero. It just is not a proportion, so neither branch fit it.&lt;/p&gt;
&lt;h3 id=&quot;the-fix&quot;&gt;The fix&lt;/h3&gt;
&lt;p&gt;Metrics now declare their statistical support explicitly, separate from how they render:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;{
    &quot;key&quot;: &quot;retries_to_valid&quot;,
    &quot;label&quot;: &quot;Retries to valid&quot;,
    &quot;format&quot;: &quot;number&quot;,         # presentation
    &quot;support&quot;: &quot;non_negative&quot;,  # statistics
    &quot;higher_is_better&quot;: False,
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Routing reads &lt;code&gt;support&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def _interval_for_support(support: str, values: Sequence[float]) -&amp;gt; Estimate:
    if support == &quot;proportion&quot;:
        return wilson_interval(values)
    if support == &quot;non_negative&quot;:
        return non_negative_mean_interval(values)
    return normal_mean_interval(values)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/r5b532438z2l6rjb9iil.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/cyrx7ssrpj52z1k5p44z.png&quot; alt=&quot;The same cell after routing on declared support.&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;non_negative_mean_interval&lt;/code&gt; clamps the lower bound at zero. I considered a bootstrap instead, which handles skewed low-count data better, and decided against it for now. The reasoning is in the docstring:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;&quot;&quot;&quot;Return a 95% normal interval around the mean, clamped at zero.

Clamping (rather than bootstrapping) keeps this a small, deterministic
change to an existing formula instead of introducing a new resampling
dependency for what is fundamentally the same asymptotic-normal
approximation used elsewhere in this module. It is a known-conservative
fix: for genuinely skewed low-count data the true interval is narrower
than a clamped symmetric one, but the clamped bound is never wrong in
the way an unclamped negative bound is.
&quot;&quot;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A clamped interval is too wide near zero. That is a real cost and I would rather state it than pretend the clamp is free.&lt;/p&gt;
&lt;p&gt;The other half of the fix is refusing to guess. Missing metadata raises instead of defaulting:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;missing = [key for key in suite.metric_keys if key not in declared]
if missing:
    raise ValueError(
        f&quot;suite {suite.name!r} is missing declared support for metrics: &quot;
        f&quot;{sorted(missing)}&quot;
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A silent default is how this happened. A new suite cannot now reach the leaderboard without saying what kind of quantity each of its metrics is.&lt;/p&gt;
&lt;h3 id=&quot;the-test-i-should-have-written-first&quot;&gt;The test I should have written first&lt;/h3&gt;
&lt;p&gt;Every interval test I had pinned hand-computed values for one function. All of them passed while the dashboard published a negative retry count, because not one of them asked whether the right formula reached the right column.&lt;/p&gt;
&lt;p&gt;That test is generic, cheap, and runs across every registered suite:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;@pytest.mark.parametrize(
    &quot;suite&quot;, registry_module.list_suites(), ids=lambda suite: suite.name
)
def test_aggregate_records_intervals_stay_within_declared_support(
    suite: Suite,
) -&amp;gt; None:
    ...
    for row in response.rows:
        for metric_key, estimate in row.metrics.items():
            support = support_by_metric[metric_key]
            if estimate.ci_low is None:
                continue
            if support == &quot;proportion&quot;:
                assert 0.0 &amp;lt;= estimate.ci_low
                assert estimate.ci_high &amp;lt;= 1.0
            elif support == &quot;non_negative&quot;:
                assert estimate.ci_low &amp;gt;= 0.0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It does not check any particular number. It checks that no published bound falls outside the range its quantity can physically occupy. Unit tests on formulas verify the math. This verifies the wiring, and the wiring is what broke.&lt;/p&gt;
&lt;p&gt;There is also a regression test pinned to the exact reported case, 40 records with one outlier requiring 5 retries, mean 0.125, asserting &lt;code&gt;ci_low &amp;gt;= 0&lt;/code&gt;. I confirmed it failed before the fix. A regression test you never watched fail is a test you are trusting for no reason.&lt;/p&gt;
&lt;p&gt;The backend went from 262 tests to 267. Five tests, and one of them covers a class of failure that the other 262 could not see.&lt;/p&gt;
&lt;h3 id=&quot;takeaway-4&quot;&gt;Takeaway&lt;/h3&gt;
&lt;p&gt;Publish the interval and the sample size next to every mean, and test that each interval respects the support of the thing it is measuring, because a leaderboard that cannot check its own bounds will cheerfully show you a negative retry count instead.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23095/my-eval-leaderboard-published-a-negative-retry-count</guid>
<pubDate>Mon, 20 Jul 2026 20:47:10 +0000</pubDate>
</item>
<item>
<title>I Ran terraform validate Inside a Lambda. Two Things Broke.</title>
<link>https://coderlegion.com/22978/i-ran-terraform-validate-inside-a-lambda-two-things-broke</link>
<description>&lt;p&gt;Most agent projects that generate code evaluate it by asking another model whether the code looks correct. I did that for about a week in TerraformAgent before I got tired of it.&lt;/p&gt;
&lt;p&gt;The failure mode is boring and predictable. The judge reads a &lt;code&gt;.tf&lt;/code&gt; file, sees resource blocks with plausible attribute names, and says yes. The file has an undeclared variable, or a reference to a resource that does not exist, or an attribute that HashiCorp removed in provider v5. The judge has no way to know. It is pattern-matching on what Terraform usually looks like, which is exactly what the generator was doing when it produced the bug.&lt;/p&gt;
&lt;p&gt;Terraform ships a checker. It is called &lt;code&gt;terraform validate&lt;/code&gt;. It is deterministic, it takes about a second, and it is never wrong about syntax or references. The whole reason people reach for an LLM judge here is that running the real thing inside a Lambda is annoying. It is annoying. It is also not that hard, and this post is the parts that actually bit me.&lt;/p&gt;
&lt;p&gt;TerraformAgent is a 6-node LangGraph pipeline that turns a natural-language infra request into Terraform. Orchestrator, researcher, parallel domain subagents, aggregator, reviewer, evaluator. The evaluator is the node I care about here.&lt;/p&gt;
&lt;h3 id=&quot;the-evaluator-writes-to-disk-and-shells-out&quot;&gt;The evaluator writes to disk and shells out&lt;/h3&gt;
&lt;p&gt;The generated code lives in state as a filename-to-content dict. To run real Terraform against it, you have to put it on a filesystem. Lambda gives you &lt;code&gt;/tmp&lt;/code&gt;, so that is where it goes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;thread_id = state.get(&quot;thread_id&quot;, &quot;default&quot;)
temp_dir = f&quot;/tmp/tf_eval_{thread_id}&quot;

if os.path.exists(temp_dir):
    shutil.rmtree(temp_dir)
os.makedirs(temp_dir, exist_ok=True)

aggregated_output = state.get(&quot;aggregated_output&quot;, {})
for filename, content in aggregated_output.items():
    filepath = os.path.join(temp_dir, filename)
    with open(filepath, &quot;w&quot;) as f:
        f.write(content)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;thread_id&lt;/code&gt; in the path matters. Lambda execution environments get reused across invocations, so two runs can land in the same container. Without the thread scoping, run B validates a directory containing run A's leftovers.&lt;/p&gt;
&lt;p&gt;Then three subprocess calls, in order:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;fmt_result = subprocess.run(
    [&quot;terraform&quot;, &quot;fmt&quot;, &quot;-check&quot;, &quot;-recursive&quot;],
    cwd=temp_dir, capture_output=True, text=True, timeout=30,
)
eval_results[&quot;terraform_fmt_pass&quot;] = fmt_result.returncode == 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;init&lt;/code&gt; next, then &lt;code&gt;validate&lt;/code&gt; only if &lt;code&gt;init&lt;/code&gt; succeeded. Skipping that guard gives you a validate failure that is really an init failure, and the retry logic then blames the wrong thing.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;if init_success:
    val_result = subprocess.run(
        [&quot;terraform&quot;, &quot;validate&quot;, &quot;-no-color&quot;],
        cwd=temp_dir, capture_output=True, text=True, timeout=60,
    )
    eval_results[&quot;terraform_validate_pass&quot;] = val_result.returncode == 0
else:
    eval_results[&quot;terraform_validate_pass&quot;] = None
    eval_results[&quot;terraform_validate_output&quot;] = &quot;skipped (init failed)&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note the three-state result. &lt;code&gt;True&lt;/code&gt;, &lt;code&gt;False&lt;/code&gt;, and &lt;code&gt;None&lt;/code&gt;. &lt;code&gt;None&lt;/code&gt; means the check did not run. If you collapse that to &lt;code&gt;False&lt;/code&gt; you get a retry loop chasing a validation error that was never evaluated. There is a fourth case in the &lt;code&gt;fmt&lt;/code&gt; block for the same reason: &lt;code&gt;FileNotFoundError&lt;/code&gt; sets the result to &lt;code&gt;None&lt;/code&gt; with the message &lt;code&gt;terraform binary not found (running locally without Docker?)&lt;/code&gt;, because the binary only exists inside the container image and I run these tests on my laptop.&lt;/p&gt;
&lt;h3 id=&quot;terraform-init-wants-the-network-lambda-should-not-&quot;&gt;terraform init wants the network. Lambda should not.&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;terraform init&lt;/code&gt; downloads the AWS provider plugin. It is about 60 MB. Doing that on every invocation is slow, costs egress, and breaks the moment HashiCorp's release endpoint has a bad minute.&lt;/p&gt;
&lt;p&gt;So bake it into the image at build time:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dockerfile&quot;&gt;RUN mkdir -p /tmp/tf-provider-cache /opt/tf-plugins &amp;amp;&amp;amp; \
    cd /tmp/tf-provider-cache &amp;amp;&amp;amp; \
    printf 'terraform {\n  required_providers {\n    aws = {\n      source  = &quot;hashicorp/aws&quot;\n      version = &quot;~&amp;gt; 5.0&quot;\n    }\n  }\n}\n' &amp;gt; main.tf &amp;amp;&amp;amp; \
    TF_PLUGIN_CACHE_DIR=/opt/tf-plugins terraform init -backend=false &amp;amp;&amp;amp; \
    rm -rf /tmp/tf-provider-cache

ENV TF_PLUGIN_CACHE_DIR=/opt/tf-plugins
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/u2ukf2iwsr4vcedaczm0.png&quot; alt=&quot;Flow diagram of TerraformAgent's evaluator node: generated files are written to a thread-scoped /tmp directory, then checked by terraform fmt, init, and validate, with the AWS provider cache copied from read-only /opt into a writable /tmp path. An LLM judge runs in parallel on task coverage. Both feed a retry decision that either re-runs flagged domains or completes.&quot;&gt;&lt;/p&gt;
&lt;p&gt;A throwaway &lt;code&gt;main.tf&lt;/code&gt; that declares nothing but the provider requirement, one &lt;code&gt;init&lt;/code&gt;, and the plugin lands in the cache directory.&lt;/p&gt;
&lt;p&gt;The cache goes in &lt;code&gt;/opt&lt;/code&gt;, not &lt;code&gt;/tmp&lt;/code&gt;. This one cost me real time. Lambda mounts a fresh empty &lt;code&gt;/tmp&lt;/code&gt; on every execution environment at runtime. Anything you write to &lt;code&gt;/tmp&lt;/code&gt; during the Docker build is gone. Not stale, not partially there. Gone. &lt;code&gt;/opt&lt;/code&gt; is part of the read-only image layers and survives to runtime.&lt;/p&gt;
&lt;h3 id=&quot;the-read-only-cache-breaks-on-version-mismatch&quot;&gt;The read-only cache breaks on version mismatch&lt;/h3&gt;
&lt;p&gt;Baking into &lt;code&gt;/opt&lt;/code&gt; fixes the download. It introduces a second problem, and this is the part I did not see coming.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;/opt&lt;/code&gt; is read-only at runtime. Terraform treats &lt;code&gt;TF_PLUGIN_CACHE_DIR&lt;/code&gt; as a directory it can write to. As long as the generated code asks for a provider version matching what got baked in, Terraform reads from the cache and everyone is happy. The moment the generated code pins something else, Terraform tries to download that version &lt;em&gt;into the cache dir&lt;/em&gt;, hits a read-only path, and fails outright. Not a slow path. A hard failure.&lt;/p&gt;
&lt;p&gt;The fix is a copy, once per execution environment:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;baked_cache = os.environ.get(&quot;TF_PLUGIN_CACHE_DIR&quot;, &quot;/opt/tf-plugins&quot;)
writable_cache = &quot;/tmp/tf-plugins&quot;
if not os.path.isdir(writable_cache):
    if os.path.isdir(baked_cache):
        shutil.copytree(baked_cache, writable_cache)
    else:
        os.makedirs(writable_cache, exist_ok=True)

env = os.environ.copy()
env[&quot;TF_PLUGIN_CACHE_DIR&quot;] = writable_cache

init_result = subprocess.run(
    [&quot;terraform&quot;, &quot;init&quot;, &quot;-backend=false&quot;, &quot;-input=false&quot;, &quot;-no-color&quot;],
    cwd=temp_dir, capture_output=True, text=True, timeout=120, env=env,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;if not os.path.isdir(writable_cache)&lt;/code&gt; guard is what makes this cheap. Warm containers skip the copy entirely. Cold ones pay a local filesystem copy, which is nothing next to a 60 MB download. Matching versions stay fully offline. Non-matching versions still work, they just fall back to a normal network install into a directory Terraform is allowed to write to.&lt;/p&gt;
&lt;p&gt;That is the tradeoff I would call out in a review: I could have refused non-matching versions and kept it strictly offline, which would be faster and more predictable. I chose to degrade to a network fetch instead, because an agent that generates &lt;code&gt;version = &quot;~&amp;gt; 4.0&quot;&lt;/code&gt; for a good reason should not hard-fail on infrastructure trivia.&lt;/p&gt;
&lt;h3 id=&quot;the-llm-judge-is-still-there-it-just-answers-a-different-question-&quot;&gt;The LLM judge is still there. It just answers a different question.&lt;/h3&gt;
&lt;p&gt;I want to correct something, because I have seen this framed as replacing the LLM judge, and that is not what happened.&lt;/p&gt;
&lt;p&gt;The evaluator runs both. Mechanical checks answer &quot;is this valid Terraform.&quot; An LLM judge answers &quot;does this do what the user asked&quot;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;judge_prompt = f&quot;&quot;&quot;Task: {state.get('task', '')}

Generated Terraform code:
{json.dumps(aggregated_output)}

Does the generated code satisfy the original task? Evaluate:
1. Are all requested resources present?
2. Are the configurations correct for the stated requirements?
3. Are there any obvious omissions?

Answer strictly with JSON: {{&quot;task_success&quot;: true/false, &quot;reason&quot;: &quot;...&quot;, &quot;missing_resources&quot;: [...]}}&quot;&quot;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nothing deterministic can check whether &quot;give me a VPC with a private subnet for the database&quot; produced the right resources. That is a semantic question and it needs a model. The point is not that LLM judges are useless. The point is that they should not be answering questions a compiler already answers.&lt;/p&gt;
&lt;h3 id=&quot;routing-the-retry&quot;&gt;Routing the retry&lt;/h3&gt;
&lt;p&gt;The evaluator's results feed a function that decides which domains re-run. Only the flagged ones:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;is_blocking = &quot;BLOCKING_ERROR&quot; in review_feedback
fmt_failed = eval_results.get(&quot;terraform_fmt_pass&quot;) is False
validate_failed = eval_results.get(&quot;terraform_validate_pass&quot;) is False

if not (is_blocking or fmt_failed or validate_failed):
    return []
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two things here. &lt;code&gt;is False&lt;/code&gt; and not falsy, so the &lt;code&gt;None&lt;/code&gt; case does not trigger a retry. And when something failed but no specific file was named in the output, every active domain retries rather than silently returning an empty list. A retry that does nothing is worse than an expensive one.&lt;/p&gt;
&lt;p&gt;The graph wires it as a conditional edge back to the subagents, capped at 3:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;workflow.add_conditional_edges(
    &quot;evaluator&quot;,
    should_retry_or_finish,
    {&quot;domain_subagents&quot;: &quot;domain_subagents&quot;, &quot;end&quot;: END},
)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;what-i-would-do-differently-1&quot;&gt;What I would do differently&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;fmt&lt;/code&gt; output is a filename list, which makes attributing a failure to a domain easy. &lt;code&gt;validate&lt;/code&gt; output is prose from Terraform, and I am string-matching known filenames against it. That works until a filename appears in an error message for a reason unrelated to that file being broken. Parsing &lt;code&gt;terraform validate -json&lt;/code&gt; would give me structured diagnostics with real file and line attribution. I have not done it yet.&lt;/p&gt;
&lt;p&gt;The retry cap of 3 is a guess. I have no data on how often a third attempt fixes something a second one did not. That is a metric I should be emitting and am not.&lt;/p&gt;
&lt;p&gt;And the judge sees &lt;code&gt;json.dumps(aggregated_output)&lt;/code&gt; with the entire generated codebase inline. On a large request that is a lot of tokens for one boolean.&lt;/p&gt;
&lt;p&gt;94 tests pass on this. None of them mock &lt;code&gt;terraform&lt;/code&gt;, because the whole point was to stop trusting a stand-in for the real thing.&lt;/p&gt;
&lt;h3 id=&quot;takeaway-5&quot;&gt;Takeaway&lt;/h3&gt;
&lt;p&gt;If a real tool can answer your evaluation question, run the real tool, and save the model for the questions that have no compiler.&lt;/p&gt;
&lt;p&gt;Repo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/akashpersetti/terraform-agent&quot;&gt;github.com/akashpersetti/terraform-agent&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22978/i-ran-terraform-validate-inside-a-lambda-two-things-broke</guid>
<pubDate>Sat, 18 Jul 2026 20:32:28 +0000</pubDate>
</item>
<item>
<title>Your WordPress says the email sent. It didn't.</title>
<link>https://coderlegion.com/22955/your-wordpress-says-the-email-sent-it-didnt</link>
<description>&lt;p&gt;A user tells you they never got the password-reset email. You check: WordPress says it sent. &lt;code&gt;wp_mail()&lt;/code&gt; returned &lt;code&gt;true&lt;/code&gt;. There is nothing in the logs, because there is nothing to log. The mail left the building and vanished.&lt;/p&gt;
&lt;p&gt;This is the worst kind of bug. It is silent, it is invisible, and it happens in the one flow where the user is already locked out and already frustrated. And on a default WordPress install, it is not a bug at all. It is the design.&lt;/p&gt;
&lt;h3 id=&quot;why-wordpress-email-is-broken-by-default&quot;&gt;Why WordPress email is broken by default&lt;/h3&gt;
&lt;p&gt;Out of the box, &lt;code&gt;wp_mail()&lt;/code&gt; hands your message to PHPMailer, which hands it to PHP's &lt;code&gt;mail()&lt;/code&gt;, which hands it to &lt;code&gt;sendmail&lt;/code&gt; on the web server. That path has no sending reputation, no SPF alignment, and no DKIM signature. To a modern mail provider, a message from a random web server, unsigned and unaligned, is indistinguishable from spam, so Gmail and Outlook quietly bin it. Often without even a bounce.&lt;/p&gt;
&lt;p&gt;And &lt;code&gt;wp_mail()&lt;/code&gt; reports none of this. Its entire failure vocabulary is a single &lt;code&gt;false&lt;/code&gt;, and on the default path it usually does not even return that: &lt;code&gt;sendmail&lt;/code&gt; accepts the message, so &lt;code&gt;wp_mail()&lt;/code&gt; returns &lt;code&gt;true&lt;/code&gt;, and the mail dies somewhere downstream where WordPress will never hear about it.&lt;/p&gt;
&lt;p&gt;So the real problem is two problems. The mail is unauthenticated, and the failure is silent. Any fix has to solve both, or it has not fixed anything.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.mantek.io/insights/wordpress-email-ses-diagram-1.png&quot; alt=&quot;Two paths for the same message: the default drops it, the plugin signs it&quot;&gt;&lt;br&gt;
&lt;em&gt;WordPress builds the same message either way. On the default path it goes to PHP's mail() and out unsigned, where wp_mail() still returns true. The plugin swaps only the transport: a PHPMailer subclass hands the finished message to the SES API, signed, and it arrives aligned.&lt;/em&gt;&lt;/p&gt;
&lt;h3 id=&quot;why-the-usual-fix-is-a-half-fix&quot;&gt;Why the usual fix is a half-fix&lt;/h3&gt;
&lt;p&gt;Search for this problem and every answer is the same: install an SMTP plugin, point it at a real mail service, done. WP Mail SMTP, Post SMTP, and a dozen others all do it, and they do fix the authentication half. Your mail now goes through a reputable server that signs it.&lt;/p&gt;
&lt;p&gt;But look at where the credentials live. An SMTP plugin hooks &lt;code&gt;phpmailer_init&lt;/code&gt;, sets &lt;code&gt;$phpmailer-&amp;gt;Host&lt;/code&gt;, &lt;code&gt;$phpmailer-&amp;gt;Username&lt;/code&gt;, and &lt;code&gt;$phpmailer-&amp;gt;Password&lt;/code&gt;, and it has to get those values from somewhere. That somewhere is the database, in &lt;code&gt;wp_options&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Sit with what that means. Your SMTP username and password, the ones that let anything send mail as your domain, are now sitting in a table that ends up in every place a database ends up. The nightly backup. The &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mantek.io/insights/wordpress-s3-media-pipeline&quot;&gt;S3 bucket&lt;/a&gt; that backup lands in. The staging clone a contractor pulled last month. The dump you emailed yourself to debug something. Anyone who touches any copy of that database can now send mail as you, DKIM-signed by your own domain, and there is no fingerprint that says it was not you.&lt;/p&gt;
&lt;p&gt;For a brochure site, that is a small risk. For a newsroom whose domain reputation is an asset, it is the sort of thing that should keep you up at night. The half-fix traded a deliverability problem for a credential-management one, and then hid the trade in a settings screen.&lt;/p&gt;
&lt;h3 id=&quot;the-architecture-three-moves&quot;&gt;The architecture: three moves&lt;/h3&gt;
&lt;p&gt;The fix is three decisions, and each one removes an entire class of the problem rather than mitigating it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One: send through the SES API, not SMTP.&lt;/strong&gt; SMTP means a username and a password, which means a secret at rest. The &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mantek.io/wordpress-aws&quot;&gt;Amazon SES&lt;/a&gt; API is a signed HTTPS request. There is no long-lived password in the flow at all.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Two: authenticate with an IAM role, not a stored key.&lt;/strong&gt; This is the move that makes the difference. If your WordPress runs on AWS compute, EC2 or ECS, the instance or task carries an IAM role, and AWS hands short-lived credentials to the machine automatically, rotated for you, never written down. The plugin reads them from the instance metadata endpoint at send time and uses them for that one request. Nothing is stored. Not in the database, not in &lt;code&gt;wp-config.php&lt;/code&gt;, not on disk. There is no secret to leak because there is no secret at rest.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Three: replace only the transport, and reimplement nothing.&lt;/strong&gt; This is the move that keeps the plugin honest, and it has a story behind it.&lt;/p&gt;
&lt;h4 id=&quot;a-seam-not-a-fork&quot;&gt;A seam, not a fork&lt;/h4&gt;
&lt;p&gt;WordPress 5.7 added a filter called &lt;code&gt;pre_wp_mail&lt;/code&gt;. Return a non-null value from it and &lt;code&gt;wp_mail()&lt;/code&gt; short-circuits, handing you the whole job. It is tempting to take it: hook the filter, build the SES call, return &lt;code&gt;true&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;It is also a trap, and I know the shape of this trap well, because I walked into a version of it once already. &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mantek.io/insights/wordpress-arabic-slug-truncation&quot;&gt;The last must-use plugin I wrote&lt;/a&gt; forked a core function, a copy of &lt;code&gt;sanitize_title_with_dashes()&lt;/code&gt;, because core gave me no seam. That fork then drifted from core for three releases without anyone noticing, dropping an entire block of character handling, until it was mangling exactly the Arabic text it was meant to protect. The postscript on that article is a confession: a hand-copy of a core contract rots silently.&lt;/p&gt;
&lt;p&gt;Short-circuiting &lt;code&gt;pre_wp_mail&lt;/code&gt; would be the same mistake in a new costume. Take over &lt;code&gt;wp_mail()&lt;/code&gt; and you now own its entire contract: header parsing, &lt;code&gt;Cc&lt;/code&gt; and &lt;code&gt;Bcc&lt;/code&gt;, &lt;code&gt;Reply-To&lt;/code&gt;, content type, charset, attachments, and every &lt;code&gt;wp_mail_*&lt;/code&gt; filter a hundred other plugins hook into. Reimplement all of that and your copy will drift from core the moment core changes, silently, exactly like last time.&lt;/p&gt;
&lt;p&gt;So this plugin reimplements none of it. Core builds its own PHPMailer object, but only when the global is not already one:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;global $phpmailer;
if ( ! ( $phpmailer instanceof PHPMailer\PHPMailer\PHPMailer ) ) {
    // ...core builds the mailer...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That &lt;code&gt;if&lt;/code&gt; is the seam. Hand core a &lt;strong&gt;subclass&lt;/strong&gt; of PHPMailer before it looks, and it uses yours. Core still assembles the entire message, still honours every filter, still does everything it always did. The subclass overrides one method, &lt;code&gt;postSend()&lt;/code&gt;, the point where PHPMailer would normally talk to the transport, and ships the finished message to SES instead:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;class WPSES_Mailer extends \PHPMailer\PHPMailer\PHPMailer {
    public function postSend() {
        $raw  = $this-&amp;gt;getSentMIMEMessage();     // core built this, not us
        $sent = wpses_send_raw( $raw, /* recipients */ );

        if ( is_wp_error( $sent ) ) {
            // Throwing lands in core's own wp_mail_failed path.
            throw new \PHPMailer\PHPMailer\Exception( $sent-&amp;gt;get_error_message() );
        }
        return true;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We take the fully assembled MIME message that core produced and put it on the wire. We replace the transport and nothing else. There is no contract to reimplement, so there is nothing to drift. The lesson from the slug plugin, learned the expensive way, is baked into the architecture of this one: &lt;strong&gt;when core gives you a real seam, take the seam, never the fork.&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id=&quot;fail-loudly-because-the-alternative-is-what-we-are-here-to-fix&quot;&gt;Fail loudly, because the alternative is what we are here to fix&lt;/h3&gt;
&lt;p&gt;Go back to the password reset that vanished. The reason it vanished is that the failure was silent. So the cardinal rule of this plugin is that it is never silent.&lt;/p&gt;
&lt;p&gt;When SES refuses a message, it tells you exactly why: an unverified sender, a sandbox restriction, a throttle. The plugin does not swallow that into a bare &lt;code&gt;false&lt;/code&gt;. It logs the real SES error code, fires an action other code can hook, and, by throwing, lets the failure land in core's own &lt;code&gt;wp_mail_failed&lt;/code&gt; so anything already listening for mail failures keeps working. A &lt;code&gt;false&lt;/code&gt; is not a diagnosis. The SES error is.&lt;/p&gt;
&lt;p&gt;And there is one thing the plugin pointedly refuses to do: fall back to PHP &lt;code&gt;mail()&lt;/code&gt;. A fallback feels like the safe, considerate choice, and it is the exact opposite. Falling back means the mail appears to send, &lt;code&gt;wp_mail()&lt;/code&gt; returns &lt;code&gt;true&lt;/code&gt;, and the message lands in spam, which is the silent failure we started with, dressed up as resilience. A loud failure gets noticed and fixed. A silent success that lands in spam is never even seen.&lt;/p&gt;
&lt;p&gt;The same principle governs what happens when the plugin is misconfigured. It does not shrug and let WordPress use &lt;code&gt;sendmail&lt;/code&gt;. It refuses to send, and says why, on every admin screen and in the log. A must-use plugin that quietly stands aside is worse than no plugin at all, because now you believe your mail is going through SES when it is going straight to the spam folder.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.mantek.io/insights/wordpress-email-ses-diagram-2.png&quot; alt=&quot;A rejection you can see, versus one you cannot&quot;&gt;&lt;br&gt;
&lt;em&gt;Same rejected send, two outcomes. The plugin throws, so the failure lands in core's own wp_mail_failed with the real SES reason and wp_mail() returns false. The path it replaces swallows the error and returns true, and the message is gone with nothing logged.&lt;/em&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-part-every-tutorial-skips-bounces-and-complaints&quot;&gt;The part every tutorial skips: bounces and complaints&lt;/h3&gt;
&lt;p&gt;Send real mail at any volume and some of it will bounce: addresses go dead, mailboxes fill up, people hit the spam button. Amazon SES watches those two numbers, your bounce rate and your complaint rate, and it is not being advisory. Cross the thresholds and it throttles your sending; stay across them and it suspends the account outright. One careless WordPress install, cheerfully emailing a list of addresses that stopped existing years ago, is enough to put a whole domain's ability to send at risk.&lt;/p&gt;
&lt;p&gt;WordPress, left alone, will do exactly that. It has no memory of a bounce. Ask it to email a deleted address a thousand times and it will try a thousand times, because nothing in &lt;code&gt;wp_mail()&lt;/code&gt; ever hears that the last nine hundred and ninety-nine failed.&lt;/p&gt;
&lt;p&gt;So the plugin keeps the memory WordPress lacks. SES publishes every bounce and complaint to an SNS topic; the plugin exposes one endpoint for that topic, writes each dead or complaining address to a suppression table, and checks that table before every send. An address that hard-bounced or marked you as spam is simply never sent to again. That one feedback loop is most of the distance between a plugin that works on your blog and one you would run under a masthead. And if the plugin cannot record a bounce for a moment, a database blip mid-notification, it does not pretend it succeeded: it tells SNS so, and SNS holds the message and delivers it again, rather than letting a bounce quietly evaporate.&lt;/p&gt;
&lt;p&gt;There is a sharp edge here that is easy to miss, and getting it wrong is worse than not having the feature at all. That SNS endpoint is a public URL. Anything on the internet can POST to it. An endpoint that believes whatever it receives is an open invitation: forge a notification and you could add any address you liked to the suppression list, silently blocking a site's own users from ever receiving its mail. So the endpoint trusts nothing by default. It pins the single SNS topic it is allowed to act on, and it verifies Amazon's cryptographic signature on every message before one row is written. A notification that is not from your topic, or whose signature does not check out, is refused. &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mantek.io/insights/wordpress-arabic-slug-truncation&quot;&gt;The last plugin&lt;/a&gt; taught the same lesson from the other side: the input you do not validate is the one that hurts you.&lt;/p&gt;
&lt;h3 id=&quot;the-traps-worth-naming&quot;&gt;The traps worth naming&lt;/h3&gt;
&lt;p&gt;None of these are subtle once you know them, and every one has cost someone an afternoon. Named here so they cost you none:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Sandbox mode.&lt;/strong&gt; A fresh SES account is sandboxed: it can only send to addresses you have verified, and the quota is tiny. Everything looks configured, and mail to real recipients is refused. The one-time fix is to request production access; the plugin's &lt;code&gt;verify&lt;/code&gt; command tells you which mode you are in.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Region mismatch.&lt;/strong&gt; SES identities are per-region. Verify your domain in &lt;code&gt;eu-west-1&lt;/code&gt;, point the plugin at &lt;code&gt;us-east-1&lt;/code&gt;, and every send fails for a reason that has nothing to do with your code. Send from the region you verified in.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;An unverified From.&lt;/strong&gt; SES will only send as an identity you own and have verified. The &lt;code&gt;From&lt;/code&gt; domain has to be a verified identity, or the send is rejected outright, loudly, which is the whole point.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Alignment is DNS, not code.&lt;/strong&gt; DKIM and SPF passing is what carries you to the inbox, and both are records you add, not switches the plugin can flip. Turn on Easy DKIM (the three CNAMEs SES hands you), set a custom MAIL FROM so SPF aligns, and DMARC then passes. The plugin signs the request; your DNS is what makes the result trusted.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The quotas are real.&lt;/strong&gt; SES caps both a per-second rate and a rolling twenty-four-hour volume. A blast that ignores them does not queue politely, it fails. Know your limits before you lean on them.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;verifying-it-and-why-the-checker-refuses-to-lie&quot;&gt;Verifying it, and why the checker refuses to lie&lt;/h3&gt;
&lt;p&gt;A must-use plugin that sends mail silently is the problem. A plugin that tells you it is configured when it is not is the same problem wearing a badge. So the plugin ships a command whose entire job is to refuse to lie about its own health:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;wp ses verify
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It signs two real requests to SES with the exact code the send path uses, so a green result is proof the signing works, not a mock of it. It reports the things that actually break delivery: which region it is talking to, where the credentials came from (a role, ideally, not a stray key shadowing it), whether the &lt;code&gt;From&lt;/code&gt; identity is verified, whether DKIM is on, whether the account is still sandboxed, the current quota, and whether the bounce topic is wired up. And it &lt;strong&gt;exits non-zero&lt;/strong&gt; when any of that is wrong, so it composes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;wp ses verify || send-me-an-alert
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Put that line in cron and a drifted config pages you before your users find it. This is the same discipline as &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mantek.io/insights/wordpress-arabic-slug-truncation&quot;&gt;the verify command in the slug plugin&lt;/a&gt;: a checker earns the right to say PASS by exercising the real thing, and it reports a failure it has not disproven rather than a reassuring green it has not earned. A check that cannot fail is not a check.&lt;/p&gt;
&lt;h3 id=&quot;the-plugin-1&quot;&gt;The plugin&lt;/h3&gt;
&lt;p&gt;It is one file. Drop it into &lt;code&gt;wp-content/mu-plugins/&lt;/code&gt;, add a region and a &lt;code&gt;From&lt;/code&gt; address to &lt;code&gt;wp-config.php&lt;/code&gt;, give the machine an IAM role that can call SES, and WordPress mail goes through SES from the next send. No AWS SDK, no settings screen, no credentials in the database.&lt;/p&gt;
&lt;p&gt;One honest word on where it is in its life: it is &lt;strong&gt;new&lt;/strong&gt;. It is feature-complete and has been hardened through repeated adversarial review, with the SES signing checked against AWS's own test vectors, but I am shipping it as &lt;strong&gt;&lt;code&gt;0.9.0&lt;/code&gt;&lt;/strong&gt; while I run it end-to-end against a live SES account. So it is out in the open under the GPL, on GitHub and on Packagist, being finished in daylight rather than behind a curtain:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;composer require mantekio/wp-ses-mail
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The repository is &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/mantekio/wp-ses-mail&quot;&gt;github.com/mantekio/wp-ses-mail&lt;/a&gt;. It is the same loop as the last one: the article explains the thinking, the repository is the working plugin, the package is the one-line install, and each points at the other two.&lt;/p&gt;
&lt;h3 id=&quot;known-limits-1&quot;&gt;Known limits&lt;/h3&gt;
&lt;p&gt;Being honest about what a tool does not do is part of the tool. This one has edges worth stating plainly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;It is a transport, not a mailing-list engine.&lt;/strong&gt; It sends the mail WordPress already wants to send, one message at a time, well. A newsletter blast to tens of thousands of subscribers wants a queue and probably a dedicated bulk sender; leaning on this for that just meets SES throttling as a failed send.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No retry or backoff yet.&lt;/strong&gt; A send SES refuses fails loudly and stops. It does not queue and try again later. For transactional mail that is usually the right behaviour (you want to know now); if you need durable retries, that is a queue in front of the plugin, not the plugin.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;There is a message-size ceiling.&lt;/strong&gt; SES caps the size of a single message. A mail with very large inline attachments can exceed it, and that send will fail rather than be silently truncated.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Alignment is yours to configure.&lt;/strong&gt; The plugin signs every message, but DKIM, SPF and DMARC alignment are DNS decisions it cannot make for you. It can tell you they are missing; it cannot add the records.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It needs WordPress 5.7 or newer.&lt;/strong&gt; The whole clean design rests on &lt;code&gt;pre_wp_mail&lt;/code&gt;, which arrived in 5.7. On anything older there is no seam to take: mail falls through to &lt;code&gt;sendmail&lt;/code&gt;, and the plugin can warn you but cannot stop it. If you are on a WordPress that old, the mail path is not the first thing to fix.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;Sending an email from WordPress is easy. Running mail you can trust for a newsroom, where a lost password reset is a locked-out journalist and a suspended domain is every address on it going quiet, is a different job, and the difference lives entirely in the boring parts. No secret at rest, because there is no secret. A failure you can see, because silence is the bug. A memory of every bounce, because SES keeps score whether or not you do. None of it is clever. All of it is the distance between mail that works on a blog and mail that holds up under a masthead.&lt;/p&gt;
&lt;p&gt;That is the whole of how we treat &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mantek.io/wordpress-aws&quot;&gt;WordPress on AWS&lt;/a&gt;: take the seam core gives you, store nothing you would mind leaking, and fail where someone can see it. If your WordPress mail is a mystery you would rather it were not, &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mantek.io/contact&quot;&gt;let's talk&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22955/your-wordpress-says-the-email-sent-it-didnt</guid>
<pubDate>Sat, 18 Jul 2026 16:45:34 +0000</pubDate>
</item>
<item>
<title>The cost of a serverless agent isn't the Lambda. It's the loop.</title>
<link>https://coderlegion.com/22873/the-cost-of-a-serverless-agent-isnt-the-lambda-its-the-loop</link>
<description>&lt;p&gt;When I moved Wingman onto Lambda, I assumed the thing I'd have to watch was compute duration. That's the serverless mental model everyone starts with: you pay per millisecond, so keep the function fast and cheap.&lt;/p&gt;
&lt;p&gt;That model was wrong for an agent. The Lambda bill turned out to be the boring, predictable part. The two things that actually move cost and latency are the cold start and the retry loop, and neither of them shows up where you'd look first.&lt;/p&gt;
&lt;p&gt;Here's what the code taught me.&lt;/p&gt;
&lt;h3 id=&quot;the-setup-what-wingman-actually-is&quot;&gt;The setup: what Wingman actually is&lt;/h3&gt;
&lt;p&gt;Wingman is a LangGraph worker-evaluator agent. A worker node (running &lt;code&gt;gpt-4o-mini&lt;/code&gt;) does the task. An evaluator node (also &lt;code&gt;gpt-4o-mini&lt;/code&gt;) grades the answer against a success criteria. If the criteria isn't met, control routes back to the worker for another attempt, up to five times.&lt;/p&gt;
&lt;p&gt;It runs as a container image on Lambda, fronted by API Gateway and CloudFront, with session history in DynamoDB. The compute is stateless: every request rebuilds the conversation from DynamoDB, so there's no persistent checkpointer to worry about across cold and warm starts. I wrote about that state design in an earlier post. This one is about what it costs to run.&lt;/p&gt;
&lt;h3 id=&quot;cold-start-1-the-image&quot;&gt;Cold start #1: the image&lt;/h3&gt;
&lt;p&gt;A Lambda container cold start begins by unpacking the image. Wingman's image carries langchain, langgraph, langchain-community, langchain-experimental, boto3, plus reportlab and sendgrid for the agent's tools. That's not a small pile of Python.&lt;/p&gt;
&lt;p&gt;The one decision I'm glad I made is in the Dockerfile. The project has a &lt;code&gt;local&lt;/code&gt; dependency group with playwright, gradio, and uvicorn, and a &lt;code&gt;dev&lt;/code&gt; group on top of that. None of those belong in Lambda. So the build exports only the core deps and drops the rest:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dockerfile&quot;&gt;RUN uv export \
      --no-dev \
      --no-group dev \
      --no-group local \
      --format requirements-txt \
      -o /tmp/requirements.txt \
    &amp;amp;&amp;amp; pip install -r /tmp/requirements.txt --no-cache-dir \
      --target &quot;${LAMBDA_TASK_ROOT}&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Playwright alone would have pulled a browser runtime into the image. Keeping it in the &lt;code&gt;local&lt;/code&gt; group means it exists for running the agent on my laptop and never ships to Lambda. Smaller image, faster unpack, faster cold start. This is the kind of thing you only notice you did right when you compare it to the version where you didn't.&lt;/p&gt;
&lt;h3 id=&quot;cold-start-2-the-init-you-pay-for-on-import&quot;&gt;Cold start #2: the init you pay for on import&lt;/h3&gt;
&lt;p&gt;The bigger cold-start cost is in the handler, and it's easy to miss because it happens at import time:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# backend/main.py — runs once per new execution environment
wingman = Wingman()
wingman.setup()

_dynamodb = boto3.resource(&quot;dynamodb&quot;, region_name=os.getenv(&quot;AWS_REGION&quot;, &quot;us-east-1&quot;))
_table = _dynamodb.Table(_dynamodb_table_name)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;setup()&lt;/code&gt; isn't cheap. It builds the tool list, constructs two &lt;code&gt;ChatOpenAI&lt;/code&gt; clients, binds tools to the worker, wraps the evaluator in structured output, and compiles the LangGraph:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def setup(self):
    self.tools = get_tools()
    worker_llm = ChatOpenAI(model=&quot;gpt-4o-mini&quot;)
    self.worker_llm_with_tools = worker_llm.bind_tools(self.tools)
    evaluator_llm = ChatOpenAI(model=&quot;gpt-4o-mini&quot;)
    self.evaluator_llm_with_output = evaluator_llm.with_structured_output(EvaluatorOutput)
    self._build_graph()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Putting this at module level is the right call. It runs once when a new execution environment spins up, then every warm invocation reuses the same compiled graph and the same clients. If I'd built the graph inside the request handler instead, I'd be paying graph compilation on every single &lt;code&gt;/api/chat&lt;/code&gt; call, warm or not. Module-level init trades a heavier cold start for near-zero per-request setup, which is the trade you want when warm invocations vastly outnumber cold ones.&lt;/p&gt;
&lt;p&gt;The lever for cold-start speed is memory:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;resource &quot;aws_lambda_function&quot; &quot;backend&quot; {
  timeout     = 300   # 5 minutes
  memory_size = 1024
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On Lambda, CPU scales with memory. At 1024MB you get roughly a full vCPU, so bumping memory doesn't just buy RAM headroom, it makes that import-time init finish faster. Memory is a latency knob here, not only a capacity one.&lt;/p&gt;
&lt;h3 id=&quot;the-real-cost-the-retry-loop-not-the-duration&quot;&gt;The real cost: the retry loop, not the duration&lt;/h3&gt;
&lt;p&gt;Now the part that surprised me.&lt;/p&gt;
&lt;p&gt;Look at the timeout: 300 seconds. Lambda's max is 900. That's a generous ceiling for a function whose real job takes a few seconds. So why so high? Because the retry loop can run the worker up to five times, and each turn is a full round of model calls plus whatever tools the worker decides to invoke.&lt;/p&gt;
&lt;p&gt;Here's the accounting that matters. &lt;code&gt;turn_count&lt;/code&gt; only increments in the evaluator:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def evaluator(self, state: State) -&amp;gt; Dict[str, Any]:
    ...
    return {
        ...
        &quot;turn_count&quot;: state.get(&quot;turn_count&quot;, 0) + 1,
    }

def route_based_on_evaluation(self, state: State) -&amp;gt; str:
    if state[&quot;success_criteria_met&quot;] or state[&quot;user_input_needed&quot;]:
        return &quot;END&quot;
    if state.get(&quot;turn_count&quot;, 0) &amp;gt;= self.MAX_TURNS:
        return &quot;END&quot;
    return &quot;worker&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/1awc8zakbl6gl0wp9nnn.png&quot; alt=&quot;Wingman architecture on AWS Lambda showing the cold-start init path (ECR image unpack into module-level Wingman.setup building two gpt-4o-mini clients and compiling the LangGraph) and the per-request path (client through CloudFront and API Gateway with its 29-second cap, or the Lambda Function URL for long tasks, into FastAPI, DynamoDB session history, and the worker-evaluator retry loop that runs up to 5 times).&quot;&gt;&lt;/p&gt;
&lt;p&gt;So a single &lt;code&gt;/api/chat&lt;/code&gt; request is not one model call. In the worst case it's five worker calls, five evaluator calls, and every tool call the worker makes in between. The Lambda duration for that is trivial to reason about and cheap. The token cost is the thing that swings, and it swings by a factor of five depending on how many times the evaluator sends the work back.&lt;/p&gt;
&lt;p&gt;That reframed the whole cost model for me. Moving to stateless compute on Lambda made the infrastructure cost flat and predictable. What it did was push all the cost variance out of the infrastructure and into the model loop. The bill I have to watch isn't Lambda-seconds. It's tokens times retries.&lt;/p&gt;
&lt;h3 id=&quot;the-29-second-cliff&quot;&gt;The 29-second cliff&lt;/h3&gt;
&lt;p&gt;One more thing the config quietly documents. API Gateway has a hard 29-second integration timeout. A five-turn loop with tool calls can blow past that even though the Lambda itself is happily configured for 300 seconds. So the request dies at the gateway while the function is still working.&lt;/p&gt;
&lt;p&gt;The fix is in the Terraform: a Lambda Function URL that bypasses API Gateway entirely for long tasks.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;resource &quot;aws_lambda_function_url&quot; &quot;backend&quot; {
  function_name      = aws_lambda_function.backend.function_name
  authorization_type = &quot;NONE&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Short chats go through CloudFront and API Gateway. Long agent runs go straight to the Function URL and get the full timeout budget. It works, but &lt;code&gt;authorization_type = &quot;NONE&quot;&lt;/code&gt; and &lt;code&gt;allow_origins = [&quot;*&quot;]&lt;/code&gt; are both flagged in the code as things to tighten after the first deploy, and they're still open. An unauthenticated public URL that invokes a model loop is a bill waiting to happen if someone finds it. That one's on my list.&lt;/p&gt;
&lt;h3 id=&quot;what-i-d-do-differently-1&quot;&gt;What I'd do differently&lt;/h3&gt;
&lt;p&gt;The retry loop has no token budget. It caps at five turns, but a &quot;turn&quot; can be an expensive worker call with several tool invocations, and nothing stops a run from burning far more tokens than the answer is worth. A turn cap is a crude proxy for a cost cap. If I rebuilt this, the evaluator would carry a token budget alongside &lt;code&gt;turn_count&lt;/code&gt;, and the loop would stop on whichever it hit first.&lt;/p&gt;
&lt;h3 id=&quot;takeaway-6&quot;&gt;Takeaway&lt;/h3&gt;
&lt;p&gt;For a serverless agent, Lambda duration is the cheap, predictable line on the bill. The cost that actually moves lives in the cold-start init you pay on import and the retry loop you pay per request, so budget those, not the milliseconds.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22873/the-cost-of-a-serverless-agent-isnt-the-lambda-its-the-loop</guid>
<pubDate>Fri, 17 Jul 2026 05:46:41 +0000</pubDate>
</item>
<item>
<title>I grade my AI twin on real traffic, not just an offline test set</title>
<link>https://coderlegion.com/22872/i-grade-my-ai-twin-on-real-traffic-not-just-an-offline-test-set</link>
<description>&lt;p&gt;Most eval setups have a quiet lie in them. You run a fixed question set through your agent, a judge model scores the answers, you get a number, and you feel good. But that number describes a sandbox. It says nothing about the question a stranger typed into your live app at 2am, or whether your anti-hallucination guardrail held for that specific input.&lt;/p&gt;
&lt;p&gt;Twin is my personal AI digital twin. It answers questions about me over a RAG index of my profile, running on Bedrock with Claude Sonnet 4.5, FastAPI backend, Next.js frontend. I already had an offline eval harness. What I added this week is a second judge that grades production traffic as it happens, and building it forced me to fix an integrity bug I'd been ignoring in the offline one.&lt;/p&gt;
&lt;h3 id=&quot;the-problem-with-the-offline-harness&quot;&gt;The problem with the offline harness&lt;/h3&gt;
&lt;p&gt;My offline harness in &lt;code&gt;evals/&lt;/code&gt; does the honest-looking thing: load &lt;code&gt;queries.json&lt;/code&gt;, run retrieval, run inference, hand the result to a judge. The judge prompt is strict about faithfulness:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;JUDGE_SYSTEM_PROMPT = &quot;&quot;&quot;You are grading whether an AI-generated answer is
faithful to the provided source material. Be strict: any claim in the answer
not directly supported by the source material counts as a hallucination, even
if it sounds plausible.
...
- &quot;correctly_refused&quot; is true if the source material contains nothing relevant
  to the question and the answer clearly states the information isn't available;
  false if the source material was empty/irrelevant but the answer invented
  something anyway...
&quot;&quot;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The judge is good. The problem was structural: the offline harness ran its own retrieval pass, separate from the answer's retrieval pass. So the judge was grading the answer against a source set that wasn't necessarily the one the model actually saw. If retrieval is non-deterministic or the index shifts, the judge is checking the wrong evidence. You can pass an eval where the model hallucinated and fail one where it didn't. The number is measuring the wrong thing.&lt;/p&gt;
&lt;p&gt;That gap is invisible on a static question set because retrieval usually returns the same chunks twice. It stops being invisible the moment you point it at live traffic, where you have no fixed inputs at all.&lt;/p&gt;
&lt;h3 id=&quot;the-approach-capture-the-exact-source-the-model-saw&quot;&gt;The approach: capture the exact source the model saw&lt;/h3&gt;
&lt;p&gt;The fix is boring and correct. Whatever chunks the answer was generated from, those exact chunks get judged. No second retrieval.&lt;/p&gt;
&lt;p&gt;In the chat path, right after inference, I capture the request with the same &lt;code&gt;retrieved_chunks&lt;/code&gt; object that produced the answer:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;retrieved_chunks = retrieval.retrieve(request.message, k=5)
# ... inference runs against retrieved_chunks ...
capture_live_eval(request.message, retrieved_chunks, assistant_response)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;capture_live_eval&lt;/code&gt; is fire-and-forget. It never raises, because an eval capture failing should never break a user's chat:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def capture_live_eval(query: str, retrieved_chunks: List, answer: str) -&amp;gt; None:
    &quot;&quot;&quot;Fire-and-forget capture of a real chat exchange for async
    faithfulness judging. Never raises.&quot;&quot;&quot;
    if not EVALS_BUCKET:
        return
    try:
        retrieved_text = &quot;\n\n&quot;.join(
            f&quot;## {chunk.section_title}\n{chunk.text}&quot;
            for chunk, score in retrieved_chunks
        )
        key = f&quot;live/raw/{datetime.now().isoformat()}-{uuid.uuid4()}.json&quot;
        body = {
            &quot;timestamp&quot;: datetime.now().isoformat(),
            &quot;query&quot;: query,
            &quot;retrieved_chunk_ids&quot;: [c.chunk_id for c, _ in retrieved_chunks],
            &quot;retrieved_text&quot;: retrieved_text,
            &quot;answer&quot;: answer,
        }
        evals_s3_client.put_object(
            Bucket=EVALS_BUCKET, Key=key,
            Body=json.dumps(body), ContentType=&quot;application/json&quot;,
        )
    except Exception as e:
        print(f&quot;Live eval capture failed (non-fatal): {e}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The important line is that &lt;code&gt;retrieved_text&lt;/code&gt; is serialized from the same chunks the answer was built on. The judge later reads exactly this. There is no second retrieval anywhere in the live path.&lt;/p&gt;
&lt;h3 id=&quot;the-judging-happens-out-of-band&quot;&gt;The judging happens out of band&lt;/h3&gt;
&lt;p&gt;I did not want to block a chat response on a judge call. So the write to S3 under &lt;code&gt;live/raw/&lt;/code&gt; triggers a separate Lambda via an S3 event. That Lambda reads the raw record, judges it, and writes the result to &lt;code&gt;live/judged/&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def process_record(bucket: str, key: str, s3_client) -&amp;gt; None:
    raw = json.loads(
        s3_client.get_object(Bucket=bucket, Key=key)[&quot;Body&quot;].read().decode(&quot;utf-8&quot;)
    )
    judged_key = key.replace(&quot;live/raw/&quot;, &quot;live/judged/&quot;, 1)

    output = dict(raw)
    try:
        judgment = judge.judge_answer(
            raw[&quot;query&quot;], raw[&quot;retrieved_text&quot;], raw[&quot;answer&quot;]
        )
        output[&quot;judgment&quot;] = judgment
    except Exception as e:
        output[&quot;judgment_error&quot;] = str(e)

    s3_client.put_object(
        Bucket=bucket, Key=judged_key,
        Body=json.dumps(output), ContentType=&quot;application/json&quot;,
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The chat request writes one small JSON object and moves on. The user waits on nothing. The judge runs whenever the event fires. The same &lt;code&gt;judge_answer&lt;/code&gt; function backs both the offline harness and the live judge, so I am grading production and my test set with identical criteria. That reuse is deliberate. It's the only reason the two numbers are comparable.&lt;/p&gt;
&lt;p&gt;One detail I like: the judge runs on Bedrock with &lt;code&gt;temperature: 0.0&lt;/code&gt; and parses a strict JSON object with a brace-depth scan rather than a regex, because judge models love to wrap JSON in prose no matter how many times you tell them not to.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/ujgg80idroms3zrmkmbc.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;what-broke-and-what-i-d-do-differently&quot;&gt;What broke, and what I'd do differently&lt;/h3&gt;
&lt;p&gt;The capture path writes &lt;code&gt;retrieved_chunk_ids&lt;/code&gt; alongside the text. I don't use those IDs in the judge yet, but they exist for a reason: my next problem is retrieval drift. If I rebuild the profile index, the chunk IDs behind a past judgment change, and a stored judgment silently starts referring to source text that no longer exists. Right now the judged record freezes &lt;code&gt;retrieved_text&lt;/code&gt; at capture time, which protects the judgment itself, but I have no way to diff &quot;what the judge saw&quot; against &quot;what the index says today.&quot; Storing the IDs is the first half of that; I haven't built the second half.&lt;/p&gt;
&lt;p&gt;The bigger honest gap: I only judge faithfulness. The judge can tell me the model stayed inside its source material. It cannot tell me the answer was useful, or that retrieval pulled the right chunks in the first place. A perfectly faithful answer to badly retrieved context still passes. Faithfulness is the cheap half of quality, and it's the half I automated because it's the half a single model call can check.&lt;/p&gt;
&lt;p&gt;I'd also reconsider the fire-and-forget silence. &lt;code&gt;capture_live_eval&lt;/code&gt; swallowing every exception means a broken eval bucket produces one &lt;code&gt;print&lt;/code&gt; and nothing else. On Lambda that print lands in CloudWatch and I will not notice for a week. If I were doing this again I'd emit a metric on capture failure, because a silent eval pipeline is the same as no eval pipeline.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; an eval number is only trustworthy if the judge grades the exact evidence the model actually used, and the fastest way to find out whether yours does is to point it at real traffic instead of a frozen question set.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22872/i-grade-my-ai-twin-on-real-traffic-not-just-an-offline-test-set</guid>
<pubDate>Fri, 17 Jul 2026 05:44:28 +0000</pubDate>
</item>
<item>
<title>The day YouTube deleted our channel</title>
<link>https://coderlegion.com/22255/the-day-youtube-deleted-our-channel</link>
<description>&lt;p&gt;It took a single notice. The YouTube channel that held years of a newsroom's video was gone in one action, and every video embed across the site was suddenly pointing at something that no longer existed. On a publication that runs video on most of its articles, that is the kind of morning that empties an archive.&lt;/p&gt;
&lt;p&gt;It was a non-event. We changed one setting, the whole site began playing the same videos from our own storage, and readers never saw a gap. This is the story of why a deleted channel was a configuration change rather than a catastrophe, and the one decision, made years earlier, that made the difference.&lt;/p&gt;
&lt;h3 id=&quot;platforms-can-delete-you-and-you-do-not-get-a-vote&quot;&gt;Platforms can delete you, and you do not get a vote&lt;/h3&gt;
&lt;p&gt;Channels and accounts get removed for all sorts of reasons, and most of them are not a fair fight: an automated copyright match against music in the background, a strike from a report, a policy you did not know had changed, a regional block, an API quietly deprecated, or an entire product shut down (the internet is littered with services that were permanent right up until they were not).&lt;/p&gt;
&lt;p&gt;The common thread is that the decision is theirs, the timing is theirs, and the content you put on their platform was never fully in your hands. For a newsroom, video on a third-party platform is best treated as reach, not as custody.&lt;/p&gt;
&lt;h3 id=&quot;publish-to-platforms-never-store-on-them&quot;&gt;Publish to platforms, never store on them&lt;/h3&gt;
&lt;p&gt;The decision that turned the incident into a shrug was made on the very first upload: every video goes to our own S3 bucket first. The original lives with us. Pushing a copy out to YouTube, Dailymotion or Vimeo is a distribution step, for reach and free bandwidth, not the place the file actually lives.&lt;/p&gt;
&lt;p&gt;It is the same discipline behind &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mantek.io/insights/wordpress-s3-media-pipeline&quot;&gt;the rest of our media handling&lt;/a&gt;: the original is offloaded to storage we control on upload, and everything downstream is a copy or a rendition of it. So when a platform pulls a video, or an entire channel, nothing is actually lost. The master is still sitting in the bucket exactly where it was put.&lt;/p&gt;
&lt;h3 id=&quot;one-video-four-sources&quot;&gt;One video, four sources&lt;/h3&gt;
&lt;p&gt;On top of that, each video is a single logical asset with up to four play sources linked behind it: YouTube, Dailymotion, Vimeo, and Amazon (the file served from our own S3 through CloudFront).&lt;/p&gt;
&lt;p&gt;The important word is &lt;em&gt;source&lt;/em&gt;. Where a video plays is a stored setting, not an embed baked into the post. That one distinction is the whole game. If the player reads a source field, you can change where every video comes from without editing a single article. If instead every post contains a literal YouTube iframe, changing the source later is a database migration under pressure, which is exactly when you do not want one.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.mantek.io/insights/youtube-deleted-our-channel-diagram-1.png&quot; alt=&quot;The play source is a dial, not a hard-coded embed&quot;&gt;&lt;br&gt;
&lt;em&gt;The original lives in S3 and is published out to YouTube, Dailymotion and Vimeo. Where a video actually plays is a stored setting, a site-wide default with a per-video override, so any source can be the active one.&lt;/em&gt;&lt;/p&gt;
&lt;h3 id=&quot;two-dials-site-wide-and-per-video&quot;&gt;Two dials: site-wide and per video&lt;/h3&gt;
&lt;p&gt;The source has two levels of control.&lt;/p&gt;
&lt;p&gt;Site-wide, you set the default that every video plays from. We ran on YouTube, because that is where the audience and the bandwidth were.&lt;/p&gt;
&lt;p&gt;Per video, you can override that default, and in a newsroom that is not a rare event. Long videos that carry clips or background music from other sources get flagged by YouTube's automated Content ID constantly, often even when you hold the rights, and clearing a strike means filing an appeal and waiting days. So those videos are set, right there on the upload screen, to play from Dailymotion, Vimeo or S3 instead, while the rest of the library stays on YouTube. The content most likely to be struck simply sidesteps the platform that polices it hardest, with no downtime and no appeal queue.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.mantek.io/insights/youtube-deleted-our-channel-diagram-2.png&quot; alt=&quot;Two levels of control: the site default and the per-video override&quot;&gt;&lt;br&gt;
&lt;em&gt;The site-wide default sets the source for every video at once. A per-video override pins a single clip to another source: the long documentary full of licensed music is set to Vimeo on the upload screen, so it sidesteps the platform most likely to strike it, while everything else stays on the default.&lt;/em&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-day-the-whole-channel-went&quot;&gt;The day the whole channel went&lt;/h3&gt;
&lt;p&gt;Then the version of the problem you cannot patch one video at a time: the entire channel was deleted at once. Every video that defaulted to YouTube would fail in the player, all at the same moment, across the whole archive.&lt;/p&gt;
&lt;p&gt;Because the play source was a single site-wide setting, and because every original was already sitting in S3, the fix was to change the default from YouTube to Amazon and save. Every embed on the site re-pointed to the S3 copies, served through CloudFront, in one action. The outage was as long as it takes to change a dropdown.&lt;/p&gt;
&lt;p&gt;Compare that to the alternative if YouTube had been the storage rather than a distribution target: the archive would simply be gone, and the &quot;recovery&quot; would be re-sourcing and re-uploading thousands of videos that no longer existed.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.mantek.io/insights/youtube-deleted-our-channel-diagram-3.png&quot; alt=&quot;One setting, the whole archive back&quot;&gt;&lt;br&gt;
&lt;em&gt;When the channel was deleted, every video that defaulted to YouTube failed at once. Because the play source is a single site-wide setting and the originals were already in S3, changing the default to Amazon restored the entire archive in one click.&lt;/em&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-fallback-is-not-a-downgrade&quot;&gt;The fallback is not a downgrade&lt;/h3&gt;
&lt;p&gt;It is worth being clear that playing from your own infrastructure is not a degraded emergency mode. The S3 source plays through CloudFront like any other CDN-backed asset. When you want adaptive streaming at scale, you put it in front: an upload event triggers AWS Elemental MediaConvert to produce HLS renditions, CloudFront caches the segments, and a standard player streams quality that adapts to each viewer's connection.&lt;/p&gt;
&lt;p&gt;In other words, falling back to your own storage is not making do. For many sites it is the best source they have. The platforms were the convenience, not the foundation.&lt;/p&gt;
&lt;h3 id=&quot;what-it-is-really-about-custody&quot;&gt;What it is really about: custody&lt;/h3&gt;
&lt;p&gt;Strip away the specifics and the principle is simple. Own the original, and treat every external platform as interchangeable reach you opt into, never as the home of your media. In practice that is three habits:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Keep the master somewhere you control (for us, S3).&lt;/li&gt;
&lt;li&gt;Make playback a setting, not a hard-coded embed, so the source is a dial.&lt;/li&gt;
&lt;li&gt;Publish to platforms for reach, but never let one become your only copy.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Do that, and platform risk (a strike, a deleted channel, a shut-down service, a pricing change you cannot stomach) stops being an existential event and becomes a switch you flip. The same thinking applies anywhere you syndicate: images to a CDN, articles to third-party readers, audio to podcast directories. Keep the master, keep the destination swappable.&lt;/p&gt;
&lt;h3 id=&quot;a-two-minute-gut-check-for-your-own-setup&quot;&gt;A two-minute gut check for your own setup&lt;/h3&gt;
&lt;p&gt;Three questions worth answering honestly:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;If your video platform closed your account tonight, do the originals exist anywhere else?&lt;/strong&gt; If the only copy is on the platform, that is the first thing to fix, before anything clever.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Is playback a per-post embed, or a source you can change centrally?&lt;/strong&gt; If it is baked into content, switching later is a migration, not a setting.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Do you publish to platforms, or store on them?&lt;/strong&gt; Publishing is healthy. Storing is the trap.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If the answers make you uncomfortable, you already know where to start.&lt;/p&gt;
&lt;h3 id=&quot;the-bottom-line&quot;&gt;The bottom line&lt;/h3&gt;
&lt;p&gt;We keep newsrooms fast and unbreakable on &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mantek.io/wordpress-aws&quot;&gt;WordPress and AWS&lt;/a&gt;, and this is the resilience side of the same coin as keeping the media pipeline lean: the originals never leave your control, so neither a platform nor a traffic spike can take the archive down.&lt;/p&gt;
&lt;p&gt;If your video lives on someone else's platform and losing it would hurt, &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mantek.io/contact&quot;&gt;let's talk&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;further-reading-1&quot;&gt;Further reading&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://aws.amazon.com/mediaconvert/&quot;&gt;AWS Elemental MediaConvert&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://aws.amazon.com/s3/&quot;&gt;Amazon S3&lt;/a&gt; and &lt;a rel=&quot;nofollow&quot; href=&quot;https://aws.amazon.com/cloudfront/&quot;&gt;Amazon CloudFront&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22255/the-day-youtube-deleted-our-channel</guid>
<pubDate>Fri, 10 Jul 2026 22:53:46 +0000</pubDate>
</item>
<item>
<title>How a Single NAT Gateway Can Silently Kill Your AWS High Availability</title>
<link>https://coderlegion.com/21893/how-a-single-nat-gateway-can-silently-kill-your-aws-high-availability</link>
<description>&lt;p&gt;&lt;em&gt;A real-world lesson from a production-like AWS lab challenge&lt;/em&gt;&lt;/p&gt;
&lt;h4 id=&quot;the-scenario-that-should-scare-you&quot;&gt;The Scenario That Should Scare You&lt;/h4&gt;
&lt;p&gt;Imagine this: your AWS environment has two Availability Zones, public and private subnets, an Application Load Balancer, Auto Scaling. Your architecture diagram looks solid. Then one Availability Zone goes down,  your ALB fails over instantly, your EC2 instances in AZ-B are running fine. But your application is still broken.&lt;/p&gt;
&lt;p&gt;Because every private subnet instance, including those in AZ-B, is routing outbound traffic through one NAT Gateway sitting in AZ-A. Which is now unreachable.&lt;/p&gt;
&lt;p&gt;You didn't have a highly available architecture. You had the illusion of one.&lt;/p&gt;
&lt;h4 id=&quot;understanding-the-problem-nat-gateways-are-zonal&quot;&gt;Understanding the Problem: NAT Gateways Are Zonal&lt;/h4&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;A NAT Gateway is not a regional resource. It lives in a specific Availability Zone.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;When you create a NAT Gateway, you place it in a specific subnet, which belongs to a specific AZ. If that AZ goes down, your NAT Gateway goes down with it.&lt;/p&gt;
&lt;p&gt;Many teams create a single NAT Gateway to save costs, then route all private subnet traffic across all AZs through that one gateway:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;Private Subnet AZ-A → 0.0.0.0/0 → nat-09xxxxx (AZ-A) ✅
Private Subnet AZ-B → 0.0.0.0/0 → nat-09xxxxx (AZ-A) ❌
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The private subnet in AZ-B is routing through a NAT Gateway in AZ-A. This is a &lt;strong&gt;cross-AZ dependency&lt;/strong&gt;, and a silent Single Point of Failure.&lt;/p&gt;
&lt;h4 id=&quot;what-i-found-in-the-lab&quot;&gt;What I Found in the Lab&lt;/h4&gt;
&lt;p&gt;The lab presented a VPC with this structure:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Resource &lt;/th&gt;&lt;th&gt;CIDR / Details&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;VPC &lt;/td&gt;&lt;td&gt;10.0.0.0/16&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Public Subnet AZ-A &lt;/td&gt;&lt;td&gt;10.0.128.0/20&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Public Subnet AZ-B &lt;/td&gt;&lt;td&gt;10.0.144.0/20&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Private Subnet 1A (AZ-A) &lt;/td&gt;&lt;td&gt;10.0.0.0/19&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Private Subnet 1B (AZ-A) &lt;/td&gt;&lt;td&gt;10.0.192.0/21&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Private Subnet 2A (AZ-B) &lt;/td&gt;&lt;td&gt;10.0.32.0/19&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Private Subnet 2B (AZ-B) &lt;/td&gt;&lt;td&gt;10.0.200.0/21&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Two NAT Gateways existed: one in AZ-A, one in AZ-B. At first glance, this looked correct.&lt;/p&gt;
&lt;p&gt;But when I inspected the Route Tables, the problem was immediately visible. &lt;strong&gt;All four private subnet Route Tables had the same entry:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;Destination: 0.0.0.0/0 → Target: nat-09xxxxxxxx (AZ-A)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The NAT Gateway in AZ-B existed, but nobody was using it. It was provisioned but completely disconnected from the routing logic. The two private subnets in AZ-B were silently depending on the NAT Gateway in AZ-A for all outbound internet traffic.&lt;/p&gt;
&lt;h4 id=&quot;why-this-happens&quot;&gt;Why This Happens&lt;/h4&gt;
&lt;p&gt;There are two common causes:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Cost-cutting gone wrong&lt;/strong&gt;&lt;br&gt;
Teams create one NAT Gateway to reduce costs, then forget that high availability requires one per AZ. A NAT Gateway costs approximately $0.045/hour plus data transfer charges. Running two instead of one adds roughly $32/month, a small price compared to the cost of an outage.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Infrastructure drift&lt;/strong&gt;&lt;br&gt;
The architecture was correct at some point, then someone modified the Route Tables manually, or via a flawed IaC change, and the second NAT Gateway became orphaned without anyone noticing. No alerts, no errors, no warnings. Everything looks fine until AZ-A goes down.&lt;/p&gt;
&lt;p&gt;This is what makes this particular SPOF so dangerous: &lt;strong&gt;it is completely invisible during normal operations.&lt;/strong&gt;&lt;/p&gt;
&lt;h4 id=&quot;the-fix-one-nat-gateway-per-az-one-route-table-per-private-subnet&quot;&gt;The Fix: One NAT Gateway Per AZ, One Route Table Per Private Subnet&lt;/h4&gt;
&lt;p&gt;The solution is straightforward: each private subnet must route its outbound internet traffic through the NAT Gateway &lt;strong&gt;in its own Availability Zone.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Correct routing after the fix:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;Private Subnet 1A (AZ-A) → 0.0.0.0/0 → nat-AZ-A ✅
Private Subnet 1B (AZ-A) → 0.0.0.0/0 → nat-AZ-A ✅
Private Subnet 2A (AZ-B) → 0.0.0.0/0 → nat-AZ-B ✅
Private Subnet 2B (AZ-B) → 0.0.0.0/0 → nat-AZ-B ✅
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Step 1 — Identify which NAT Gateway belongs to which AZ&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Go to &lt;strong&gt;VPC → NAT Gateways&lt;/strong&gt;, click each NAT Gateway and check the &lt;strong&gt;Subnet&lt;/strong&gt; field, this tells you which AZ it belongs to.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 2 — Fix the Route Tables for AZ-B private subnets&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;VPC → Route Tables&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Find the Route Table associated with &lt;strong&gt;Private Subnet 2A (AZ-B)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Edit routes&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Change &lt;code&gt;0.0.0.0/0&lt;/code&gt; from &lt;code&gt;nat-AZ-A&lt;/code&gt; → &lt;code&gt;nat-AZ-B&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Save changes&lt;/li&gt;
&lt;li&gt;Repeat for &lt;strong&gt;Private Subnet 2B (AZ-B)&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Step 3 — Verify&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;All four private subnet Route Tables should now point exclusively to the NAT Gateway in their own AZ. If AZ-A goes down, AZ-B is completely self-sufficient.&lt;/p&gt;
&lt;h4 id=&quot;getting-it-right-from-the-start-terraform&quot;&gt;Getting It Right From the Start: Terraform&lt;/h4&gt;
&lt;p&gt;If you're provisioning your VPC with Infrastructure as Code, which you should be, here's how to enforce this pattern correctly with Terraform from day one.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ˋ&lt;/code&gt;hcl&lt;/p&gt;
&lt;h2 id=&quot;nat-gateway-in-az-a&quot;&gt;NAT Gateway in AZ-A&lt;/h2&gt;
&lt;p&gt;resource &quot;aws_eip&quot; &quot;nat_a&quot; {&lt;br&gt;
  domain = &quot;vpc&quot;&lt;br&gt;
}&lt;/p&gt;
&lt;p&gt;resource &quot;aws_nat_gateway&quot; &quot;nat_a&quot; {&lt;br&gt;
  allocation_id = aws_eip.nat_a.id&lt;br&gt;
  subnet_id     = aws_subnet.public_a.id&lt;/p&gt;
&lt;p&gt;  tags = {&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Name = &quot;nat-gateway-az-a&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;  }&lt;br&gt;
}&lt;/p&gt;
&lt;h2 id=&quot;nat-gateway-in-az-b&quot;&gt;NAT Gateway in AZ-B&lt;/h2&gt;
&lt;p&gt;resource &quot;aws_eip&quot; &quot;nat_b&quot; {&lt;br&gt;
  domain = &quot;vpc&quot;&lt;br&gt;
}&lt;/p&gt;
&lt;p&gt;resource &quot;aws_nat_gateway&quot; &quot;nat_b&quot; {&lt;br&gt;
  allocation_id = aws_eip.nat_b.id&lt;br&gt;
  subnet_id     = aws_subnet.public_b.id&lt;/p&gt;
&lt;p&gt;  tags = {&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Name = &quot;nat-gateway-az-b&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;  }&lt;br&gt;
}&lt;/p&gt;
&lt;h2 id=&quot;route-table-az-a-private-subnets&quot;&gt;Route Table — AZ-A private subnets&lt;/h2&gt;
&lt;p&gt;resource &quot;aws_route_table&quot; &quot;private_a&quot; {&lt;br&gt;
  vpc_id = aws_vpc.main.id&lt;/p&gt;
&lt;p&gt;  route {&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cidr_block     = &quot;0.0.0.0/0&quot;
nat_gateway_id = aws_nat_gateway.nat_a.id
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;  }&lt;/p&gt;
&lt;p&gt;  tags = { Name = &quot;private-rt-az-a&quot; }&lt;br&gt;
}&lt;/p&gt;
&lt;h2 id=&quot;route-table-az-b-private-subnets&quot;&gt;Route Table — AZ-B private subnets&lt;/h2&gt;
&lt;p&gt;resource &quot;aws_route_table&quot; &quot;private_b&quot; {&lt;br&gt;
  vpc_id = aws_vpc.main.id&lt;/p&gt;
&lt;p&gt;  route {&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cidr_block     = &quot;0.0.0.0/0&quot;
nat_gateway_id = aws_nat_gateway.nat_b.id
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;  }&lt;/p&gt;
&lt;p&gt;  tags = { Name = &quot;private-rt-az-b&quot; }&lt;br&gt;
}&lt;/p&gt;
&lt;h2 id=&quot;associations-az-a&quot;&gt;Associations — AZ-A&lt;/h2&gt;
&lt;p&gt;resource &quot;aws_route_table_association&quot; &quot;private_1a&quot; {&lt;br&gt;
  subnet_id      = aws_subnet.private_1a.id&lt;br&gt;
  route_table_id = aws_route_table.private_a.id&lt;br&gt;
}&lt;/p&gt;
&lt;p&gt;resource &quot;aws_route_table_association&quot; &quot;private_1b&quot; {&lt;br&gt;
  subnet_id      = aws_subnet.private_1b.id&lt;br&gt;
  route_table_id = aws_route_table.private_a.id&lt;br&gt;
}&lt;/p&gt;
&lt;h2 id=&quot;associations-az-b&quot;&gt;Associations — AZ-B&lt;/h2&gt;
&lt;p&gt;resource &quot;aws_route_table_association&quot; &quot;private_2a&quot; {&lt;br&gt;
  subnet_id      = aws_subnet.private_2a.id&lt;br&gt;
  route_table_id = aws_route_table.private_b.id&lt;br&gt;
}&lt;/p&gt;
&lt;p&gt;resource &quot;aws_route_table_association&quot; &quot;private_2b&quot; {&lt;br&gt;
  subnet_id      = aws_subnet.private_2b.id&lt;br&gt;
  route_table_id = aws_route_table.private_b.id&lt;br&gt;
}&lt;br&gt;
&lt;code&gt;ˋ&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The beauty of this approach: &lt;strong&gt;the correct pattern is enforced by design.&lt;/strong&gt; Each AZ has its own NAT Gateway, its own Route Table, and explicit associations. Infrastructure drift becomes impossible, any change goes through code review.&lt;/p&gt;
&lt;h4 id=&quot;the-broader-lesson-designing-for-failure&quot;&gt;The Broader Lesson: Designing for Failure&lt;/h4&gt;
&lt;p&gt;AWS high availability is built on one fundamental principle:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Assume everything will fail. Design so that the failure of any single component does not bring down the entire system.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;A NAT Gateway is a component. An Availability Zone is a failure domain. When you route cross-AZ traffic through a single NAT Gateway, you create an invisible dependency that violates this principle, and the worst part is that &lt;strong&gt;everything looks fine until the moment it isn't.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The AWS Well-Architected Framework's Reliability Pillar specifically calls for eliminating Single Points of Failure. A shared NAT Gateway is a textbook SPOF, easy to miss precisely because the architecture looks correct at first glance.&lt;/p&gt;
&lt;h4 id=&quot;key-takeaways&quot;&gt;Key Takeaways&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;A NAT Gateway is &lt;strong&gt;zonal&lt;/strong&gt;, it belongs to one specific Availability Zone&lt;/li&gt;
&lt;li&gt;Routing all private subnet traffic through a single NAT Gateway creates a &lt;strong&gt;hidden Single Point of Failure&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;The fix: &lt;strong&gt;one NAT Gateway per AZ, one Route Table per AZ&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Use &lt;strong&gt;Terraform&lt;/strong&gt; to enforce this pattern by design and prevent infrastructure drift&lt;/li&gt;
&lt;li&gt;The cost of two NAT Gateways (~$32/month extra) is nothing compared to the cost of an outage&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;This article is part of my AWS Solutions Architect Associate (SAA-C03) preparation series. I document real hands-on lab experiences, architecture challenges, and lessons learned along the way.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Follow along for more practical AWS architecture and Infrastructure as Code content.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/21893/how-a-single-nat-gateway-can-silently-kill-your-aws-high-availability</guid>
<pubDate>Sat, 04 Jul 2026 08:44:42 +0000</pubDate>
</item>
<item>
<title>Stop Putting Everything in One Terraform State: Use Terragrunt Dependency Blocks</title>
<link>https://coderlegion.com/21890/stop-putting-everything-in-one-terraform-state-use-terragrunt-dependency-blocks</link>
<description>&lt;h4 id=&quot;prerequisites-3&quot;&gt;Prerequisites&lt;/h4&gt;
&lt;p&gt;Before getting started, make sure you have the following:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Basic knowledge of Terraform (HCL syntax, resources, variables, remote state)&lt;/li&gt;
&lt;li&gt;Terraform &amp;gt;= 1.11 installed - &lt;a rel=&quot;nofollow&quot; href=&quot;https://developer.hashicorp.com/terraform/install&quot;&gt;Download&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Terragrunt installed - &lt;a rel=&quot;nofollow&quot; href=&quot;https://terragrunt.gruntwork.io/docs/getting-started/quick-start/#install-terragrunt&quot;&gt;Installation guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;An AWS CLI configured with sufficient permissions to create S3 buckets and EC2 instances&lt;/li&gt;
&lt;li&gt;Visual Studio Code with the &lt;a rel=&quot;nofollow&quot; href=&quot;https://marketplace.visualstudio.com/items?itemName=HashiCorp.terraform&quot;&gt;HashiCorp Terraform extension&lt;/a&gt; for syntax hightlighting and autocompletion&lt;/li&gt;
&lt;li&gt;Read Part 1 of this series: [Stop Copy-Pasting Terraform State Configs: Use Terragrunt Instead](Stop Copy-Pasting Terraform State Configs: Use Terragrunt Instead)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;introduction&quot;&gt;Introduction&lt;/h3&gt;
&lt;p&gt;In Part 1 of this series, we saw how Terragrunt eliminates the repetition of remote state backend configurations across environments. if you haven't read it yet, I recommend starting there - [Stop Copy-Pasting Terraform State Configs: Use Terragrunt Instead](Stop Copy-Pasting Terraform State Configs: Use Terragrunt Instead).&lt;br&gt;
Today, we go one step further.&lt;br&gt;
Most of Terraform projects start the same way: everything in one state file. Your VPC, your security groups, your EC2 instances, your RDS database, all managed together. It feels simple and convenient at first. But as your infrastructure grows, this approach becomes a hidden risk.&lt;br&gt;
Imagine this: a developer runs terraform apply to redeploy an EC2 instance that is rebuilt multiple times a day. Because everything is in the same state file, that single command now has access to your VPC configuration, your production database, and your security groups, resources that should never be touched during a routine EC2 redeployment.&lt;br&gt;
One wrong move, one bad variable, one interrupted apply, and you could accidentally destroy or corrupt critical infrastructure that takes hours to rebuild.&lt;br&gt;
In this article, we'll explore how Terragrunt's dependency blocks allow you to split your Terraform state between infrastructure components, so that frequently changed resources never put your critical infrastructure at risk.&lt;/p&gt;
&lt;h3 id=&quot;the-problem-one-state-file-to-rule-them-all&quot;&gt;The Problem: One State File to Rule Them All&lt;/h3&gt;
&lt;p&gt;When everything lives in a single Terraform state file, your infrastructure looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;single state file
├── VPC                ← modified once a month
├── Subnets            ← modified once a month
├── Security Groups    ← modified occasionally
├── RDS Database       ← critical, rarely modified
└── EC2 Instances      ← modified 10x per day
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every terraform apply, no matter how small, touches this single state file. This creates three serious problems:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Problem 1: &lt;em&gt;Blast radius&lt;/em&gt;: If something goes wrong during a routine EC2 redeployment, the entire state file is at risk. A corrupted state means Terraform loses track of all your resources, VPC, database, everything.&lt;/li&gt;
&lt;li&gt;Problem 2: &lt;em&gt;No separation of concerns&lt;/em&gt;: A junior developer redeploying an EC2 instance has the same Terraform access as a senior engineer modifying the VPC. There is no natural boundary between critical and non-critical infrastructure.&lt;/li&gt;
&lt;li&gt;Problem 3: &lt;em&gt;Slow operations As your infrastructure grows&lt;/em&gt;: Terraform has to refresh the state of every single resource on every terraform plan or terraform apply, even if you're only changing one EC2 instance. This makes operations increasingly slow.&lt;br&gt;
The solution is to split your state between infrastructure components, and Terragrunt dependency blocks make this both simple and elegant.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;the-solution-separate-states-with-dependency-blocks&quot;&gt;The Solution: Separate States with Dependency Blocks&lt;/h3&gt;
&lt;p&gt;Instead of one monolithic state file, Terragrunt allows you to give each infrastructure component its own isolated state: &lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;vpc/                    ← state 1 — modified rarely
security-groups/        ← state 2 — modified occasionally
rds/                    ← state 3 — critical, rarely modified
ec2/                    ← state 4 — modified daily
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each component lives in its own folder with its own terragrunt.hcl file and its own state file in S3:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;s3://my-terraform-state/
├── dev/vpc/terraform.tfstate
├── dev/security-groups/terraform.tfstate
├── dev/rds/terraform.tfstate
└── dev/ec2/terraform.tfstate
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now when a developer runs terraform apply on the EC2 component, only the EC2 state is touched. The VPC, the database, and the security groups are completely isolated and protected.&lt;br&gt;
&lt;strong&gt;But here's the challenge&lt;/strong&gt;: if components are separated, how does the EC2 module know the subnet ID from the VPC module? How does the security group know the VPC ID?&lt;br&gt;
This is where Terragrunt's dependency block comes in.&lt;br&gt;
The dependency block allows a component to &lt;strong&gt;read the outputs of another component&lt;/strong&gt; without sharing the same state file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;# ec2/terragrunt.hcl

include &quot;root&quot; {
  path = find_in_parent_folders()
}

# Declare dependency on VPC component
dependency &quot;vpc&quot; {
  config_path = &quot;../vpc&quot;

  mock_outputs = {
    subnet_id = &quot;subnet-00000000&quot;
  }
}

# Declare dependency on security groups component
dependency &quot;security_groups&quot; {
  config_path = &quot;../security-groups&quot;

  mock_outputs = {
    sg_id = &quot;sg-00000000&quot;
  }
}

# Use outputs from dependencies as inputs
inputs = {
  subnet_id      = dependency.vpc.outputs.subnet_id
  security_group = dependency.security_groups.outputs.sg_id
  instance_type  = &quot;t2.micro&quot;
  environment    = &quot;dev&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two things to notice here: &lt;br&gt;
First, config_path points to the folder of the dependency, not a specific file. Terragrunt knows where to find the outputs.&lt;br&gt;
Second, mock_outpouts provides fake values for when you run terragrunt plan without the dependencies being deployed yet. this allows you to validate your configuration before deploying anything.&lt;/p&gt;
&lt;h3 id=&quot;the-complete-project-structure&quot;&gt;The Complete Project Structure&lt;/h3&gt;
&lt;p&gt;Here is the complete project structure for a dev environment with separated state files: &lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;project/
├── terragrunt.hcl                    # Root — remote state defined once
└── dev/
    ├── vpc/
    │   ├── terragrunt.hcl
    │   └── main.tf                   # VPC + Subnets
    ├── security-groups/
    │   ├── terragrunt.hcl
    │   └── main.tf                   # Security Groups
    ├── rds/
    │   ├── terragrunt.hcl
    │   └── main.tf                   # RDS Database
    └── ec2/
        ├── terragrunt.hcl
        └── main.tf                   # EC2 Instances
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each component exposes its key values through Terraform outputs, which are then consumed by dependent components via the dependency block.&lt;br&gt;
Here is how the dependency chain flows:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;vpc/
  └── outputs: vpc_id, subnet_id
        │
        ├─────────────────────────┐
        ▼                         ▼
security-groups/                 ec2/
  inputs: vpc_id            inputs: subnet_id
  outputs: sg_id                  │
        │                         │
        └─────────────────────────┘
                    ▼
                  ec2/
             inputs: sg_id
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Terragrunt reads this dependency graph automatically and deploys components in the correct order: VPC first, then security groups, then EC2. You never have to think about the deployment order manually.&lt;br&gt;
The VPC component is the simplest, it has no dependencies: &lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;# dev/vpc/terragrunt.hcl

include &quot;root&quot; {
  path = find_in_parent_folders()
}

inputs = {
  environment = &quot;dev&quot;
  vpc_cidr    = &quot;10.0.0.0/16&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And its main.tf exposes the values that other components need:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-terraform&quot;&gt;# dev/vpc/main.tf

output &quot;vpc_id&quot; {
  value = aws_vpc.main.id
}

output &quot;subnet_id&quot; {
  value = aws_subnet.public.id
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The outputs are what the dependency block reads when EC2 asks for dependency.vpc.outputs.subnet_id.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;em&gt;The full code for this project structure is available in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/JM01lab/aws-terragrunt-examples/tree/main/part2-component-isolation/dev&quot;&gt;GitHub repository&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;deploying-with-dependency-blocks&quot;&gt;Deploying with Dependency Blocks&lt;/h3&gt;
&lt;p&gt;Once your structure is in place, deploying is as simple as one command from the dev/ folder:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;terragrunt run-all apply
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Terragrunt automatically:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Reads the dependency graph across all components&lt;/li&gt;
&lt;li&gt;Deploys in the correct order — VPC → Security Groups → EC2&lt;/li&gt;
&lt;li&gt;Passes outputs from one component as inputs to the next&lt;/li&gt;
&lt;li&gt;Creates a separate state file in S3 for each component&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You can also target individual components:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;# Redeploy only EC2 — VPC and Security Groups are untouched
cd dev/ec2/
terragrunt apply

# Check outputs of a specific component
cd dev/vpc/
terragrunt output

# Destroy in reverse order automatically
cd dev/
terragrunt run-all destroy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Notice the power here&lt;/strong&gt;: when you run terragrunt apply in dev/ec2/ only, Terraform touches only the EC2 state file. Your VPC and database state files are completely safe, even if something goes wrong.&lt;/p&gt;
&lt;h4 id=&quot;the-mock-outputs---why-they-matter&quot;&gt;The mock_outputs - Why They Matter&lt;/h4&gt;
&lt;p&gt;When you run terragrunt plan on EC2 component before the VPC is deployed, Terragrunt needs values for subnet.id and sg.id to validate the configuration. Since the real values don't exist yet, mock_outputs provides temporary placeholders:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;dependency &quot;vpc&quot; {
  config_path = &quot;../vpc&quot;

  mock_outputs = {
    subnet_id = &quot;subnet-00000000&quot;
  }

  mock_outputs_allowed_terraform_commands = [&quot;validate&quot;, &quot;plan&quot;]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The mock_outputs_allowed_terraform_commands parameter ensures that mock values are only used during validate and plan, never during apply. This prevents accidental deployments with fake values.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A Note on Security&lt;/strong&gt;&lt;br&gt;
Before wrapping up, a quick but important note on security, raised by &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/sqlxpert&quot;&gt;Paul Marcelin&lt;/a&gt; in the comments of Part 1.&lt;br&gt;
When your state files are separated by component, you have a natural opportunity to apply different IAM permissions per component. For example:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Developers can have read/write access to the EC2 state file&lt;/li&gt;
&lt;li&gt;Only senior engineers or CI/CD pipelines can access the VPC and RDS state files&lt;/li&gt;
&lt;li&gt;Production state files can be encrypted with dedicated KMS keys per environment.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is a significant security improvement over a single state file where everyone has access to everything. Separating state files is the first step, securing them with IAM policies and KMS encryption is the natural next step.&lt;br&gt;
For a deep dive on Terraform state file security, I recommend this LinkedIn post by Yaroslav Naumenko: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/posts/ynaumenko_terraform-terragrunt-iac-share-7439240151249231872-OMca?utm_source=social_share_send&amp;amp;utm_medium=member_desktop_web&amp;amp;rcm=ACoAADjV-nEBBut00mj1Y619QEAPOvT8nA_vQb8&quot;&gt;&quot;Your Terraform state file is a secret. Most teams don't treat it that way.&quot;&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;conclusion-2&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;Let's recap what we covered in this article:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The problem:  a monolithic state file creates a dangerous blast radius where routine operations can accidentally affect critical infrastructure&lt;/li&gt;
&lt;li&gt;The solution: Terragrunt dependency blocks allow each component to have its own isolated state file&lt;/li&gt;
&lt;li&gt;The dependency block reads outputs from other components without sharing their state&lt;/li&gt;
&lt;li&gt;mock_outputs allow you to validate configurations before dependencies are deployed&lt;/li&gt;
&lt;li&gt;terragrunt run-all apply automatically respects the dependency order&lt;/li&gt;
&lt;li&gt;Separating state files is also the foundation for better security, different IAM permissions per component.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt; Together, Part 1 and Part 2 give you a complete Terragrunt workflow:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;Part 1 → One root terragrunt.hcl    = No repeated backend configs
Part 2 → Dependency blocks          = No more monolithic state files
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you found this helpful, share it and follow me for the next article in the series.&lt;br&gt;
The code for this article is available on my &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/JM01lab/aws-terragrunt-examples/tree/main/part2-component-isolation/dev&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/21890/stop-putting-everything-in-one-terraform-state-use-terragrunt-dependency-blocks</guid>
<pubDate>Sat, 04 Jul 2026 08:15:24 +0000</pubDate>
</item>
<item>
<title>The Reality of Roadmaps 🗺️</title>
<link>https://coderlegion.com/20091/the-reality-of-roadmaps</link>
<description>&lt;blockquote&gt;&lt;p&gt;Do roadmaps actually help people learn? &lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I am not sure.&lt;/p&gt;
&lt;p&gt;Because the first time I saw one, it did the exact opposite.&lt;/p&gt;
&lt;p&gt;I remember opening a DevOps roadmap at roadmap.sh and seeing Linux, Networking, AWS, Docker, Terraform, Kubernetes, CI/CD, Security...&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xqfjplbs7un3oenuqh7s.png&quot; alt=&quot;Learning roadmap&quot;&gt;&lt;/p&gt;
&lt;p&gt;The list just kept going.&lt;/p&gt;
&lt;p&gt;Instead of feeling motivated, I felt overwhelmed.&lt;/p&gt;
&lt;p&gt;I closed all the tabs, shut down my laptop, and went to sleep.&lt;/p&gt;
&lt;p&gt;I am serious.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gyze85bwsagrbsafhi73.png&quot; alt=&quot;Shut-down&quot;&gt;&lt;/p&gt;
&lt;p&gt;It looked less like a learning path and more like a university degree.&lt;/p&gt;
&lt;p&gt;What made it worse was that I went back the next day and tried again.&lt;/p&gt;
&lt;p&gt;Same feeling.&lt;/p&gt;
&lt;p&gt;Surely there had to be a simpler version somewhere.&lt;/p&gt;
&lt;p&gt;So I did what most people do.&lt;/p&gt;
&lt;p&gt;I went looking for a better roadmap.&lt;/p&gt;
&lt;p&gt;Instead, I found more roadmaps with different layouts, orders, and opinions.&lt;/p&gt;
&lt;p&gt;One said to learn Docker before Kubernetes.&lt;/p&gt;
&lt;p&gt;Another said the opposite.&lt;/p&gt;
&lt;p&gt;One started with Python.&lt;/p&gt;
&lt;p&gt;Another skipped it entirely.&lt;/p&gt;
&lt;p&gt;I kept searching for the version that would finally make sense.&lt;/p&gt;
&lt;p&gt;What I found instead was more confusion.&lt;/p&gt;
&lt;p&gt;At some point, I stopped and asked myself an honest question.&lt;/p&gt;
&lt;p&gt;Was I actually learning anything?&lt;/p&gt;
&lt;p&gt;The answer was no.&lt;/p&gt;
&lt;p&gt;I was researching how to learn without doing any learning.&lt;/p&gt;
&lt;p&gt;Collecting roadmaps as if they were going to do the learning for me.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jfg43nihmb8tm7s0bp5j.png&quot; alt=&quot;Learning stack&quot;&gt;&lt;/p&gt;
&lt;p&gt;That realisation was uncomfortable.&lt;/p&gt;
&lt;p&gt;Because it meant the problem was not the roadmap.&lt;/p&gt;
&lt;p&gt;It was what I expected the roadmap to do.&lt;/p&gt;
&lt;p&gt;There are AWS concepts that took me weeks to understand.&lt;/p&gt;
&lt;p&gt;There are things I thought I understood until I tried using them.&lt;/p&gt;
&lt;p&gt;There are topics I ignored and had to come back to later.&lt;/p&gt;
&lt;p&gt;None of that ever shows up on a roadmap.&lt;/p&gt;
&lt;p&gt;The roadmap only shows the clean version of the story.&lt;/p&gt;
&lt;p&gt;The finished version.&lt;/p&gt;
&lt;p&gt;The version that makes sense after everything has already happened.&lt;/p&gt;
&lt;p&gt;Roadmaps show what a field looks like when you zoom out.&lt;/p&gt;
&lt;p&gt;They do not show what learning feels like when you are inside it.&lt;/p&gt;
&lt;p&gt;I still visit roadmap.sh.&lt;/p&gt;
&lt;p&gt;I still think roadmaps are useful.&lt;/p&gt;
&lt;p&gt;But I no longer expect them to remove the messy part of learning.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/h3hmos3ffp7w0e1w06la.png&quot; alt=&quot;Feasible/reality roadmap&quot;&gt;&lt;/p&gt;
&lt;p&gt;This roadmap feels real to me.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;If you enjoy reflections like this, you can subscribe to the &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/build-relation/newsletter-follow?entityUrn=7336744982432600064&quot;&gt;newsletter&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;You can follow my work and projects on &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ijaydimples&quot;&gt;Twitter&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/in/ijeoma-igboagu/&quot;&gt;LinkedIn&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ijayhub&quot;&gt;GitHub&lt;/a&gt;, and &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.youtube.com/@cloudinreallife&quot;&gt;YouTube&lt;/a&gt;, where I share what I am building and learning along the way.&lt;/p&gt;
&lt;p&gt;Thank you for reading.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/20091/the-reality-of-roadmaps</guid>
<pubDate>Tue, 30 Jun 2026 09:00:00 +0000</pubDate>
</item>
<item>
<title>Reqrea’s 1 Million Passport Exposure Is a Reminder Why Cloud Security Audits Matter</title>
<link>https://coderlegion.com/21594/reqreas-1-million-passport-exposure-is-a-reminder-why-cloud-security-audits-matter</link>
<description>&lt;p&gt;&lt;img src=&quot;featured-og.webp&quot; alt=&quot;1 Million Passport Exposure&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;incident&quot;&gt;Incident&lt;/h4&gt;
&lt;p&gt;On May 15, 2026, Reqrea, a Japan-based KYC company, reported that more than 1 million passports had been exposed to the public internet via a misconfigured S3 bucket.&lt;/p&gt;
&lt;p&gt;This breach impacted over 1 million travelers from around the world. Exposed passports could potentially put all impacted individuals at risk of identity theft or sophisticated social engineering attacks in the future.&lt;/p&gt;
&lt;p&gt;This recent incident further confirms my point in the past that AWS misconfigurations remain one of the top cybersecurity vulnerabilities in 2026.&lt;/p&gt;
&lt;h4 id=&quot;how-it-was-identified&quot;&gt;How it was identified&lt;/h4&gt;
&lt;p&gt;The vulnerability was discovered by security researcher Anurag Sen, who detected that one of the S3 buckets used by the company was accessible to the public without authentication or an authorization process.&lt;/p&gt;
&lt;h4 id=&quot;why-it-matters&quot;&gt;Why it matters&lt;/h4&gt;
&lt;p&gt;KYC platforms and companies handling PII are expected to have strong change control, security governance and regular security audits to prevent such costly mistakes from happening. In this instance, since Reqrea has collected such data, they may face regulatory, contractual, and reputational exposure.&lt;/p&gt;
&lt;p&gt;Unfortunately, such cases could potentially harm companies' reputation among customers and partners, which could hurt future sales.&lt;/p&gt;
&lt;p&gt;&amp;gt;[!NOTE]&lt;br&gt;
&amp;gt;The average data breach costs U.S. organizations about USD 10.22 million, according to IBM’s 2025 Cost of a Data Breach Report.&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=16414488604608112191&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;lesson-learned&quot;&gt;Lesson Learned&lt;/h4&gt;
&lt;p&gt;Companies handling PII need to adopt &quot;Secure by Design&quot; policy and have strong cloud governance if they want to use cloud solutions.&lt;/p&gt;
&lt;p&gt;A good preventive strategy would entail companies bringing in third parties (e.g. consultants) before going live or before getting involved in any activity involving protected data collection or transmission of such data.&lt;/p&gt;
&lt;p&gt;Another lesson learned is that delegating KYC to third parties may actually reduce security if the KYC platform itself is insecure.&lt;/p&gt;
&lt;p&gt;I can easily point out the fix needed for this vulnerability. However, we have to look at this incident or vulnerability rather as a question of governance and policies first and technical as second.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/21594/reqreas-1-million-passport-exposure-is-a-reminder-why-cloud-security-audits-matter</guid>
<pubDate>Mon, 29 Jun 2026 19:49:17 +0000</pubDate>
</item>
<item>
<title>IRSA(IAM Roles for Service Account) Explained</title>
<link>https://coderlegion.com/21425/irsa-iam-roles-for-service-account-explained</link>
<description>&lt;p&gt;One of the most common mistakes I see in Kubernetes environments is this:&lt;/p&gt;
&lt;p&gt;A pod needs access to AWS services, so teams simply attach permissions to the EC2 worker node and call it a day.&lt;/p&gt;
&lt;p&gt;It works.&lt;/p&gt;
&lt;p&gt;Until one compromised pod suddenly has access to everything the node can access.&lt;/p&gt;
&lt;p&gt;That’s when I started appreciating the beauty of IRSA (IAM Roles for Service Accounts) in Amazon EKS.&lt;/p&gt;
&lt;h3 id=&quot;what-is-irsa-&quot;&gt;What is IRSA?&lt;/h3&gt;
&lt;p&gt;IRSA enables Kubernetes pods running in Amazon EKS to securely access AWS services using dedicated IAM roles.&lt;/p&gt;
&lt;p&gt;Instead of storing AWS Access Keys inside Kubernetes Secrets or relying on broad node-level permissions , each workload can assume its own IAM role using a Kubernetes Service Account.&lt;/p&gt;
&lt;h3 id=&quot;how-does-it-work-&quot;&gt;How does it work?&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Create an IAM Role with required permissions&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Configure the EKS OIDC Provider&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Associate the IAM Role with a Kubernetes Service Account&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Deploy pods using that Service Account&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;AWS STS issues temporary credentials to the pod&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Pod accesses AWS services securely&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;No static credentials.&lt;br&gt;
No credential rotation headaches.&lt;br&gt;
No shared node permissions.&lt;/p&gt;
&lt;p&gt;Why IRSA Matters&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Eliminates AWS Access Keys from Kubernetes&lt;/li&gt;
&lt;li&gt;Enforces Least-Privilege Access&lt;/li&gt;
&lt;li&gt;Temporary Credentials issued by AWS STS&lt;/li&gt;
&lt;li&gt;Better Auditability with CloudTrail&lt;/li&gt;
&lt;li&gt;Reduced Blast Radius if a Pod is Compromised&lt;/li&gt;
&lt;li&gt;Each Workload Gets Its Own Identity&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;irsa-vs-node-iam-roles&quot;&gt;IRSA vs Node IAM Roles&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;h3 id=&quot;node-iam-roles&quot;&gt;Node IAM Roles&lt;/h3&gt;
&lt;p&gt;• All pods inherit node permissions&lt;br&gt;
• Difficult to isolate workloads&lt;br&gt;
• Larger attack surface&lt;br&gt;
• Harder to determine which workload accessed AWS resources&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;h3 id=&quot;irsa&quot;&gt;IRSA&lt;/h3&gt;
&lt;p&gt;• Per-workload IAM permissions&lt;br&gt;
• Better security boundaries&lt;br&gt;
• Improved compliance and auditing&lt;br&gt;
• Fine-grained access control&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;real-world-example-1&quot;&gt;Real-World Example&lt;/h2&gt;
&lt;p&gt;Imagine an EKS cluster running:&lt;/p&gt;
&lt;p&gt;Payment Service → Access to DynamoDB&lt;br&gt;
Reporting Service → Access to S3&lt;br&gt;
Notification Service → Access to SQS&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;With Node IAM Roles, all three services could potentially access all resources.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;With IRSA, each service receives only the permissions it actually needs.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That’s security done right.!!&lt;/p&gt;
&lt;p&gt;&quot;The Bigger Lesson&quot;&lt;/p&gt;
&lt;p&gt;Cloud security is moving away from: &quot;Long-lived credentials&quot; To  &quot;Identity-based access&quot;&lt;/p&gt;
&lt;p&gt;Whether it’s:&lt;/p&gt;
&lt;p&gt;• IRSA in EKS&lt;br&gt;
• OIDC in GitHub Actions&lt;br&gt;
• Workload Identity in GCP&lt;br&gt;
• Managed Identities in Azure&lt;/p&gt;
&lt;p&gt;The future is the same:&lt;/p&gt;
&lt;p&gt;&quot;Stop distributing secrets. Start distributing identities.&quot;&lt;/p&gt;
&lt;p&gt;How are you managing AWS access for Kubernetes workloads today?&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/21425/irsa-iam-roles-for-service-account-explained</guid>
<pubDate>Fri, 26 Jun 2026 20:43:15 +0000</pubDate>
</item>
</channel>
</rss>