<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Victor Ojeje</title>
    <description>The latest articles on DEV Community by Victor Ojeje (@escanut).</description>
    <link>https://dev.to/escanut</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3717092%2Feb83272c-b400-4201-93bc-c3d414522d38.jpeg</url>
      <title>DEV Community: Victor Ojeje</title>
      <link>https://dev.to/escanut</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/escanut"/>
    <language>en</language>
    <item>
      <title>I Passed AWS SAA-C03. Here's What Actually Mattered.</title>
      <dc:creator>Victor Ojeje</dc:creator>
      <pubDate>Fri, 03 Apr 2026 21:16:46 +0000</pubDate>
      <link>https://dev.to/escanut/i-passed-aws-saa-c03-heres-what-actually-mattered-4n6n</link>
      <guid>https://dev.to/escanut/i-passed-aws-saa-c03-heres-what-actually-mattered-4n6n</guid>
      <description>&lt;p&gt;I sat the SAA-C03 recently and passed. Before I forget what actually worked, here's the honest breakdown.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The exam is not a memory test&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;AWS publishes the exam guide openly. Four domains, weighted percentages: Secure Architectures is 30%, Resilient Architectures 26%, High-Performing 24%, Cost-Optimized 20%. The questions drop you inside a scenario with a compliance constraint, a budget ceiling, or a failure condition, then ask you to pick the right architecture.&lt;/p&gt;

&lt;p&gt;If you study by memorising service features, you will fail on the scenario questions because you have never practiced making the decision. I knew this going in because I had already built a fraud detection pipeline with SQS, Lambda, DynamoDB, and SNS. When a question asked me to choose between Lambda and Fargate for a PCI-constrained workload, I had a real reference point. That matters more than any flashcard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tutorials Dojo was the core resource, not a supplement&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I used Jon Bonso's practice exams as the primary study mechanism, not a final check before sitting. The reason it works is the explanations. When you get a question wrong, it does not just show you the correct answer. It tells you exactly why each wrong answer is wrong.&lt;/p&gt;

&lt;p&gt;That distinction is the whole exam. You are not picking the right option out of five. You are eliminating three plausible options. If you do not understand why the wrong ones fail, you will keep making the same mistakes on different question wording.&lt;/p&gt;

&lt;p&gt;Community benchmark on Tutorials Dojo is consistent 80% before sitting the real exam. I used that as my gate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every wrong answer became an architecture pattern&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When I got a question wrong I did not just note the correct service. I mapped the whole scenario.&lt;/p&gt;

&lt;p&gt;Wrong on a DynamoDB composite key question? That becomes a working mental model for when a Global Secondary Index is the actual fix versus just restructuring the partition key. Wrong on an S3 lifecycle transition? That maps to the 30-day minimum billing rule for Standard-IA and why an aggressive lifecycle policy can cost more than doing nothing and oh boy does the exam love S3.&lt;/p&gt;

&lt;p&gt;You build a schema that generalises to questions you have never seen. That is the only way to handle the 65 questions in 130 minutes without running out of time on edge cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Breadth over depth, done deliberately&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;SAA-C03 tests roughly 35 to 40 services with meaningful depth. Going deep on everything is a waste. Going shallow on the high-weight domains is how people fail.&lt;/p&gt;

&lt;p&gt;Secure Architectures is 30% of the exam. If you cannot immediately distinguish when to use a Customer Managed KMS key versus an AWS Managed Key for an audit requirement, you are losing points on the biggest single domain. I spent more time on IAM policies, KMS, VPC endpoints, and CloudTrail than on anything else, specifically because the exam weights said to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I would skip next time&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I spent too long on ECS versus EKS distinctions early on and very hyper focused portions of services like lambda, Eventbridge and Config for example. That is not where the exam goes. The exam goes into architectural tradeoffs including Multi-AZ RDS versus Read Replicas versus Aurora Global Database. That type of decision appears repeatedly.&lt;/p&gt;




&lt;p&gt;If you're preparing for SAA-C03, start with Tutorials Dojo's AWS Certified Solutions Architect Associate Practice Exams from day one, not after you've finished a course. The feedback loop from testing yourself early is faster than any other method.&lt;/p&gt;

&lt;p&gt;Follow me on Reddit: &lt;a href="https://reddit.com/user/Escanut/" rel="noopener noreferrer"&gt;reddit.com/user/Escanut/&lt;/a&gt; &lt;br&gt;
GitHub: &lt;a href="https://github.com/escanut" rel="noopener noreferrer"&gt;github.com/escanut&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cloud</category>
      <category>devops</category>
      <category>certification</category>
    </item>
    <item>
      <title>How I Built a PCI-Ready Merchant Onboarding API on AWS for Under $5/Month</title>
      <dc:creator>Victor Ojeje</dc:creator>
      <pubDate>Sun, 22 Mar 2026 17:40:23 +0000</pubDate>
      <link>https://dev.to/escanut/how-i-built-a-pci-ready-merchant-onboarding-api-on-aws-for-under-5month-4958</link>
      <guid>https://dev.to/escanut/how-i-built-a-pci-ready-merchant-onboarding-api-on-aws-for-under-5month-4958</guid>
      <description>&lt;p&gt;Payment processors get rejected from enterprise contracts  because it wasn't auditable. PCI DSS cares whether every data access event is logged, every record is encrypted with a key you control, and whether you can recover a merchant record to a specific second if a dispute arises.&lt;/p&gt;

&lt;p&gt;This post walks through a serverless merchant onboarding API built to those standards, with full cost transparency.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Solves
&lt;/h2&gt;

&lt;p&gt;A payment processor needs to register merchants: collect business details, store KYC documents, and expose that data to internal admin systems. Straightforward on the surface. The compliance overhead is where most implementations fall apart:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PCI DSS Requirement 3.5.1&lt;/strong&gt; — stored data must be encrypted with a key the organization controls, not a cloud-provider default&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PCI DSS Requirement 8&lt;/strong&gt; — every API call must be authenticated and tied to an identity&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PCI DSS Requirement 10&lt;/strong&gt; — every data access event must produce audit evidence&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Building infra that can survive a compliance audit without emergency rework is the goal to strive for.&lt;/p&gt;




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

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

&lt;p&gt;The full flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Merchant authenticates with email and password via &lt;strong&gt;Amazon Cognito&lt;/strong&gt;, receives a JWT (JSON Web Token)&lt;/li&gt;
&lt;li&gt;Merchant calls &lt;strong&gt;API Gateway&lt;/strong&gt; with that JWT in the &lt;code&gt;Authorization&lt;/code&gt; header&lt;/li&gt;
&lt;li&gt;API Gateway validates the JWT against Cognito ensuring Lambda never receives an unauthenticated request&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lambda&lt;/strong&gt; processes the request and creates merchant profile in DynamoDB, stores KYC documents in S3 if present&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DynamoDB&lt;/strong&gt; stores profiles encrypted at rest with a customer-managed KMS key, PITR enabled&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;S3&lt;/strong&gt; stores KYC documents encrypted with SSE-KMS, Bucket Key enabled&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CloudTrail&lt;/strong&gt; captures every API call, Lambda invocation, and DynamoDB operation as mandated by PCI DSS Requirement 10 &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CloudWatch&lt;/strong&gt; monitors operational metrics&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Assumptions and Traffic Model
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Basis&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Monthly Active Users (MAU)&lt;/td&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;Initial operating scale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Projected new merchants/month&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;Growth estimate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DynamoDB writes/month&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;One write per new merchant registration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DynamoDB reads/month&lt;/td&gt;
&lt;td&gt;330,000&lt;/td&gt;
&lt;td&gt;10,000 users × 3 reads + 500 new records × 20 reads × 30 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 PUT requests/month&lt;/td&gt;
&lt;td&gt;2,000&lt;/td&gt;
&lt;td&gt;500 merchants × 4 KYC documents each&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 GET requests/month&lt;/td&gt;
&lt;td&gt;6,000&lt;/td&gt;
&lt;td&gt;2,000 PUTs × 3 retrieval average&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API Gateway + Lambda requests&lt;/td&gt;
&lt;td&gt;330,500&lt;/td&gt;
&lt;td&gt;Matches read/write totals&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;They were derived from real behavioral assumptions: how often a merchant logs in, how often admins pull records, document retrieval patterns.&lt;/p&gt;




&lt;h2&gt;
  
  
  Security Decisions and Why They Were Made
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. JWT Validation at API Gateway, Not Lambda
&lt;/h3&gt;

&lt;p&gt;Authentication is enforced at the API Gateway layer using a Cognito authorizer. This means Lambda is never invoked for an unauthenticated request meaning there is no compute cost and no attack surface for unauthenticated traffic.&lt;/p&gt;

&lt;p&gt;If you push JWT validation into Lambda, you pay for Lambda invocations even for rejected requests, and you've moved your authentication boundary inward, increasing blast radius.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Customer-Managed KMS Key on DynamoDB (Not AWS-Owned)
&lt;/h3&gt;

&lt;p&gt;The DynamoDB table uses a customer-managed KMS key&lt;/p&gt;

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

