<?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: saurabh gupta</title>
    <description>The latest articles on DEV Community by saurabh gupta (@saurabh_gupta).</description>
    <link>https://dev.to/saurabh_gupta</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4060577%2F2ab26425-f572-4764-b6f9-2d42f9b8bb31.jpeg</url>
      <title>DEV Community: saurabh gupta</title>
      <link>https://dev.to/saurabh_gupta</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/saurabh_gupta"/>
    <language>en</language>
    <item>
      <title>What Building a Fintech Ledger Taught Me About Idempotency</title>
      <dc:creator>saurabh gupta</dc:creator>
      <pubDate>Sat, 19 Sep 2026 02:55:47 +0000</pubDate>
      <link>https://dev.to/saurabh_gupta/what-building-a-fintech-ledger-taught-me-about-idempotency-591n</link>
      <guid>https://dev.to/saurabh_gupta/what-building-a-fintech-ledger-taught-me-about-idempotency-591n</guid>
      <description>&lt;h1&gt;
  
  
  What Building a Fintech Ledger Taught Me About Idempotency
&lt;/h1&gt;

&lt;p&gt;I built a double-entry payments ledger on AWS EKS — six FastAPI services, Terraform, an SNS/SQS async pipeline, the whole thing. Going in, I assumed the hard parts would be infrastructure: Fargate quirks, Terraform state, getting Prometheus to scrape anything.&lt;/p&gt;

&lt;p&gt;Those were annoying. But they were &lt;em&gt;findable&lt;/em&gt; — something crashes, you read logs, you fix it.&lt;/p&gt;

&lt;p&gt;The genuinely hard part was a question that sounds too simple to be interesting:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if the same "send money" request arrives twice?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That question turned out to have four different answers depending on where in the system you ask it, and getting it wrong in any one of those places would have silently moved someone's money twice.&lt;/p&gt;




&lt;h2&gt;
  
  
  The version that looks right and isn't
&lt;/h2&gt;

&lt;p&gt;My first instinct was the obvious one: check whether it already happened.&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;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM transactions WHERE idempotency_key = %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;existing&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;existing&lt;/span&gt;
&lt;span class="c1"&gt;# ...otherwise process the transfer
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This reads fine. It's also broken, and it's broken in the way that's hardest to catch: only under concurrency.&lt;/p&gt;

&lt;p&gt;Between the &lt;code&gt;SELECT&lt;/code&gt; and the &lt;code&gt;INSERT&lt;/code&gt; there's a window. If two requests carrying the same idempotency key arrive close enough together — the original, plus a retry fired because the client's HTTP request timed out — both run that &lt;code&gt;SELECT&lt;/code&gt; before either has committed anything. Both see nothing. Both proceed. Two debits.&lt;/p&gt;

&lt;p&gt;And client retries aren't an edge case. They're the &lt;em&gt;normal&lt;/em&gt; path: a mobile app on a flaky connection, a load balancer timing out, a user double-tapping "Send" because the spinner hasn't resolved. A payments system that can't survive a retry isn't handling a rare scenario badly — it's handling the common one badly.&lt;/p&gt;




&lt;h2&gt;
  
  
  The guarantee has to live in the database
&lt;/h2&gt;

&lt;p&gt;The thing that took me embarrassingly long to internalize: &lt;strong&gt;no amount of application logic fixes this.&lt;/strong&gt; Any "check, then act" sequence has a window unless something below it makes the two steps atomic.&lt;/p&gt;

&lt;p&gt;So the actual mechanism is four words:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;transactions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;               &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;idempotency_key&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;UNIQUE&lt;/code&gt;. That's it. That's the guarantee. Everything else in my idempotency story is an optimization sitting on top of it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;transactions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTHING&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No row returned means someone else already claimed that key — go fetch their result and return it. A row returned means you own the work, and nobody can take it from you, because Postgres enforces that at the storage layer rather than trusting that your Python checked first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Redis is a shortcut, not the promise
&lt;/h3&gt;

&lt;p&gt;I do cache idempotency keys in ElastiCache to skip the database on hot retries. But two rules kept that cache from quietly becoming a liability:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It is &lt;strong&gt;never&lt;/strong&gt; the source of truth. A cache miss, an evicted key, a Redis node dying — none of it changes correctness, because the &lt;code&gt;UNIQUE&lt;/code&gt; constraint is still there underneath. Losing Redis costs latency, not money.&lt;/li&gt;
&lt;li&gt;Keys are written &lt;strong&gt;only after the database transaction commits&lt;/strong&gt;. Caching before commit means caching an outcome that might still roll back — a bug that would be nearly impossible to reproduce and catastrophic when it fired.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The test that actually proves it
&lt;/h2&gt;