&lt;p&gt;AWS-Owned Keys (the free default) fail PCI DSS Requirement 3.5.1 for a specific reason: you cannot produce key usage evidence for an auditor because you have no visibility into key operations. CloudTrail does not log AWS-Owned Key usage. Customer-managed keys generate CloudTrail entries on every encrypt and decrypt operation. That's the audit evidence.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. S3 SSE-KMS with Bucket Key Enabled
&lt;/h3&gt;

&lt;p&gt;KYC documents in S3 are encrypted with SSE-KMS. Bucket Key is enabled, which reduces the number of KMS API calls by batching envelope encryption at the bucket level rather than generating a unique data key per object.&lt;/p&gt;

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

&lt;p&gt;Without Bucket Key, every S3 object PUT and GET generates a KMS API call. At 8,000 S3 requests/month, that's 8,000 additional KMS calls on top of DynamoDB operations. Bucket Key collapses that significantly.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Point-in-Time Recovery (PITR) on DynamoDB
&lt;/h3&gt;

&lt;p&gt;PITR is enabled on the merchants table. This provides continuous backups with one-second granularity for the last 35 days.&lt;/p&gt;

&lt;p&gt;In a payment dispute or fraud investigation, you may need to prove what a merchant record looked like at a specific timestamp. Without PITR, you either rebuild from CloudTrail (tedious) or you can't answer the question. PITR makes this a 30-second console operation.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. GSI for Read Efficiency
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;GET /merchants&lt;/code&gt; endpoint (admin listing) queries a Global Secondary Index (GSI) called &lt;code&gt;entity-type-index&lt;/code&gt; rather than scanning the full table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;query_kwargs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;IndexName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;GSI_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;KeyConditionExpression&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entity_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MERCHANT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ScanIndexForward&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;DynamoDB Scan reads every item in the table regardless of what you need. At scale, that becomes expensive. A GSI query reads only matching items. With 300,000+ reads per month, using Scan instead of a GSI query would multiply your read capacity consumption by the full table size factor.&lt;/p&gt;

&lt;p&gt;Cursor-based pagination is implemented using &lt;code&gt;ExclusiveStartKey&lt;/code&gt; and base64-encoded tokens, which is the correct DynamoDB pagination pattern.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Duplicate Registration Prevention at the Database Level
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Item&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ConditionExpression&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attribute_not_exists(cac_number)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A merchant's CAC (Corporate Affairs Commission) registration number is unique to their business. This condition expression makes DynamoDB reject the write if a record with that CAC number already exists. This is enforced at the storage layer, not in application logic, so it cannot be bypassed by a Lambda bug or a parallel request race condition.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. CloudTrail for PCI DSS Requirement 10
&lt;/h3&gt;

&lt;p&gt;CloudTrail is configured to log management events and Lambda data events. Every &lt;code&gt;PutItem&lt;/code&gt;, &lt;code&gt;GetItem&lt;/code&gt;, and &lt;code&gt;Query&lt;/code&gt; operation on the merchants table appears in CloudTrail. Every S3 object PUT and GET is logged.&lt;/p&gt;

&lt;p&gt;PCI DSS Requirement 10.2 specifies the exact event types that must be logged: user access to cardholder data, invalid logical access attempts, use of privilege escalation mechanisms. CloudTrail at this configuration covers all of them.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Full Lambda Function
&lt;/h2&gt;

&lt;p&gt;This single function handles all three API routes. Every design decision in the security section above maps directly to something in this code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;boto3.dynamodb.conditions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt;

&lt;span class="n"&gt;dynamodb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dynamodb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;TABLE_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TABLE_NAME&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merchants&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dynamodb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TABLE_NAME&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# GSI (Global Secondary Index) used for GET /merchants — avoids a full table
# Scan which reads every item regardless of what you need. At 300,000 admin
# reads/month, Scan would multiply read capacity consumption by the full
# table size factor.
&lt;/span&gt;&lt;span class="n"&gt;GSI_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entity-type-index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;DEFAULT_PAGE_SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;
&lt;span class="n"&gt;MAX_PAGE_SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
&lt;span class="n"&gt;EMAIL_REGEX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;^[\w\.-]+@[\w\.-]+\.\w+$&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;statusCode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;headers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_post_body&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;business_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cac_number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contact_email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Missing or empty fields: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contact_email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;EMAIL_REGEX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invalid contact_email format&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="c1"&gt;# CAC = Corporate Affairs Commission registration number (Nigeria).
&lt;/span&gt;    &lt;span class="c1"&gt;# Exactly 6 digits — validated here before it ever touches DynamoDB.
&lt;/span&gt;    &lt;span class="n"&gt;cac&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cac_number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;cac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isdigit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cac&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cac_number must be exactly 6 digits&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;business_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;business_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cac_number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contact_email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;post_merchant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Request body must be valid JSON&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="n"&gt;validated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;validate_post_body&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="n"&gt;merchant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;created_date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merchant_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;merchant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;created_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;created_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entity_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MERCHANT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;business_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;validated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;business_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cac_number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;validated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cac_number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contact_email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;validated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contact_email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# ConditionExpression enforces uniqueness at the storage layer.
&lt;/span&gt;    &lt;span class="c1"&gt;# If a record with this CAC number already exists, DynamoDB rejects
&lt;/span&gt;    &lt;span class="c1"&gt;# the write with ConditionalCheckFailedException — no application-layer
&lt;/span&gt;    &lt;span class="c1"&gt;# race condition can bypass this.
&lt;/span&gt;    &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Item&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ConditionExpression&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attribute_not_exists(cac_number)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merchant_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;merchant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;created_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;created_date&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_all_merchants&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;queryStringParameters&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DEFAULT_PAGE_SIZE&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="n"&gt;MAX_PAGE_SIZE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;limit must be an integer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="n"&gt;query_kwargs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;IndexName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;GSI_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;KeyConditionExpression&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entity_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MERCHANT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ScanIndexForward&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# newest first
&lt;/span&gt;    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# Cursor-based pagination using DynamoDB's ExclusiveStartKey.
&lt;/span&gt;    &lt;span class="c1"&gt;# The cursor is base64-encoded JSON of the last evaluated key — safe
&lt;/span&gt;    &lt;span class="c1"&gt;# to pass to clients without exposing internal DynamoDB key structure.
&lt;/span&gt;    &lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cursor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;exclusive_start_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;query_kwargs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ExclusiveStartKey&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;exclusive_start_key&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invalid cursor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;query_kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;next_cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LastEvaluatedKey&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;next_cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LastEvaluatedKey&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merchants&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;next_cursor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;next_cursor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_merchant_by_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;merchant_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;KeyConditionExpression&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merchant_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;merchant_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;Limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Merchant not found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;