&lt;p&gt;I could have written "added a unique constraint, therefore idempotent" in the README and moved on. Plenty of projects do.&lt;/p&gt;

&lt;p&gt;The problem: a &lt;em&gt;sequential&lt;/em&gt; test — send the request, wait for the response, send it again — passes even against my broken check-then-insert version. There's no race, because the first &lt;code&gt;INSERT&lt;/code&gt; has long since committed by the time the second &lt;code&gt;SELECT&lt;/code&gt; runs. You get a green checkmark that proves nothing.&lt;/p&gt;

&lt;p&gt;So the test fires both requests at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;ThreadPoolExecutor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_workers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;f1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fire_transfer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;same_idempotency_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;f2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fire_transfer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;same_idempotency_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;r1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;result&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;f2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;result&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;r1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;r2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;entries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_ledger_entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;assert&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;   &lt;span class="c1"&gt;# one debit, one credit. never four.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the concurrent version touches the actual race window. Writing the sequential one first and feeling good about it would have been a false sense of security I'd have carried all the way to production.&lt;/p&gt;




&lt;h2&gt;
  
  
  The bug that taught me idempotency has a &lt;em&gt;failure&lt;/em&gt; path too
&lt;/h2&gt;

&lt;p&gt;This one I found by accident, weeks later, while adding Prometheus metrics — and it's the part I'd most want to talk through in an interview.&lt;/p&gt;

&lt;p&gt;Failed transfers (insufficient funds, frozen account) were doing this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                                  &lt;span class="c1"&gt;# commits on exit, rolls back on exception
&lt;/span&gt;    &lt;span class="bp"&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;UPDATE transactions SET status = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;failed&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; WHERE id = %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;txn_id&lt;/span&gt;&lt;span class="p"&gt;,))&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;422&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;insufficient funds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;lt;-- inside the block
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Raising inside &lt;code&gt;with conn:&lt;/code&gt; rolls back the transaction — &lt;em&gt;including the "mark as failed" update I'd just written&lt;/em&gt;. So a failed transfer left &lt;strong&gt;zero trace&lt;/strong&gt; in the database. The idempotency key was never durably claimed. Which means a retry of that request looked brand new, and got processed from scratch.&lt;/p&gt;

&lt;p&gt;I'd built idempotency for the success path and completely forgotten the failure path. The fix was to stash the exception and raise it &lt;em&gt;after&lt;/em&gt; the transaction block exits cleanly, so the failure status actually commits:&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;pending_error&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;with&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;insufficient_funds&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;UPDATE transactions SET status = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;failed&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="n"&gt;pending_error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;422&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;insufficient funds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# block exited normally -&amp;gt; the 'failed' row is committed
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;pending_error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;pending_error&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson generalizes past this bug: &lt;strong&gt;"what happens if this arrives twice" has to be answered for every outcome, not just the happy one.&lt;/strong&gt; A rejected request is still a request that can be retried.&lt;/p&gt;




&lt;h2&gt;
  
  
  Idempotency at the seam between two systems
&lt;/h2&gt;

&lt;p&gt;Once transfers were safe, I hit the same problem one layer out. A completed transfer needs to trigger fraud scoring and a notification. The obvious implementation:&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                    &lt;span class="c1"&gt;# write the ledger entries
&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;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="c1"&gt;# tell everyone about it
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a &lt;strong&gt;dual write&lt;/strong&gt; — two independent systems, two independent failure modes. If the commit succeeds and the publish fails, the money moved and nothing downstream ever hears about it. Reverse the order and you announce a transfer that then fails to commit. There is no ordering of those two lines that is safe, because they can't be one atomic operation.&lt;/p&gt;