&lt;span class="c1"&gt;# Route table — static dispatch is faster than regex matching at Lambda
# invocation frequency and easier to read during a code review.
&lt;/span&gt;&lt;span class="n"&gt;ROUTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/merchants&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;post_merchant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/merchants&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;get_all_merchants&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lambda_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;method&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;httpMethod&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;path_params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pathParameters&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ROUTES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ROUTES&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)](&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Dynamic route: /merchants/{id}
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;path_params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;get_merchant_by_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path_params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Route not found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things worth noting in this code that matter for regulated environments:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Input validation happens before any AWS SDK call.&lt;/strong&gt; Email format, CAC number format, and required field presence are all checked before Lambda touches DynamoDB. Malformed requests never generate KMS decrypt operations, which keeps the KMS call count accurate to your cost model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;entity_type: "MERCHANT"&lt;/code&gt; is set by the server, not the client.&lt;/strong&gt; The GSI partition key cannot be spoofed by a bad actor sending a crafted payload. If it were client-supplied, a request sending &lt;code&gt;entity_type: "ADMIN"&lt;/code&gt; could pollute the index.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The route table (&lt;code&gt;ROUTES&lt;/code&gt; dict) replaces a chain of if/elif checks.&lt;/strong&gt; At 330,500 invocations per month this is not a performance concern, but during a code review at a financial institution, explicit dispatch tables are easier to audit than nested conditionals.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Authentication Was Tested
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Generate SECRET_HASH (required for Cognito app clients with secret)&lt;/span&gt;
import hmac, hashlib, &lt;span class="nb"&gt;base64
&lt;/span&gt;message &lt;span class="o"&gt;=&lt;/span&gt; username + client_id
secret_hash &lt;span class="o"&gt;=&lt;/span&gt; base64.b64encode&lt;span class="o"&gt;(&lt;/span&gt;
    hmac.new&lt;span class="o"&gt;(&lt;/span&gt;client_secret.encode&lt;span class="o"&gt;()&lt;/span&gt;, message.encode&lt;span class="o"&gt;()&lt;/span&gt;, hashlib.sha256&lt;span class="o"&gt;)&lt;/span&gt;.digest&lt;span class="o"&gt;()&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt;.decode&lt;span class="o"&gt;()&lt;/span&gt;

&lt;span class="c"&gt;# Get JWT from Cognito&lt;/span&gt;
aws cognito-idp initiate-auth &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--client-id&lt;/span&gt; 3einfu39fvd2opcoldicn0fh2q &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--auth-flow&lt;/span&gt; USER_PASSWORD_AUTH &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--auth-parameters&lt;/span&gt; &lt;span class="nv"&gt;USERNAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;username&amp;gt;,PASSWORD&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;password&amp;gt;,SECRET_HASH&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;&lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;# Call the API with the JWT&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://93z9qxnyv4.execute-api.us-east-1.amazonaws.com/prod/merchants &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: &amp;lt;IdToken&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"business_name": "Jiji", "cac_number": "128957", "contact_email": "jiji@example.com"}'&lt;/span&gt;

&lt;span class="c"&gt;# List all merchants&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; GET https://93z9qxnyv4.execute-api.us-east-1.amazonaws.com/prod/merchants &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: &amp;lt;IdToken&amp;gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Retrieve a specific merchant&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; GET https://93z9qxnyv4.execute-api.us-east-1.amazonaws.com/prod/merchants/01fcd787-47d1-4db5-a396-f2adcf0c3e18 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: &amp;lt;IdToken&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Requests without a valid JWT are rejected at the API Gateway layer before Lambda is invoked.&lt;/p&gt;




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

&lt;blockquote&gt;
&lt;p&gt;Source: AWS Pricing Calculator export, 03/22/2026, us-east-1. 10,000 MAU, 330,500 requests/month.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Monthly Cost&lt;/th&gt;
&lt;th&gt;% of Bill&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;KMS&lt;/td&gt;
&lt;td&gt;$2.01&lt;/td&gt;
&lt;td&gt;41.3%&lt;/td&gt;
&lt;td&gt;669,000 symmetric API calls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API Gateway&lt;/td&gt;
&lt;td&gt;$1.16&lt;/td&gt;
&lt;td&gt;23.8%&lt;/td&gt;
&lt;td&gt;330,500 requests, avg 34KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lambda&lt;/td&gt;
&lt;td&gt;$0.76&lt;/td&gt;
&lt;td&gt;15.6%&lt;/td&gt;
&lt;td&gt;330,500 invocations, 512MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudTrail&lt;/td&gt;
&lt;td&gt;$0.34&lt;/td&gt;
&lt;td&gt;7.0%&lt;/td&gt;
&lt;td&gt;Lambda data events at volume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudWatch&lt;/td&gt;
&lt;td&gt;$0.32&lt;/td&gt;
&lt;td&gt;6.6%&lt;/td&gt;
&lt;td&gt;0.63GB log ingestion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DynamoDB&lt;/td&gt;
&lt;td&gt;$0.24&lt;/td&gt;
&lt;td&gt;4.9%&lt;/td&gt;
&lt;td&gt;On-demand, PITR, KMS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3&lt;/td&gt;
&lt;td&gt;$0.04&lt;/td&gt;
&lt;td&gt;0.8%&lt;/td&gt;
&lt;td&gt;1GB storage, 8,000 requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cognito&lt;/td&gt;
&lt;td&gt;$0.00&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;td&gt;10,000 MAU = within free tier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$4.87/month&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;What most engineers miss about this bill:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;KMS is 41% of the monthly cost at this scale. The moment you add customer-managed encryption to DynamoDB and S3 (which PCI DSS requires), KMS becomes the dominant cost driver. Every DynamoDB read and write generates KMS API calls. Without Bucket Key on S3, every object operation would add to that count.&lt;/p&gt;

&lt;p&gt;At 10,000 MAU this is $4.87/month. At 100,000 MAU, KMS call volume scales roughly linearly with request volume. Planning for this in the initial architecture prevents a compliance requirement from arriving as a surprise bill.&lt;/p&gt;

&lt;p&gt;Cognito at this scale is free. The first 10,000 MAU are within the AWS free tier perpetually.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Breaks at Scale
&lt;/h2&gt;

&lt;p&gt;This architecture is appropriate for the stated scale. Three things need revisiting before 10x growth:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. CloudTrail cost at high Lambda data event volume&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
CloudTrail logs Lambda data events at $0.10 per 100,000 events. At 330,500 Lambda invocations, that's $0.34/month. At 3.3M invocations, it's $3.30/month. At 33M, $33/month. Worth modelling before reaching that range.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. GSI read capacity under admin query load&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
300,000 admin reads per month assumes 20 reads per admin per day. If admin tooling increases query frequency, the GSI read cost scales. Add DynamoDB DAX (DynamoDB Accelerator) at that point, DAX adds $150+/month at minimum instance size.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. KMS request limits&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
KMS has a default quota of 5,500 symmetric requests per second in us-east-1. At the traffic levels modelled here, this is not a constraint. At high-throughput payment processing scale, it becomes one. Request a quota increase before hitting the ceiling, not after.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Architecture Over EC2-Based Alternatives
&lt;/h2&gt;

&lt;p&gt;The comparable EC2 architecture (t3.micro + RDS + ALB) runs approximately $50-80/month before adding KMS, CloudTrail, and WAF. Serverless brings this to $4.87/month at 10,000 MAU.&lt;/p&gt;

&lt;p&gt;More importantly: there are no servers to patch. A common PCI DSS finding in EC2-based environments is unpatched OS vulnerabilities. Lambda eliminates that finding category entirely with AWS managing the runtime, and the function execution environment is ephemeral.&lt;/p&gt;

&lt;p&gt;The tradeoff is cold starts. Lambda cold starts for Python average 100-300ms. For a merchant onboarding API (not a real-time payment path), this is acceptable. For a transaction authorization endpoint, it is not.&lt;/p&gt;




&lt;h2&gt;
  
  
  Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Amazon Cognito (authentication, JWT issuance)&lt;/li&gt;
&lt;li&gt;Amazon API Gateway (JWT validation, request routing)&lt;/li&gt;
&lt;li&gt;AWS Lambda (Python 3.14, business logic)&lt;/li&gt;
&lt;li&gt;Amazon DynamoDB (merchant profiles, SSE-KMS, PITR, on-demand)&lt;/li&gt;
&lt;li&gt;Amazon S3 (KYC documents(for demonstration), SSE-KMS, Bucket Key, versioning)&lt;/li&gt;
&lt;li&gt;AWS CloudTrail (audit logging, PCI DSS Requirement 10)&lt;/li&gt;
&lt;li&gt;Amazon CloudWatch (operational monitoring)&lt;/li&gt;
&lt;li&gt;AWS KMS (customer-managed encryption keys)&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If you're hiring for Cloud Engineer, DevOps, or SRE roles with infrastructure security depth, I'm open to remote opportunities.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.linkedin.com/in/victorojeje/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; | &lt;a href="https://github.com/escanut" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="mailto:ojejevictor@gmail.com"&gt;ojejevictor@gmail.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>security</category>
      <category>cloud</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Built a Serverless Fraud Detection Pipeline on AWS. Here's What It Actually Costs to Do It Right.</title>
      <dc:creator>Victor Ojeje</dc:creator>
      <pubDate>Tue, 17 Mar 2026 22:19:14 +0000</pubDate>
      <link>https://dev.to/escanut/i-built-a-serverless-fraud-detection-pipeline-on-aws-heres-what-it-actually-costs-to-do-it-right-5hd7</link>
      <guid>https://dev.to/escanut/i-built-a-serverless-fraud-detection-pipeline-on-aws-heres-what-it-actually-costs-to-do-it-right-5hd7</guid>
      <description>&lt;p&gt;Most cloud portfolio projects look like this: spin up an EC2 instance, deploy a web app, take a screenshot. Done.&lt;/p&gt;

&lt;p&gt;That tells a hiring manager you can follow a tutorial.&lt;br&gt;
This is not that.&lt;/p&gt;

&lt;p&gt;I built a real-time transaction screening pipeline modeled after how financial institutions actually handle suspicious activity routing.&lt;br&gt;
The entire processing backbone costs $2.48/month to run at 500 000 transactions.&lt;br&gt;
Here is how it works, why every decision was made, and what it actually costs to secure it properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem This Solves
&lt;/h2&gt;

&lt;p&gt;Payment processors and banks screen every transaction before it clears. A transaction above a defined threshold, or matching a suspicious pattern, needs to be flagged, stored, and routed to an analyst in near real-time. Latency here is not a UX problem. It is a compliance and fraud exposure problem.&lt;br&gt;
The architecture needs to handle three things without failing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Ingest transactions reliably, even under burst load&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Evaluate and persist every transaction regardless of outcome&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Alert immediately when a transaction crosses the threshold, with zero alert loss&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A traditional EC2-based approach handles this with always-on servers. You pay for compute whether transactions are flowing or not. You manage patching, availability zones, and process monitoring yourself.&lt;/p&gt;

&lt;p&gt;The serverless approach inverts this entirely.&lt;/p&gt;

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

&lt;p&gt;Every transaction enters through SQS. Lambda processes each message, writes the full record to DynamoDB regardless of whether it is flagged, and fires an SNS alert only if the amount exceeds the threshold. If Lambda fails to process a message three times, SQS routes it to a Dead Letter Queue for manual investigation. Nothing is silently dropped.&lt;/p&gt;

&lt;p&gt;Lambda was deployed inside a VPC across two availability zones. All connections to SQS, DynamoDB, SNS, and CloudWatch run through VPC interface and gateway endpoints. No traffic touches the public internet at any point in the pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lambda Function
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;decimal&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;

&lt;span class="n"&gt;dyn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;dynamodb&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;sns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sns&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;TABLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TABLE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fraud-detections&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;SNS_ARN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SNS_ARN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arn:aws:sns:us-east-1:461840362463:fraud-alerts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Threshold represents configurable screening rule for the institution
&lt;/span&gt;&lt;span class="n"&gt;THRESHOLD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500000&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lambda_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Records&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;txn_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transaction_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
        &lt;span class="n"&gt;merchant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merchant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

        &lt;span class="n"&gt;flagged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;THRESHOLD&lt;/span&gt;

        &lt;span class="n"&gt;dyn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TABLE&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;put_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;Item&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transaction_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;txn_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merchant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;merchant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;flagged&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flagged&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;flagged&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;sns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;TopicArn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SNS_ARN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Suspicious transfer Flagged: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;txn_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; — NGN &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; — &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;merchant&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;Subject&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Fraud Alert&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;statusCode&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every transaction is written to DynamoDB first, before the flag check. This matters. In a real system you need a complete audit trail. A transaction that does not trigger an alert is not necessarily clean. It needs to exist in the record. Regulators do not accept gaps.&lt;/p&gt;

&lt;p&gt;The threshold is a variable, not a hardcoded business rule. In production this would be pulled from a configuration store or Parameter Store, allowing compliance teams to adjust screening rules without a code deployment.&lt;/p&gt;

&lt;p&gt;Decimal is used instead of float for the amount. DynamoDB does not accept Python floats. Using float here causes silent precision errors on large transaction amounts. This is a real production bug that shows up in systems built by engineers who have not actually handled financial data before.&lt;/p&gt;

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

&lt;p&gt;Two transactions were sent through the pipeline.&lt;/p&gt;

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

&lt;p&gt;TXN-001 crossed the NGN 500,000 threshold. The SNS alert fired immediately to email with the subject line "Fraud Alert" and the full transaction details in the message body.&lt;br&gt;
TXN-002 was written to DynamoDB with flagged: false. No alert sent. Complete audit record preserved.&lt;/p&gt;

&lt;p&gt;Both records confirmed live in DynamoDB&lt;/p&gt;

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

&lt;p&gt;Email alert received from SNS Topic.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Actually Costs
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Monthly Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AWS Lambda (500k requests)&lt;/td&gt;
&lt;td&gt;$1.14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon SQS (1.5M requests)&lt;/td&gt;
&lt;td&gt;$0.60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon DynamoDB (1GB)&lt;/td&gt;
&lt;td&gt;$0.56&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon SNS (10k alerts)&lt;/td&gt;
&lt;td&gt;$0.18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pipeline Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$2.48&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VPC Interface Endpoints (3x)&lt;/td&gt;
&lt;td&gt;$43.81&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Secured Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$46.29/month&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pipeline itself costs $2.48/month to process 500,000 transactions.&lt;/p&gt;

&lt;p&gt;The VPC endpoints cost $43.81/month. &lt;br&gt;
That is 94.6% of the total bill for a security control, not compute.&lt;/p&gt;

&lt;p&gt;This is an intentional tradeoff. Without VPC endpoints, Lambda communicates with SQS, DynamoDB, SNS, and CloudWatch over the public internet. In a financial context that is not a configuration choice. It is an audit finding. VPC endpoints keep all traffic private within the AWS network, satisfy network isolation requirements common in PCI-DSS and SOC 2 environments, and eliminate the data exfiltration risk that comes with public endpoint exposure.&lt;/p&gt;

&lt;p&gt;If this were a cost-only conversation, you would skip the endpoints and save $43.81/month. In a production fintech environment, that decision gets flagged in the first security review.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Is Not
&lt;/h2&gt;

&lt;p&gt;This pipeline has a single threshold rule. Production fraud detection at scale uses ML-based anomaly scoring, velocity checks across merchant categories, device fingerprinting, and graph-based relationship analysis. Those are layered on top of an event-driven backbone exactly like this one.&lt;/p&gt;

&lt;p&gt;This is the infrastructure layer. It is the part that has to work before any model or rule engine gets plugged in. Getting this layer wrong means no amount of ML sophistication above it recovers cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Serverless for This
&lt;/h2&gt;

&lt;p&gt;Three concrete reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Burst handling without pre-provisioning. Payment transaction volume is not linear. End-of-month salary runs, Black Friday, public holiday spikes. Lambda scales to concurrency automatically. An EC2-based system requires capacity planning or auto-scaling lag measured in minutes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;No idle cost. An EC2 t3.micro running 24/7 costs approximately $7.59/month before you add storage, monitoring, or patching overhead. Lambda at 500,000 transactions costs $1.14/month. At zero transactions it costs nothing.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Operational surface reduced to code. There is no OS to patch, no SSH access to harden, no process monitor to configure. The attack surface is the IAM role and the function code. Both are auditable and version-controlled.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Carries Into Production
&lt;/h2&gt;

&lt;p&gt;The patterns here are not demo-specific:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;SQS as a durable ingest buffer decouples transaction producers from the processing layer. A downstream Lambda failure does not lose the transaction.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;DLQ after three failures means no silent message loss. Every failed transaction is recoverable.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;VPC-private connectivity means the pipeline satisfies network isolation requirements out of the box.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;IAM roles scoped to least privilege mean Lambda cannot touch anything outside its defined resource set.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are the same architectural decisions you find in production payment infrastructure. The scale is different. The patterns are not.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Victor Ojeje is a Cloud and Infrastructure Engineer based in Lagos. He builds production-grade AWS infrastructure with a focus on security, automation, and cost-conscious design.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;LinkedIn: &lt;a href="//linkedin.com/in/victorojeje"&gt;linkedin.com/in/victorojeje&lt;/a&gt; | GitHub: &lt;a href="//github.com/escanut]"&gt;github.com/escanut&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>serverless</category>
      <category>cloudcomputing</category>
      <category>cybersecurity</category>
    </item>
    <item>
      <title>How I went from Docker Compose to production EKS without burning AWS budget on mistakes</title>
      <dc:creator>Victor Ojeje</dc:creator>
      <pubDate>Wed, 18 Feb 2026 06:12:33 +0000</pubDate>
      <link>https://dev.to/escanut/how-i-went-from-docker-compose-to-production-eks-without-burning-aws-budget-on-mistakes-1aio</link>
      <guid>https://dev.to/escanut/how-i-went-from-docker-compose-to-production-eks-without-burning-aws-budget-on-mistakes-1aio</guid>
      <description>&lt;h2&gt;
  
  
  The Problem with Learning Kubernetes Directly on AWS
&lt;/h2&gt;

&lt;p&gt;Most tutorials for Kubernetes on AWS tell you to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Spin up an EKS cluster
&lt;/li&gt;
&lt;li&gt;Apply your manifests
&lt;/li&gt;
&lt;li&gt;Debug why nothing works
&lt;/li&gt;
&lt;li&gt;Watch your bill climb while you figure it out
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;EKS charges &lt;strong&gt;$0.10 per hour&lt;/strong&gt; just for the control plane, before a single EC2 node is added. That is about $72/month for a cluster sitting idle while you troubleshoot ingress routing, secret injection failures, and container networking issues.&lt;/p&gt;

&lt;p&gt;That is fine for a company budget. It hurts when you are learning on your own.&lt;/p&gt;

&lt;p&gt;There is a better workflow. Test locally, prove it works, then pay for AWS.&lt;/p&gt;




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

&lt;p&gt;A production-grade containerized web application with a staged deployment workflow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stage 1:&lt;/strong&gt; Docker Compose for fast local iteration
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stage 2:&lt;/strong&gt; Minikube to validate Kubernetes behavior before touching AWS
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stage 3:&lt;/strong&gt; EKS with full CI/CD, AWS Secrets Manager, and automated ALB provisioning
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;App repo:&lt;/strong&gt; &lt;a href="https://github.com/escanut/fastapi-k8s-project" rel="noopener noreferrer"&gt;https://github.com/escanut/fastapi-k8s-project&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Infra repo:&lt;/strong&gt; &lt;a href="https://github.com/escanut/fastapi-aws-infra" rel="noopener noreferrer"&gt;https://github.com/escanut/fastapi-aws-infra&lt;/a&gt;  &lt;/p&gt;

&lt;p&gt;The app is a FastAPI backend with async request handling and connection pooling connected to PostgreSQL, plus a vanilla JS frontend. Simple product catalog with create, retrieve, delete items. Nothing fancy on purpose. The focus is the infrastructure pattern and delivery workflow.&lt;/p&gt;


&lt;h2&gt;
  
  
  Stage 1: Docker Compose
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Goal:&lt;/strong&gt; instant feedback on code changes with zero cluster overhead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:15-alpine&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;admin&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;products"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

  &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./backend&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DB_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./backend:/app&lt;/span&gt;

  &lt;span class="na"&gt;frontend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:alpine&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./nginx.conf:/etc/nginx/conf.d/default.conf&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nginx routes /api traffic to FastAPI. Credentials are plain env variables here, intentionally different from production. That contrast is the lesson.&lt;/p&gt;

&lt;p&gt;Validates: app logic, queries, routing, frontend-backend communication.&lt;br&gt;
Does not validate: Kubernetes behavior.&lt;/p&gt;
&lt;h2&gt;
  
  
  Stage 2: Minikube
&lt;/h2&gt;

&lt;p&gt;Goal: catch Kubernetes failures at zero cost before provisioning AWS.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;minikube start
&lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;minikube docker-env&lt;span class="si"&gt;)&lt;/span&gt;
minikube addons &lt;span class="nb"&gt;enable &lt;/span&gt;ingress
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; dev/k8s/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setup mirrors production closely:&lt;/p&gt;

&lt;p&gt;Secrets from Kubernetes Secrets, not env vars&lt;/p&gt;

&lt;p&gt;Postgres as a pod with a PVC&lt;/p&gt;

&lt;p&gt;Ingress configured like production&lt;/p&gt;

&lt;p&gt;Broken ingress, wrong ports, and secret issues show up here instead of on a paid cluster. Minikube is not identical to ALB, but it is close enough to surface real problems early.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CI/CD Pipeline
&lt;/h2&gt;

&lt;p&gt;On every push to main:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="s"&gt;Configure AWS Credentials&lt;/span&gt;
  &lt;span class="s"&gt;This uses aws-actions/configure-aws-credentials@v4&lt;/span&gt;

  &lt;span class="s"&gt;Build and push frontend &amp;amp; backend image&lt;/span&gt;
  &lt;span class="s"&gt;This runs docker build + push&lt;/span&gt;

  &lt;span class="s"&gt;Updates deployment&lt;/span&gt;
  &lt;span class="s"&gt;This runs kubectl set image ...&lt;/span&gt;

  &lt;span class="s"&gt;Verify&lt;/span&gt;
  &lt;span class="s"&gt;This runs kubectl rollout status ...&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each image is tagged with commit SHA. Rollback just means redeploying a previous SHA. No guesswork.&lt;/p&gt;

&lt;p&gt;OIDC is used for short-lived credentials. No static keys stored.&lt;/p&gt;

&lt;h2&gt;
  
  
  VPC Endpoints: Why They Matter Beyond Security
&lt;/h2&gt;

&lt;p&gt;Without endpoints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Worker → NAT → Internet → AWS API → Internet → NAT → Worker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;NAT data processing costs $0.045/GB. Image pulls, logs, and secrets traffic add up fast.&lt;/p&gt;

&lt;p&gt;Eight endpoints route AWS-internal traffic privately. NAT remains only for external access during bootstrap.&lt;/p&gt;

&lt;p&gt;This results to lower cost and reduced exposure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets Management Progression
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;Stage 1&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Plain env var&lt;/span&gt;  
&lt;span class="na"&gt;Stage 2&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;K8s Secret&lt;/span&gt;  
&lt;span class="na"&gt;Stage 3&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ExternalSecret synced from Secrets Manager&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Production password is randomly generated and stored in Secrets Manager. Terraform state is encrypted. Password is not floating around in plaintext.&lt;/p&gt;

&lt;h2&gt;
  
  
  IAM Design: Least Privilege
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;GitHub Actions: scoped OIDC role&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Node role: read-only ECR&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Backend pods: read specific secret via IRSA&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;ALB Controller: ALB-only permissions&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each component gets only what it needs to perform its task.&lt;/p&gt;

&lt;h2&gt;
  
  
  Post-Install: Why Not Everything in Terraform
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;post-install.sh&lt;/code&gt; installs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;AWS Load Balancer Controller&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;External Secrets Operator&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CRDs plus dependency ordering make Terraform Helm messy here. This approach is predictable and aligns with upstream guidance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Infrastructure Cost Snapshot
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Monthly&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;EKS&lt;/td&gt;
&lt;td&gt;$72&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EC2 nodes&lt;/td&gt;
&lt;td&gt;~$30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RDS&lt;/td&gt;
&lt;td&gt;~$12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NAT&lt;/td&gt;
&lt;td&gt;~$65&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VPC endpoints&lt;/td&gt;
&lt;td&gt;~$11.60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ALB&lt;/td&gt;
&lt;td&gt;~$16&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Endpoints reduce NAT data charges on AWS API traffic. In active pipelines, that tradeoff makes sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Demonstrates
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Cost-aware Kubernetes learning path&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;OIDC instead of static keys&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;IRSA for pod-level IAM&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;VPC endpoints for cost and security&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;External Secrets integration&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Fully automated ALB provisioning&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Repos are public and documented. Start local, move to Minikube, then EKS.&lt;/p&gt;

&lt;p&gt;If something breaks or you want to discuss design choices, reach out. I am always refining this setup myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open to Work
&lt;/h2&gt;

&lt;p&gt;Currently seeking remote roles in Cloud, DevOps, and Platform Engineering.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;LinkedIn: &lt;a href="https://linkedin.com/in/victorojeje" rel="noopener noreferrer"&gt;https://linkedin.com/in/victorojeje&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Email: &lt;a href="mailto:ojejevictor@gmail.com"&gt;ojejevictor@gmail.com&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;GitHub: &lt;a href="https://github.com/escanut" rel="noopener noreferrer"&gt;https://github.com/escanut&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kubernetes</category>
      <category>aws</category>
      <category>terraform</category>
      <category>devops</category>
    </item>
    <item>
      <title>I built auto-scaling infrastructure that actually survives failures</title>
      <dc:creator>Victor Ojeje</dc:creator>
      <pubDate>Sat, 31 Jan 2026 21:19:07 +0000</pubDate>
      <link>https://dev.to/escanut/i-built-auto-scaling-infrastructure-that-actually-survives-failures-3np9</link>
      <guid>https://dev.to/escanut/i-built-auto-scaling-infrastructure-that-actually-survives-failures-3np9</guid>
      <description>&lt;p&gt;Most deployment tutorials get you to "it works on my machine" status. Few show you how to build infrastructure that survives database connection failures, instance crashes during deployment, or availability zone outages.&lt;/p&gt;

&lt;p&gt;I deployed a production-grade Flask API on AWS with auto-scaling that maintains availability through failures. Here's what separates infrastructure that works in demos from infrastructure that works in production.&lt;/p&gt;

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

&lt;p&gt;A three-tier Flask API deployment on AWS with PostgreSQL, designed to survive common failure scenarios.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live Repository&lt;/strong&gt;: &lt;a href="https://github.com/escanut/terraform-aws-flask-oidc" rel="noopener noreferrer"&gt;github.com/escanut/terraform-aws-flask-oidc&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Architecture&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application Load Balancer (Multi-AZ)
        ↓
Auto Scaling Group (2-4 instances)
        ↓
RDS PostgreSQL (Multi-AZ with automatic failover)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key Stats&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Zero-downtime deployments (50% minimum healthy capacity)&lt;/li&gt;
&lt;li&gt;6-8 minute deployment from commit to live&lt;/li&gt;
&lt;li&gt;380-second instance warmup prevents premature failures&lt;/li&gt;
&lt;li&gt;Multi-AZ redundancy across database and networking&lt;/li&gt;
&lt;li&gt;Runtime credential retrieval (no secrets in code or Docker images)&lt;/li&gt;
&lt;li&gt;Monthly cost: $158 (us-east-1)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Health Check Integration: The Critical Path
&lt;/h2&gt;

&lt;p&gt;Health checks are where most auto-scaling setups fail. You need three layers working together:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Docker Container Health Check&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;HEALTHCHECK&lt;/span&gt;&lt;span class="s"&gt; --interval=30s --timeout=3s --start-period=40s --retries=3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;40-second start period accounts for ECR image pull (15-20 seconds) and application initialization.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Flask Application Health Check&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/health&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;health&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="c1"&gt;# Executes real database query
&lt;/span&gt;    &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;SELECT 1&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;healthy&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Validates the application can actually reach RDS. An instance passing Docker checks but unable to connect to the database is useless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. ALB Health Check&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;health_check&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;healthy_threshold&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
  &lt;span class="nx"&gt;unhealthy_threshold&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
  &lt;span class="nx"&gt;interval&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
  &lt;span class="nx"&gt;path&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/health"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;2 consecutive successes (20 seconds) confirms stability. 3 consecutive failures (30 seconds) removes flapping instances.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deregistration delay: 30 seconds&lt;/strong&gt; allows in-flight requests to complete before removing unhealthy instances.&lt;/p&gt;

&lt;h2&gt;
  
  
  Instance Warmup: Preventing Cascading Failures
&lt;/h2&gt;

&lt;p&gt;This configuration prevented deployment failures:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;instance_refresh&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;strategy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Rolling"&lt;/span&gt;
  &lt;span class="nx"&gt;preferences&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;min_healthy_percentage&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;
    &lt;span class="nx"&gt;instance_warmup&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;380&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Instance warmup: 380 seconds (6+ minutes)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens during warmup:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;EC2 instance launches (30-60 seconds)&lt;/li&gt;
&lt;li&gt;User data script: apt updates, Docker install, AWS CLI install, ECR login, image pull (4-5 minutes)&lt;/li&gt;
&lt;li&gt;Docker health checks stabilize (40 seconds)&lt;/li&gt;
&lt;li&gt;ALB health checks pass 2 consecutive times (20 seconds)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;What happens if warmup is too short (e.g., 120 seconds):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Auto Scaling Group marks instance "healthy" before Docker finishes pulling the image&lt;/li&gt;
&lt;li&gt;ASG immediately terminates old instances&lt;/li&gt;
&lt;li&gt;New instances aren't ready for traffic&lt;/li&gt;
&lt;li&gt;ALB routes traffic to instances returning connection refused&lt;/li&gt;
&lt;li&gt;502 Bad Gateway errors cascade to users&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;380-second warmup ensures:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Old instances stay alive until new instances are genuinely ready&lt;/li&gt;
&lt;li&gt;Zero user-facing errors during deployments&lt;/li&gt;
&lt;li&gt;Rolling updates complete without capacity loss&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This number was determined empirically by monitoring actual instance launch times, not guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-AZ Architecture: Eliminating Single Points of Failure
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;RDS Multi-AZ Configuration:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_db_instance"&lt;/span&gt; &lt;span class="s2"&gt;"rds"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;multi_az&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;instance_class&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"db.t4g.micro"&lt;/span&gt;
  &lt;span class="nx"&gt;storage_encrypted&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Cost: $24.82/month&lt;/strong&gt; (vs $12.41 for single-AZ)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What Multi-AZ provides:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Synchronous replication to standby in different availability zone&lt;/li&gt;
&lt;li&gt;Automatic failover in 1-2 minutes (zero manual intervention)&lt;/li&gt;
&lt;li&gt;Zero data loss during AZ failure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What happens during AZ failure without Multi-AZ:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Database becomes unreachable&lt;/li&gt;
&lt;li&gt;Application returns 503 errors to all users&lt;/li&gt;
&lt;li&gt;Manual recovery required (10-30+ minutes)&lt;/li&gt;
&lt;li&gt;Potential data loss depending on last backup&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AWS availability zone failures occur 2-3 times per year per region. Production systems justify the cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dual NAT Gateways:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_nat_gateway"&lt;/span&gt; &lt;span class="s2"&gt;"nat"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;  &lt;span class="c1"&gt;# One per AZ&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Cost: $65.70/month&lt;/strong&gt; (vs $32.85 for single NAT)&lt;/p&gt;

&lt;p&gt;EC2 instances in private subnets use NAT Gateways for internet access (apt updates, Docker installation, security patches). Single NAT Gateway creates single point of failure. If the NAT Gateway's AZ fails, instances in the other AZ can't bootstrap or update.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Total redundancy cost: $45/month&lt;/strong&gt; ($12.41 for Multi-AZ RDS + $32.85 for second NAT Gateway)&lt;/p&gt;

&lt;p&gt;This is insurance against extended outages, manual recovery procedures, and potential data loss.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security: Runtime Credentials, Not Hardcoded Secrets
&lt;/h2&gt;

&lt;p&gt;Database credentials are retrieved at runtime from AWS Secrets Manager:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;secret_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;DB_SECRET_NAME&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;secretsmanager&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_secret_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SecretId&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;secret_name&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;SecretString&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Where credentials are NOT:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Application code&lt;/li&gt;
&lt;li&gt;Docker image layers&lt;/li&gt;
&lt;li&gt;Environment variables (visible in process listings)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Where credentials ARE:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AWS Secrets Manager (encrypted at rest, access logged via CloudTrail)&lt;/li&gt;
&lt;li&gt;EC2 instance memory only (ephemeral, destroyed on termination)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;IAM instance profile grants read-only access to one specific secret. Even if an instance is compromised, the attacker can't access other secrets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling Deployment Strategy
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Configuration:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;min_healthy_percentage&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Deployment flow:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;GitHub Actions builds Docker image, pushes to ECR (tagged with commit SHA)&lt;/li&gt;
&lt;li&gt;Triggers Auto Scaling Group instance refresh&lt;/li&gt;
&lt;li&gt;ASG replaces 50% of instances at a time&lt;/li&gt;
&lt;li&gt;New instances pull latest image, complete 380-second warmup&lt;/li&gt;
&lt;li&gt;ALB confirms new instances are healthy&lt;/li&gt;
&lt;li&gt;Old instances terminated&lt;/li&gt;
&lt;li&gt;Process repeats for remaining instances&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Deployment time: 6-8 minutes&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Docker build/push: 3 minutes&lt;/li&gt;
&lt;li&gt;Rolling update with warmup: 3-5 minutes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Zero downtime&lt;/strong&gt;: ALB continues routing traffic to healthy instances throughout deployment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure Scenarios and Recovery
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;EC2 instance crash:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ALB health checks fail after 30 seconds&lt;/li&gt;
&lt;li&gt;ALB stops routing traffic to failed instance&lt;/li&gt;
&lt;li&gt;Auto Scaling Group replaces unhealthy instance&lt;/li&gt;
&lt;li&gt;New instance completes warmup and joins rotation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Impact&lt;/strong&gt;: Zero user-facing errors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Availability zone failure:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Multi-AZ RDS fails over to standby (1-2 minutes)&lt;/li&gt;
&lt;li&gt;EC2 instances in failed AZ marked unhealthy&lt;/li&gt;
&lt;li&gt;Auto Scaling Group launches replacements in healthy AZ&lt;/li&gt;
&lt;li&gt;NAT Gateway redundancy ensures new instances bootstrap successfully&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Impact&lt;/strong&gt;: 1-2 minutes degraded performance (reduced capacity)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Docker image pull failure:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Instance warmup prevents premature traffic routing&lt;/li&gt;
&lt;li&gt;Failed instance never receives traffic&lt;/li&gt;
&lt;li&gt;Auto Scaling Group retries launch&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Impact&lt;/strong&gt;: Zero (deployment time increases, no user errors)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Key Lessons
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Instance warmup must account for real bootstrapping time&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I initially set warmup to 120 seconds. Deployments failed because Docker image pull took 40-60 seconds and health checks needed 20 seconds to stabilize. 380 seconds was determined by monitoring actual instance launch times.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Health checks must validate actual functionality&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A health check that only verifies "the container is running" is useless. The &lt;code&gt;/health&lt;/code&gt; endpoint executes a real database query. If the database is unreachable, the instance is genuinely unhealthy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-AZ costs are insurance premiums&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The extra $45/month for Multi-AZ RDS and dual NAT Gateways prevents manual failover procedures, potential data loss, and extended outages. For production systems, this is a rounding error compared to downtime costs.&lt;/p&gt;

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

&lt;p&gt;Production infrastructure is measured by how it handles failures, not how it performs in demos.&lt;/p&gt;

&lt;p&gt;Multi-layer health checks, proper warmup periods, Multi-AZ redundancy, and zero-downtime deployments separate infrastructure that works in tutorials from infrastructure that works when databases fail, instances crash, or entire availability zones go offline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repository&lt;/strong&gt;: &lt;a href="https://github.com/escanut/terraform-aws-flask-oidc" rel="noopener noreferrer"&gt;github.com/escanut/terraform-aws-flask-oidc&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reach out&lt;/strong&gt;: &lt;a href="https://linkedin.com/in/victorojeje" rel="noopener noreferrer"&gt;linkedin.com/in/victorojeje&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm seeking remote opportunities in Cloud Engineering, DevOps, and Infrastructure Engineering. If your team needs someone who thinks about failure scenarios and production resilience, let's connect.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>terraform</category>
      <category>devops</category>
      <category>cloudcomputing</category>
    </item>
    <item>
      <title>How I eliminated access keys from my deployment pipeline with OIDC, Terraform, and GitHub Actions</title>
      <dc:creator>Victor Ojeje</dc:creator>
      <pubDate>Fri, 23 Jan 2026 21:45:10 +0000</pubDate>
      <link>https://dev.to/escanut/how-i-eliminated-access-keys-from-my-deployment-pipeline-with-oidc-terraform-and-github-actions-jek</link>
      <guid>https://dev.to/escanut/how-i-eliminated-access-keys-from-my-deployment-pipeline-with-oidc-terraform-and-github-actions-jek</guid>
      <description>&lt;h2&gt;
  
  
  The Problem with Traditional CI/CD
&lt;/h2&gt;

&lt;p&gt;Most tutorials for deploying static sites to AWS tell you to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generate AWS access keys&lt;/li&gt;
&lt;li&gt;Store them as GitHub secrets&lt;/li&gt;
&lt;li&gt;Hope nobody compromises your repository&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This approach has serious security implications. Long-lived credentials in CI/CD pipelines are a common attack vector since if your repository is compromised or secrets are accidentally exposed, attackers gain full AWS access with those credentials.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There's a better way: OpenID Connect (OIDC) federation.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;A fully automated static website deployment pipeline that eliminates access keys entirely by using temporary credentials exchanged through OIDC.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source Code:&lt;/strong&gt; &lt;a href="https://github.com/escanut/aws-s3-static-site-cicd" rel="noopener noreferrer"&gt;GitHub Repository&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Architecture Overview
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Developer commits → GitHub Actions → OIDC token exchange 
→ Temporary AWS credentials → Deploy to S3 → CloudFront invalidation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key Components:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;S3&lt;/strong&gt; for static website hosting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CloudFront&lt;/strong&gt; for global CDN&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terraform&lt;/strong&gt; for infrastructure as code&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Actions&lt;/strong&gt; for CI/CD&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OIDC&lt;/strong&gt; for secure authentication&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why OIDC Over Access Keys?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Traditional Approach (Access Keys):
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Potential security risk&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Configure AWS Credentials&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/configure-aws-credentials@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;aws-access-key-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_ACCESS_KEY_ID }}&lt;/span&gt;
    &lt;span class="na"&gt;aws-secret-access-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_SECRET_ACCESS_KEY }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;ul&gt;