&lt;p&gt;The fix is the &lt;strong&gt;Outbox Pattern&lt;/strong&gt;, and it's the design decision I'm happiest with in the whole project. The transfer writes an &lt;code&gt;outbox_events&lt;/code&gt; row &lt;em&gt;in the same database transaction&lt;/em&gt; as the ledger entries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;ledger_entries&lt;/span&gt; &lt;span class="p"&gt;...;&lt;/span&gt;   &lt;span class="c1"&gt;-- debit + credit&lt;/span&gt;
  &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;outbox_events&lt;/span&gt;  &lt;span class="p"&gt;...;&lt;/span&gt;   &lt;span class="c1"&gt;-- "this happened"&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the event's existence is exactly as reliable as the money movement — same transaction, same atomicity, no gap. A separate poller is the only thing in the entire system that talks to SNS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;outbox_events&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;published&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SKIP LOCKED&lt;/code&gt; is what makes that poller safe to run as multiple replicas with zero coordination logic — each instance grabs rows nobody else currently holds. It runs as one replica today, but the scaling path needs no redesign.&lt;/p&gt;

&lt;h3&gt;
  
  
  And then the duplicates come back
&lt;/h3&gt;

&lt;p&gt;Here's the part that ties it all together: &lt;strong&gt;SQS is at-least-once.&lt;/strong&gt; A consumer will occasionally see the same message twice — a visibility timeout expiring mid-processing, a redrive, an ack that didn't land.&lt;/p&gt;

&lt;p&gt;So the exact question from the very beginning shows up again, in a completely different place, for completely different reasons. "What if this arrives twice" isn't a property of your HTTP API. It's a property of every boundary in a distributed system, and each one needs its own answer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the discipline paid off
&lt;/h2&gt;

&lt;p&gt;The payoff wasn't during normal operation. It was during the failure testing.&lt;/p&gt;

&lt;p&gt;When I deliberately drained both Fargate nodes running a service back-to-back, and when I exercised an RDS Multi-AZ failover, requests in flight at the wrong moment failed — connections dropped, queries timed out. In most systems that's genuinely alarming: &lt;em&gt;did the transfer happen? Is it safe to retry? Could I double-charge someone?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here the answer was boring, which is the highest compliment you can pay a failure mode. &lt;strong&gt;Retry with the same idempotency key.&lt;/strong&gt; If it committed before the disruption, the retry finds the existing row and returns it. If it didn't, the retry processes it fresh. Exactly once, either way.&lt;/p&gt;

&lt;p&gt;Same story with the poison-message test: I deliberately pushed a malformed event into the fraud queue and watched it fail, retry, fail again, and land in the dead-letter queue after real SQS retries — while valid messages behind it kept processing normally. No head-of-line blocking, nothing silently lost.&lt;/p&gt;

&lt;p&gt;That's the real return on this work. Idempotency isn't just politeness toward flaky mobile clients. It's what converts "we had a database failover" from &lt;em&gt;a forensic investigation into what state the system might be in&lt;/em&gt; into &lt;em&gt;a thing that happened for ninety seconds and then stopped happening.&lt;/em&gt; You answer the duplicate question once, up front, instead of re-litigating it during every incident for the rest of the system's life.&lt;/p&gt;




&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;If I compressed this whole project into one paragraph for someone building something similar:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Put the correctness guarantee in a database constraint, not in application logic. Write a test that genuinely races it, because a sequential test will lie to you. Answer the duplicate question for failures too, not just successes. And at every boundary between two systems, ask it again — because the answer doesn't carry over.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Kubernetes manifests, the Terraform modules, the Grafana dashboards — that's all real work, and I learned a lot building it. But it's work you can course-correct on later. A ledger that silently double-debits someone under concurrent retries is not something you course-correct on later.&lt;/p&gt;