&lt;li&gt;Keys are long-lived (valid until manually rotated)&lt;/li&gt;
&lt;li&gt;If leaked, provide persistent AWS access&lt;/li&gt;
&lt;li&gt;Require manual rotation and management&lt;/li&gt;
&lt;li&gt;Stored as static secrets in GitHub&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  OIDC Approach:
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# This is secure&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Configure AWS Credentials&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/configure-aws-credentials@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_ROLE_ARN }}&lt;/span&gt;
    &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_REGION }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;ul&gt;
&lt;li&gt;Credentials expire in 1 hour (temporary)&lt;/li&gt;
&lt;li&gt;No static secrets to leak&lt;/li&gt;
&lt;li&gt;Zero credential rotation needed&lt;/li&gt;
&lt;li&gt;AWS manages the trust relationship&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Implementation Deep Dive
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Setting Up the OIDC Provider in Terraform
&lt;/h3&gt;

&lt;p&gt;First, create an OIDC identity provider that trusts GitHub:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_openid_connect_provider"&lt;/span&gt; &lt;span class="s2"&gt;"github"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://token.actions.githubusercontent.com"&lt;/span&gt;

  &lt;span class="nx"&gt;client_id_list&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"sts.amazonaws.com"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="nx"&gt;thumbprint_list&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"6938fd4d98bab03faadb97b34396831e3780aea1"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The thumbprint verifies GitHub's signing certificate authenticity.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Creating the IAM Role with Trust Policy
&lt;/h3&gt;

&lt;p&gt;For this to work, we define &lt;strong&gt;exactly&lt;/strong&gt; which repositories can assume this role:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role"&lt;/span&gt; &lt;span class="s2"&gt;"github_actions"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"github-actions-s3-deployment"&lt;/span&gt;

  &lt;span class="nx"&gt;assume_role_policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;Version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;
    &lt;span class="nx"&gt;Statement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="nx"&gt;Effect&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
      &lt;span class="nx"&gt;Principal&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;Federated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_openid_connect_provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;github&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nx"&gt;Action&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sts:AssumeRoleWithWebIdentity"&lt;/span&gt;
      &lt;span class="nx"&gt;Condition&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;StringEquals&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="s2"&gt;"token.actions.githubusercontent.com:aud"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sts.amazonaws.com"&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nx"&gt;StringLike&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="s2"&gt;"token.actions.githubusercontent.com:sub"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"repo:${var.github_username}/${var.repo_name}:ref:refs/heads/main"&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Critical security feature:&lt;/strong&gt; The &lt;code&gt;StringLike&lt;/code&gt; condition restricts access to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your specific GitHub username&lt;/li&gt;
&lt;li&gt;Your specific repository&lt;/li&gt;
&lt;li&gt;Only the &lt;code&gt;main&lt;/code&gt; branch&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even if someone forks your repo, they can't assume this role.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Least-Privilege IAM Permissions
&lt;/h3&gt;