&lt;p&gt;You get that right at the schema level, or you don't get it right at all.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;FinLedger is open source: &lt;a href="https://github.com/saurabhg4356/finledger" rel="noopener noreferrer"&gt;github.com/saurabhg4356/finledger&lt;/a&gt; — including the full design doc, the chaos-engineering runbooks, and a written list of the nine production bugs I hit and diagnosed along the way.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>kubernetes</category>
      <category>python</category>
      <category>database</category>
    </item>
    <item>
      <title>ShopSphere — A Cloud-Native E-Commerce Platform</title>
      <dc:creator>saurabh gupta</dc:creator>
      <pubDate>Mon, 03 Aug 2026 12:11:15 +0000</pubDate>
      <link>https://dev.to/saurabh_gupta/shopsphere-a-cloud-native-e-commerce-platform-110c</link>
      <guid>https://dev.to/saurabh_gupta/shopsphere-a-cloud-native-e-commerce-platform-110c</guid>
      <description>&lt;p&gt;`&lt;/p&gt;

&lt;h1&gt;
  
  
  8 Things That Broke When I Deployed Kubernetes on AWS EKS Fargate (And How I Fixed Them)
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Building a production-shaped microservices platform from scratch — what the tutorials don't cover.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I spent 8 weeks building ShopSphere: a cloud-native e-commerce backend with 3 FastAPI microservices, deployed on Amazon EKS, monitored with Prometheus and Grafana, secured with GuardDuty and AWS Secrets Manager, and delivered by a GitHub Actions CI/CD pipeline.&lt;/p&gt;

&lt;p&gt;The stack: Python 3.13 + FastAPI → Docker → ECR → EKS Fargate → RDS PostgreSQL → ALB → Terraform IaC → GitHub Actions.&lt;/p&gt;

&lt;p&gt;Everything I've written below is real. I didn't learn it from a tutorial — I learned it because it broke at 11pm and I had to figure out why.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why EKS Fargate (and the EC2 quota wall)
&lt;/h2&gt;

&lt;p&gt;The original plan was a standard EKS cluster with managed EC2 node groups. That plan died immediately.&lt;/p&gt;

&lt;p&gt;New AWS accounts start with an EC2 vCPU quota of 0 for several instance families. Both On-Demand and Spot were blocked. Requesting quota increases takes days and isn't guaranteed. The project couldn't wait.&lt;/p&gt;

&lt;p&gt;Fargate is the alternative: AWS runs each pod on its own dedicated microVM. No EC2 Auto Scaling Groups, no node management, no quota to hit. You pay per pod-second of CPU and memory rather than per instance.&lt;/p&gt;

&lt;p&gt;I migrated the cluster to Fargate. This was the right call. But Fargate has its own set of constraints that are scattered across AWS documentation, GitHub issues, and Stack Overflow threads — never in one place. Here's what I hit.&lt;/p&gt;




&lt;h2&gt;
  
  
  Problem 1: CoreDNS silently failing to schedule
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; After applying the Fargate profile and deploying, service name resolution failed cluster-wide. The ALB controller couldn't start. External Secrets Operator couldn't start. Prometheus couldn't scrape anything. Everything that needed to reach &lt;code&gt;some-service.namespace.svc.cluster.local&lt;/code&gt; just timed out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; Fargate uses a mutating admission webhook to intercept pod creation and inject Fargate-specific configuration. For a pod to be scheduled on Fargate, the webhook needs to process it. For the webhook to process it, the pod needs the annotation &lt;code&gt;eks.amazonaws.com/compute-type: fargate&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;CoreDNS's default Kubernetes deployment doesn't have this annotation. So Fargate's webhook ignores CoreDNS pods, they never get scheduled, and they sit &lt;code&gt;Pending&lt;/code&gt; indefinitely. Since CoreDNS &lt;em&gt;is&lt;/em&gt; the cluster's DNS resolver, everything else fails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;&lt;/code&gt;&lt;code&gt;bash&lt;br&gt;
kubectl patch deployment coredns -n kube-system \&lt;br&gt;
  --type=json \&lt;br&gt;
  -p='[{"op":"add","path":"/spec/template/metadata/annotations/eks.amazonaws.com~1compute-type","value":"fargate"}]'&lt;br&gt;
kubectl rollout restart deployment/coredns -n kube-system&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This is documented in AWS's EKS + Fargate guide but easy to miss when you're following a general EKS tutorial.&lt;/p&gt;




&lt;h2&gt;
  
  
  Problem 2: Prometheus, AlertManager, Grafana all refuse to start
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; &lt;code&gt;helm install monitoring prometheus-community/kube-prometheus-stack&lt;/code&gt; completes without error. But all pods are stuck with &lt;code&gt;Pod not supported on Fargate: volumes not supported&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; &lt;code&gt;kube-prometheus-stack&lt;/code&gt;'s default Helm values request &lt;code&gt;PersistentVolumeClaims&lt;/code&gt; backed by EBS storage for Prometheus (metrics storage), AlertManager (alert state), and Grafana (dashboard state). Fargate pods cannot mount EBS volumes. At all. It's not a configuration issue — it's a fundamental architectural constraint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Switch to ephemeral in-pod storage for the entire monitoring stack:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`yaml&lt;/p&gt;