&lt;p&gt;The role only gets permissions it absolutely needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role_policy"&lt;/span&gt; &lt;span class="s2"&gt;"github_actions"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;github_actions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;

  &lt;span class="nx"&gt;policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;Version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;
    &lt;span class="nx"&gt;Statement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;Effect&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
        &lt;span class="nx"&gt;Action&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
          &lt;span class="s2"&gt;"s3:PutObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"s3:GetObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"s3:DeleteObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"s3:ListBucket"&lt;/span&gt;
        &lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="nx"&gt;Resource&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
          &lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;website_storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"${aws_s3_bucket.website_storage.arn}/*"&lt;/span&gt;
        &lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;Effect&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
        &lt;span class="nx"&gt;Action&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cloudfront:CreateInvalidation"&lt;/span&gt;
        &lt;span class="nx"&gt;Resource&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_cloudfront_distribution&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;website_storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No overly broad permissions.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. CloudFront Configuration Challenge
&lt;/h3&gt;

&lt;p&gt;Here's something that tripped me up: S3 website endpoints require &lt;strong&gt;custom origin configuration&lt;/strong&gt;, not standard S3 origin configuration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# This doesn't work for S3 website endpoints&lt;/span&gt;

&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;domain_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;website_storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bucket_regional_domain_name&lt;/span&gt;
  &lt;span class="nx"&gt;origin_id&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"S3-Website"&lt;/span&gt;

  &lt;span class="nx"&gt;s3_origin_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;origin_access_identity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_cloudfront_origin_access_identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cloudfront_access_identity_path&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# This works for the s3 website endpoints&lt;/span&gt;

&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;domain_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;website_storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;website_endpoint&lt;/span&gt;
  &lt;span class="nx"&gt;origin_id&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"S3-Website"&lt;/span&gt;

  &lt;span class="nx"&gt;custom_origin_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;http_port&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;
    &lt;span class="nx"&gt;https_port&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt;
    &lt;span class="nx"&gt;origin_protocol_policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"http-only"&lt;/span&gt;
    &lt;span class="nx"&gt;origin_ssl_protocols&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"TLSv1.2"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why?&lt;/strong&gt; S3 website endpoints function as HTTP servers, supporting redirects and error documents. Standard S3 origins access the S3 API directly and don't support these features.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Remote State Management
&lt;/h3&gt;

&lt;p&gt;For production infrastructure, always use remote state with locking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bucket&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"escanut-tf-state"&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"s3-website/terraform.tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;region&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;
    &lt;span class="nx"&gt;dynamodb_table&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tf-state-lock"&lt;/span&gt;
    &lt;span class="nx"&gt;encrypt&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This will enable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Team collaboration without conflicts&lt;/li&gt;
&lt;li&gt;State encryption at rest&lt;/li&gt;
&lt;li&gt;Prevention of concurrent modifications&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The GitHub Actions Workflow
&lt;/h2&gt;

&lt;p&gt;The deployment workflow is remarkably simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy to S3&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;  &lt;span class="c1"&gt;# Required for OIDC&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Configure AWS Credentials&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/configure-aws-credentials@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_ROLE_ARN }}&lt;/span&gt;
          &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_REGION }}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Sync to S3&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;aws s3 sync ./build s3://${{ secrets.S3_BUCKET }} --delete&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Invalidate CloudFront&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;aws cloudfront create-invalidation \&lt;/span&gt;
            &lt;span class="s"&gt;--distribution-id ${{ secrets.CLOUDFRONT_ID }} \&lt;/span&gt;
            &lt;span class="s"&gt;--paths "/*"&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment Overview&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;echo "Website deployment successful" &lt;/span&gt;
          &lt;span class="s"&gt;echo "https://${{ secrets.CLOUDFRONT_URL }}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Deployment time:&lt;/strong&gt; Approximately 30 seconds from commit to live site (measured from workflow logs).&lt;/p&gt;

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

&lt;p&gt;One of the best parts? This infrastructure costs almost nothing to run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monthly costs&lt;/strong&gt; (based on January 2026 AWS pricing):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Usage&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;S3 Storage&lt;/td&gt;
&lt;td&gt;&amp;lt;1GB&lt;/td&gt;
&lt;td&gt;$0.02&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 Requests&lt;/td&gt;
&lt;td&gt;10k/month&lt;/td&gt;
&lt;td&gt;$0.05&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudFront&lt;/td&gt;
&lt;td&gt;10GB transfer&lt;/td&gt;
&lt;td&gt;$0.85&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$0.92/month&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For comparison, a single t3.micro EC2 instance costs $8-15/month and requires active management.&lt;br&gt;
.&lt;/p&gt;

&lt;p&gt;AWS's eventually consistent nature means public access settings must be applied before the bucket policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security Wins
&lt;/h2&gt;

&lt;p&gt;This architecture eliminates several attack vectors:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No long-lived credentials&lt;/strong&gt; - temporary credentials expire in 1 hour&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Repository-scoped access&lt;/strong&gt; - only your specific repo can deploy&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Branch-specific trust&lt;/strong&gt; - only &lt;code&gt;main&lt;/code&gt; branch can trigger deployments&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Least-privilege permissions&lt;/strong&gt; - role can only write to specific S3 bucket&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Encrypted state&lt;/strong&gt; - Terraform state encrypted at rest in S3&lt;br&gt;&lt;br&gt;
&lt;strong&gt;No credential rotation&lt;/strong&gt; - AWS handles credential lifecycle automatically&lt;/p&gt;

&lt;p&gt;The current implementation shows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Infrastructure as Code proficiency&lt;/li&gt;
&lt;li&gt;Cloud security best practices&lt;/li&gt;
&lt;li&gt;CI/CD automation&lt;/li&gt;
&lt;li&gt;Cost optimization awareness&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;OIDC federation with GitHub Actions is the modern standard for CI/CD authentication to AWS. It's more secure, requires zero maintenance, and is easier to implement than managing long-lived access keys.&lt;/p&gt;