&lt;h1&gt;
  
  
  prometheus-values.yaml
&lt;/h1&gt;

&lt;p&gt;prometheus:&lt;br&gt;
  prometheusSpec:&lt;br&gt;
    storageSpec: {}         # no PVC — ephemeral storage&lt;br&gt;
    retention: 6h           # short retention; this is dev, not production&lt;/p&gt;

&lt;p&gt;alertmanager:&lt;br&gt;
  alertmanagerSpec:&lt;br&gt;
    storage: {}             # no PVC&lt;/p&gt;

&lt;p&gt;grafana:&lt;br&gt;
  persistence:&lt;br&gt;
    enabled: false          # no PVC&lt;br&gt;
  sidecar:&lt;br&gt;
    dashboards:&lt;br&gt;
      enabled: true         # load dashboards from ConfigMaps instead&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The monitoring stack becomes stateless by design. Prometheus re-scrapes from pod startup on restart. Grafana dashboards live in ConfigMaps — code-defined, version-controlled, no state to lose.&lt;/p&gt;




&lt;h2&gt;
  
  
  Problem 3: External Secrets Operator webhook port collision
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; ESO installs successfully. The &lt;code&gt;SecretStore&lt;/code&gt; applies without error. But &lt;code&gt;ExternalSecret&lt;/code&gt; objects never sync — they stay in a permanent pending state. ESO pods show TLS errors in their logs: &lt;code&gt;x509: certificate is valid for [...], not for [fargate-node-ip]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; ESO's admission webhook runs on port 10250 by default. On Fargate, every pod runs in its own microVM that has its own kubelet — also on port 10250. When ESO registers its webhook with the Kubernetes API server, and the API server tries to call the webhook to validate ExternalSecret objects, it connects to what it thinks is the ESO webhook address but is actually the Fargate node's kubelet on that port. The TLS certificate ESO presents doesn't include the Fargate node's internal address in its SANs — hence the mismatch.&lt;/p&gt;

&lt;p&gt;This affects several Kubernetes webhook-based operators on Fargate: cert-manager, ADOT, and ESO all have open issues for this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;&lt;/code&gt;`yaml&lt;/p&gt;

&lt;h1&gt;
  
  
  In the ESO Helm values
&lt;/h1&gt;

&lt;p&gt;webhook:&lt;br&gt;
  port: 9443    # anything other than 10250&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Problem 4: Fargate profile updates stranding pods permanently Pending
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; After adding a new namespace to the Fargate profile, some pods in existing namespaces get stuck &lt;code&gt;Pending&lt;/code&gt; and never schedule, despite the profile update completing successfully.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; Fargate profiles are immutable — adding a namespace selector requires destroying and recreating the profile. AWS does this automatically during an update, but there's a brief window (a few seconds to a minute) where no Fargate profile is active. Any pod that gets created during this window goes through normal Kubernetes scheduling. The default scheduler tries to find an EC2 node — there are none. The pod sits &lt;code&gt;Pending&lt;/code&gt;. When the new profile comes back, &lt;strong&gt;it only evaluates pods at creation time, not retroactively&lt;/strong&gt;. Pods already stuck Pending with the wrong scheduler state never get reconsidered for Fargate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; After any Fargate profile update, find and delete all &lt;code&gt;Pending&lt;/code&gt; pods so they get recreated and scheduled correctly:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`bash&lt;br&gt;
kubectl get pods --all-namespaces | grep Pending&lt;/p&gt;

&lt;h1&gt;
  
  
  For each stuck pod:
&lt;/h1&gt;

&lt;p&gt;kubectl delete pod  -n &lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Automate this if you're doing frequent profile updates during setup.&lt;/p&gt;




&lt;h2&gt;
  
  
  Problem 5: IRSA roles referenced but not created
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Pods start successfully. Health checks pass. But any AWS API call — reading from Secrets Manager, listing ECR images — fails with &lt;code&gt;AccessDenied&lt;/code&gt;. The pod appears healthy but is silently broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; I added IRSA annotations to the Kubernetes ServiceAccounts:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;yaml&lt;br&gt;
annotations:&lt;br&gt;
  eks.amazonaws.com/role-arn: "arn:aws:iam::123456789012:role/shopsphere-user-service-role"&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;But I hadn't actually created that IAM role in Terraform yet. The annotation references a role that doesn't exist. Kubernetes applies the ServiceAccount fine. The pod starts fine. The EKS pod identity webhook injects the &lt;code&gt;AWS_ROLE_ARN&lt;/code&gt; and &lt;code&gt;AWS_WEB_IDENTITY_TOKEN_FILE&lt;/code&gt; environment variables correctly. The AWS SDK tries to assume the role — and gets &lt;code&gt;AccessDenied&lt;/code&gt; because the role doesn't exist.&lt;/p&gt;

&lt;p&gt;This is completely invisible until you actually make an AWS API call from inside the pod.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; For each service, add to Terraform:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;hcl&lt;br&gt;
resource "aws_iam_role" "user_service" {&lt;br&gt;
  name = "shopsphere-user-service-role"&lt;br&gt;
  assume_role_policy = jsonencode({&lt;br&gt;
    Version = "2012-10-17"&lt;br&gt;
    Statement = [{&lt;br&gt;
      Effect = "Allow"&lt;br&gt;
      Principal = {&lt;br&gt;
        Federated = "arn:aws:iam::${var.aws_account_id}:oidc-provider/${local.oidc_provider}"&lt;br&gt;
      }&lt;br&gt;
      Action = "sts:AssumeRoleWithWebIdentity"&lt;br&gt;
      Condition = {&lt;br&gt;
        StringEquals = {&lt;br&gt;
          "${local.oidc_provider}:sub" = "system:serviceaccount:shopsphere:user-service-sa"&lt;br&gt;
        }&lt;br&gt;
      }&lt;br&gt;
    }]&lt;br&gt;
  })&lt;br&gt;
}&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Every Kubernetes ServiceAccount that needs AWS permissions needs a corresponding IAM role and OIDC trust policy in Terraform. No exceptions.&lt;/p&gt;




&lt;h2&gt;
  
  
  Problem 6: read-only root filesystem breaking container startup
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; After adding &lt;code&gt;readOnlyRootFilesystem: true&lt;/code&gt; and &lt;code&gt;capabilities: drop: [ALL]&lt;/code&gt; to the pod security context, pods fail to start with permission errors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; The original Dockerfile used a CMD pattern that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;chown&lt;/code&gt;-ed the application directory at container start&lt;/li&gt;
&lt;li&gt;Used &lt;code&gt;su&lt;/code&gt; or &lt;code&gt;gosu&lt;/code&gt; to drop from root to the app user&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both require either write access to the filesystem (for chown) or Linux capabilities that we just dropped (for su/gosu). With &lt;code&gt;readOnlyRootFilesystem: true&lt;/code&gt; and no capabilities, the container can't start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Move all ownership-setting to Dockerfile build time:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`dockerfile&lt;/p&gt;

&lt;h1&gt;
  
  
  In the runtime stage, before switching to appuser:
&lt;/h1&gt;

&lt;p&gt;RUN adduser --disabled-password --no-create-home appuser &amp;amp;&amp;amp; \&lt;br&gt;
    chown -R appuser:appuser /app&lt;/p&gt;

&lt;p&gt;USER appuser&lt;/p&gt;

&lt;h1&gt;
  
  
  CMD just starts the app — no chown, no su needed
&lt;/h1&gt;

&lt;p&gt;CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;For volumes that need write access at runtime (temp files, SQLite), use &lt;code&gt;emptyDir&lt;/code&gt; volumes in the pod spec and configure &lt;code&gt;fsGroup&lt;/code&gt; in the security context — Kubernetes handles the ownership:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`yaml&lt;br&gt;
securityContext:&lt;br&gt;
  fsGroup: 1000    # Kubernetes chowns volume mounts to this GID at pod start&lt;br&gt;
volumes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;name: tmp
emptyDir: {}
`&lt;code&gt;&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Problem 7: Diagnosing an account-level ELB restriction
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Creating a LoadBalancer-type Service fails. The AWS Load Balancer Controller logs show &lt;code&gt;OperationNotPermitted&lt;/code&gt;. IAM permissions look correct. Quotas look fine. No relevant errors in CloudTrail except the failure itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; Some AWS accounts have account-level API restrictions applied that are distinct from IAM permissions and service quotas. These appear in the raw API response but not in the kubectl error summary. The pattern: &lt;code&gt;OperationNotPermitted&lt;/code&gt; rather than &lt;code&gt;AccessDenied&lt;/code&gt; or &lt;code&gt;LimitExceeded&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This required opening an AWS Support case. It's not a configuration mistake. Attempting to work around it through configuration changes wastes time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Workaround while the Support case resolves:&lt;/strong&gt; &lt;code&gt;kubectl port-forward&lt;/code&gt; for Grafana and AlertManager access. This is actually more secure — no public LoadBalancer for the monitoring stack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Read the full raw API error response, not just the kubectl summary. &lt;code&gt;OperationNotPermitted&lt;/code&gt; and &lt;code&gt;AccessDenied&lt;/code&gt; have different root causes and require different responses. Knowing which one you're looking at tells you whether to keep debugging configuration or open a Support case.&lt;/p&gt;