&lt;p&gt;If you're still using AWS access keys in your deployment pipelines, it's time to upgrade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try it yourself:&lt;/strong&gt; Fork the &lt;a href="https://github.com/escanut/aws-s3-static-site-cicd" rel="noopener noreferrer"&gt;repository&lt;/a&gt;, update the variables, and deploy your own infrastructure in under 5 minutes.&lt;/p&gt;




&lt;p&gt;Drop a comment below or reach out on &lt;a href="https://www.linkedin.com/in/victorojeje/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If this was helpful, consider giving the GitHub repo a ⭐!&lt;/p&gt;

</description>
      <category>aws</category>
      <category>terraform</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>Deploying Secure WordPress on AWS: A Multi-Tier Architecture Approach</title>
      <dc:creator>Victor Ojeje</dc:creator>
      <pubDate>Tue, 20 Jan 2026 18:07:20 +0000</pubDate>
      <link>https://dev.to/escanut/deploying-secure-wordpress-on-aws-a-multi-tier-architecture-approach-1igf</link>
      <guid>https://dev.to/escanut/deploying-secure-wordpress-on-aws-a-multi-tier-architecture-approach-1igf</guid>
      <description>&lt;p&gt;This project started as a learning exercise and ended up looking very close to how small production workloads are actually deployed. The goal was simple: run WordPress on AWS with proper network isolation, least-privilege access, and basic monitoring, without spending money or cutting corners.&lt;/p&gt;

&lt;p&gt;Everything here was built inside Free Tier limits, but the design decisions follow the same patterns used in real environments.&lt;/p&gt;

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

&lt;p&gt;I deployed WordPress using a classic two-tier setup inside a custom VPC.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Core components:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Custom VPC: 10.0.0.0/16&lt;/li&gt;
&lt;li&gt;Public subnet: 10.0.1.0/24 for the web server&lt;/li&gt;
&lt;li&gt;Private subnets: 10.0.10.0/24 and 10.0.11.0/24 for RDS&lt;/li&gt;
&lt;li&gt;EC2 t2.micro running Ubuntu 24.04&lt;/li&gt;
&lt;li&gt;RDS MySQL db.t4g.micro&lt;/li&gt;
&lt;li&gt;Elastic IP for stable external access&lt;/li&gt;
&lt;li&gt;Apache 2.4, PHP 8.1, WordPress&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Traffic flow:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Internet → Internet Gateway → EC2 Compute (public subnet) → RDS (private subnets)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;&lt;em&gt;[Architecture and traffic flow]&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The database has no public IP and no route to the internet. All access is controlled with security groups. I intentionally did not rely on NACLs or host firewalls to keep the design aligned with how AWS environments are usually managed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security Implementation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Security Groups
&lt;/h3&gt;

&lt;p&gt;The security group setup is minimal and intentional.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Web tier security group:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SSH (22): allowed only from my admin IP&lt;/li&gt;
&lt;li&gt;HTTP (80) and HTTPS (443): open to the internet&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Database tier security group:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;MySQL (3306): allowed only from the web tier security group&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No IP-based rules between tiers. Only security group references.&lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;[EC2 security group details]&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;[RDS security group details]&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This matters. The first version used the EC2 private IP as the database allow list. That failed immediately after a reboot when the IP changed. Switching to security group IDs fixed the issue permanently and reflects how AWS expects environments to scale and change.&lt;/p&gt;

&lt;h3&gt;
  
  
  WordPress Hardening
&lt;/h3&gt;

&lt;p&gt;I applied basic but meaningful WordPress security controls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Disabled file editing in the admin dashboard&lt;/li&gt;
&lt;li&gt;Limited login attempts to reduce brute-force risk&lt;/li&gt;
&lt;li&gt;Disabled XML-RPC&lt;/li&gt;
&lt;li&gt;Enforced sane file permissions (755 for directories, 644 for files)&lt;/li&gt;
&lt;li&gt;Enabled HTTPS using a self-signed certificate&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let's Encrypt does not issue certificates for AWS public DNS names. Using a self-signed cert keeps traffic encrypted while avoiding extra costs. Which is fine in this case, as its a demo, not production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitoring and Alerting
&lt;/h2&gt;

&lt;p&gt;Both EC2 and RDS are monitored with CloudWatch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Alarm configuration:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CPU utilization threshold: 70 percent&lt;/li&gt;
&lt;li&gt;Action: SNS email notification&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I chose 70 percent instead of waiting for 80 to 90 percent. On small instance types, sustained CPU spikes quickly translate into performance problems.&lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;[CloudWatch dashboard]&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;[CloudWatch alarms]&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;During normal usage, EC2 CPU sat around 12 to 18 percent. During load testing, alarms triggered correctly and SNS emails arrived within about 2 to 3 minutes. That confirms the alerting path actually works, not just that it exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real Problems Encountered
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Security Group Misconfiguration
&lt;/h3&gt;

&lt;p&gt;Using IP addresses for internal trust broke the moment the instance restarted. This is a common mistake in AWS environments and a good lesson. In AWS, security groups are the trust boundary, not IPs.&lt;/p&gt;

&lt;h3&gt;
  
  
  WordPress URL Handling
&lt;/h3&gt;

&lt;p&gt;WordPress stores absolute URLs inside the database. Changing the Elastic IP meant internal links broke even after updating wp-config.php.&lt;/p&gt;

&lt;p&gt;The fix required a full database search and replace using WP-CLI. This isn't documented in most WordPress migration guides but breaks production migrations regularly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Free Tier Cost Awareness
&lt;/h3&gt;

&lt;p&gt;AWS does not stop you from spending money. An unattached Elastic IP started generating charges within hours.&lt;/p&gt;

&lt;p&gt;Billing alarms should be set up immediately, even for learning projects. Free Tier does not mean free by default.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Single-AZ RDS
&lt;/h3&gt;

&lt;p&gt;Multi-AZ was skipped to stay within Free Tier. This means backups exist, but there is no automatic failover. Recovery requires restoring from a snapshot and takes several minutes. That is acceptable here, not for systems with tight recovery objectives.&lt;/p&gt;

&lt;h3&gt;
  
  
  Apache instead of Nginx
&lt;/h3&gt;

&lt;p&gt;Apache was chosen for simplicity and WordPress compatibility. Nginx would scale better under high concurrency, but that advantage does not matter at this size.&lt;/p&gt;

&lt;h3&gt;
  
  
  Self-signed SSL
&lt;/h3&gt;

&lt;p&gt;Encrypted traffic without a domain cost. Browser warnings are expected. This was a deliberate trade-off, not an oversight.&lt;/p&gt;

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

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

&lt;p&gt;&lt;em&gt;[WordPress live test]&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WordPress reachable over HTTPS via Elastic IP&lt;/li&gt;
&lt;li&gt;RDS fully isolated in private subnets&lt;/li&gt;
&lt;li&gt;Database access restricted to the web tier only&lt;/li&gt;
&lt;li&gt;CloudWatch alarms and SNS notifications verified&lt;/li&gt;
&lt;li&gt;Automated RDS backups tested via snapshot restore&lt;/li&gt;
&lt;li&gt;Total cost stayed at $0 within Free Tier limits&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Skills Demonstrated
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cloud and Infrastructure:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;VPC and subnet design&lt;/li&gt;
&lt;li&gt;Security group based access control&lt;/li&gt;
&lt;li&gt;EC2 and RDS provisioning&lt;/li&gt;
&lt;li&gt;Elastic IP management&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Linux and Systems:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ubuntu server setup&lt;/li&gt;
&lt;li&gt;Apache and PHP configuration&lt;/li&gt;
&lt;li&gt;MySQL client usage&lt;/li&gt;
&lt;li&gt;Service and permission management&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Network isolation&lt;/li&gt;
&lt;li&gt;Least-privilege enforcement&lt;/li&gt;
&lt;li&gt;TLS configuration&lt;/li&gt;
&lt;li&gt;Application-level hardening&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;CloudWatch monitoring&lt;/li&gt;
&lt;li&gt;SNS alerting&lt;/li&gt;
&lt;li&gt;Backup validation&lt;/li&gt;
&lt;li&gt;Cost awareness&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Final Notes
&lt;/h2&gt;

&lt;p&gt;This project reflects how small but real AWS environments are built. The focus was fully on correct fundamentals including isolation, access control, monitoring, and understanding trade-offs.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Victor Ogechukwu Ojeje&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Email: &lt;a href="mailto:ojejevictor@gmail.com"&gt;ojejevictor@gmail.com&lt;/a&gt;&lt;br&gt;&lt;br&gt;
LinkedIn: &lt;a href="https://www.linkedin.com/in/victorojeje/" rel="noopener noreferrer"&gt;linkedin.com/in/victorojeje&lt;/a&gt;&lt;br&gt;&lt;br&gt;
GitHub: &lt;a href="https://github.com/escanut" rel="noopener noreferrer"&gt;github.com/escanut&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Deployment screenshots and configuration details available in the &lt;a href="https://github.com/escanut/WordPress-Deployment-on-AWS" rel="noopener noreferrer"&gt;project repository&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cloud</category>
      <category>devops</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