&lt;h2&gt;
  
  
  Problem 8: Fargate profile namespace selectors and webhook timing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; After applying a new Fargate profile that should cover a new namespace, pods in that namespace still don't schedule. They show &lt;code&gt;0/0 nodes available&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; A Fargate profile only schedules pods that match its namespace + label selectors, but the profile has to exist &lt;em&gt;before&lt;/em&gt; the pod is created. If you apply the profile and then immediately apply the namespace and deployment in the same &lt;code&gt;kubectl apply -f&lt;/code&gt;, there's a race — the pods may be created before the profile is fully active.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Add a sleep or confirmation step:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`bash&lt;/p&gt;

&lt;h1&gt;
  
  
  Wait for the Fargate profile to be ACTIVE before deploying
&lt;/h1&gt;

&lt;p&gt;aws eks wait fargate-profile-active \&lt;br&gt;
  --cluster-name shopsphere-cluster \&lt;br&gt;
  --fargate-profile-name shopsphere-fp&lt;/p&gt;

&lt;h1&gt;
  
  
  Then deploy
&lt;/h1&gt;

&lt;p&gt;kubectl apply -f k8s/base/&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What the finished system looks like
&lt;/h2&gt;

&lt;p&gt;After solving all of the above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;git push origin main&lt;/code&gt; → 4 GitHub Actions jobs (test → scan → approve → deploy) → new version live in EKS with zero manual steps&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;curl http://ALB_URL/health&lt;/code&gt; → 200 from all 3 services&lt;/li&gt;
&lt;li&gt;Grafana shows p95 latency, request rate, error rate, and pod health in real time&lt;/li&gt;
&lt;li&gt;AlertManager sent a real email when I deliberately triggered an error spike, and a resolution email when I fixed it&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;kubectl get externalsecret -n shopsphere&lt;/code&gt; → &lt;code&gt;SecretSynced&lt;/code&gt; — DB credentials come from Secrets Manager, not a YAML file&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;kubectl get networkpolicies -n shopsphere&lt;/code&gt; → 5 policies, default-deny enforced&lt;/li&gt;
&lt;li&gt;GuardDuty and CloudTrail monitoring the account&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The project is at &lt;a href="https://github.com/saurabhg4356/shopsphere" rel="noopener noreferrer"&gt;github.com/saurabhg4356/shopsphere&lt;/a&gt; with full source, architecture diagram, and setup instructions.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd tell someone starting this today
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Set up a AWS budget alert before your first &lt;code&gt;terraform apply&lt;/code&gt;.&lt;/strong&gt; The NAT Gateway is always running and costs money even when nothing is happening.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Read the Fargate-specific documentation, not just the EKS documentation.&lt;/strong&gt; The two have meaningfully different constraints and the Fargate docs are more scattered.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;When something fails, read the full raw API error.&lt;/strong&gt; &lt;code&gt;kubectl describe&lt;/code&gt; gives summaries. The raw API response — in CloudTrail, in pod events, in controller logs — tells you the actual error code, which often tells you exactly what category of problem you're dealing with.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Terraform modules from the start.&lt;/strong&gt; I built a flat structure first and refactored it into modules. It's much easier to start modular than to extract modules from a flat structure later.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The hard problems aren't the code.&lt;/strong&gt; FastAPI is simple. Docker is simple. Kubernetes YAML is tedious but learnable. The genuinely hard part is the system-level debugging — when six different components interact and only one of them is wrong, and that one has a misleading error message.&lt;br&gt;
`&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>kubernetes</category>
      <category>docker</category>
      <category>aws</category>
      <category>python</category>
    </item>
  </channel>
</rss>
