<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>https://benjaminheath.dev/blog</id>
    <title>#!/BSH Blog</title>
    <updated>2026-06-23T00:00:00.000Z</updated>
    <generator>https://github.com/jpmonette/feed</generator>
    <link rel="alternate" href="https://benjaminheath.dev/blog"/>
    <subtitle>#!/BSH Blog</subtitle>
    <icon>https://benjaminheath.dev/img/favicon.svg</icon>
    <entry>
        <title type="html"><![CDATA[June 23rd - AI: Anti-Hype Edition]]></title>
        <id>https://benjaminheath.dev/blog/ai-anti-hype-edition</id>
        <link href="https://benjaminheath.dev/blog/ai-anti-hype-edition"/>
        <updated>2026-06-23T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A no hype take on how AI can be used as a tool in software engineering. Including a real workflow, honest limitations, and what not to do.]]></summary>
        <content type="html"><![CDATA[<p>How to begin? Perhaps a brief rant followed by an admission?</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="brief-rant">Brief Rant<a href="https://benjaminheath.dev/blog/ai-anti-hype-edition#brief-rant" class="hash-link" aria-label="Direct link to Brief Rant" title="Direct link to Brief Rant" translate="no">​</a></h2>
<p>Is anyone else beyond sick of seeing AI shoved into absolutely everything, like it's the new sriracha? Why did my dishwasher need AI included in it?!?! What's next, are they going to shove AI in my toothpaste?</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="admission">Admission<a href="https://benjaminheath.dev/blog/ai-anti-hype-edition#admission" class="hash-link" aria-label="Direct link to Admission" title="Direct link to Admission" translate="no">​</a></h2>
<p>On the first attempt of writing this post I followed a workflow similar to the one discussed below, using AI to scaffold the body of the post, but this lead to an obvious outcome. The whole thing read like it was written by AI(duh). This was also blatantly hypocritical given my rants at the beginning and frustration over all the AI slop out there. There was only one path forward, I trashed the original and rewrote it the "old school" way. I'm writing directly in markdown, will use spellcheck, and have some human reviewers but no AI writing.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="mindset">Mindset<a href="https://benjaminheath.dev/blog/ai-anti-hype-edition#mindset" class="hash-link" aria-label="Direct link to Mindset" title="Direct link to Mindset" translate="no">​</a></h2>
<p>Let me get back on track here, since this is supposed to be about how I use AI in the context of software engineering. To me AI is purely another tool in my ever expanding toolbox; sitting comfortably on the shelf next to many other tools.</p>
<p>The usefulness I find in AI as a tool is predominately faster "googling" and non-deterministic automation.</p>
<p>With it I am able to research an error, bug, or potential solution via multiple sources in a more rapid fashion than manual searching. Is it perfect? No. This is clear from one of my most frequent prompts being:</p>
<p><code>Can you please provide links to the documentation you are referencing?</code></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="best-use-cases-ive-found">Best use cases I've found<a href="https://benjaminheath.dev/blog/ai-anti-hype-edition#best-use-cases-ive-found" class="hash-link" aria-label="Direct link to Best use cases I've found" title="Direct link to Best use cases I've found" translate="no">​</a></h2>
<p><strong>Documentation</strong> This one is kinda related to the next item in the list but I wanted to call it out specifically. If you need to write documentation for a chunk of code or create a readme for a project you're new to, AI can be great. The documentation it creates still requires careful review but significantly faster and less brain melting than writing documentation completely by hand.</p>
<p><strong>Scaffolding</strong> I rarely use fancy IDE's that have features for spitting out templated or boilerplate code so I leverage AI for scaffolding out the code that I have already handwritten in the past; once scaffolded the code can be reviewed with a fine tooth comb and have any adjustments made that are needed.</p>
<p><strong>Research</strong> The context within which I operate often varies massively day to day (sometimes hour to hour); writing a script one moment, followed by debugging an API, followed by tweaking firewall rules, and then deploying some infra. This makes it extremely valuable to be able to do things like</p>
<ul>
<li class="">Getting a summary of a topic pulled from multiple sources; whether net new or something I've worked with previously but need a refresher on.</li>
<li class="">Searching notes for a prior solution to a similar problem.</li>
</ul>
<p><strong>Rubber Duck</strong> Sometimes there is no human available and due to the non-deterministic nature of LLM responses they can be used to think through potential solutions or weird edge cases.</p>
<p><strong>Learning Tool</strong> Basically the polar and principally opposite idea to "vibe-coding". I never blindly accept a line of code or a change proposed by an AI tool. If I don't understand what the code does, it's not getting committed. I will stop and have the tool explain character by character, over and over, until I fully understand it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="ai-in-my-workflow">AI in my workflow<a href="https://benjaminheath.dev/blog/ai-anti-hype-edition#ai-in-my-workflow" class="hash-link" aria-label="Direct link to AI in my workflow" title="Direct link to AI in my workflow" translate="no">​</a></h2>
<p>Currently my workflow looks like this:</p>
<ol>
<li class="">
<p>Have a real problem to solve. Not some tutorial exercise or hypothetical that has no real world use.</p>
</li>
<li class="">
<p>Delve through the dark caverns of my brain searching for prior experience with a related problem and come up with some potential solutions. This is required, brain must be exercised regularly.</p>
</li>
<li class="">
<p>When a fellow human isn't available, I'm sick of talking to myself, or more feedback is needed than what my dog can provide I'll use an AI tool as a rubber duck. I'll use it to rip apart the potential solutions from step 2 or surface potential solutions that may not have occurred to me.</p>
</li>
<li class="">
<p>Once a path forward is decided on, think through how to break the work into workable chunks. Repeat this step as many times as needed to not feel overwhelmed.</p>
</li>
<li class="">
<p>Scaffolding - I really enjoying working with computers so if there are things for a solution that I have memorized, can quickly scaffold myself while enjoying my creamy keyboard, or something I want to learn I will do it without any AI involvment. The biggest constraint here is the amount of time available for a task. Sometimes you have a problem that needs to be solved as quickly as possible for whatever reason and in those situations I will use any tool available. Given infinite time I would spend time on every char, tracing every bug, perfectly optimize resource usage, or creating the perfect user experience. But unfortunately we don't have the watches from Clockstoppers, yet!</p>
</li>
<li class="">
<p>Scaffolding with AI - Describe the current piece of the problem being attacked in as much detail as possible. Being hyper specific with what you want will reduce the frustration that future you feels, sometimes.</p>
<ul>
<li class="">Carefully read every single character that the AI proposes before any actions are approved.</li>
<li class="">If there is something that is new or doesn't make sense, pause, and get explanation character by character until 100% understanding is achieved.</li>
</ul>
</li>
<li class="">
<p>Only when 100% understanding is achieved will the proposed action be allowed to be taken. This is for two main reasons. First, because I have been burned in the past by not following this rule. Second, it allows me to continue to gain knowledge along the way.</p>
</li>
<li class="">
<p>Review all of the generated code with a flea comb to find errors, vulnerabilities, edge cases, and adherence to specifications - triple check EVERYTHING.</p>
</li>
<li class="">
<p>Fix anything that is wrong and revise things that I don't like. For example I can't stand "magic" code that someone with no context of the project can't easily understand. If some random engineer can't there is a high probability that future you won't understand either.</p>
</li>
</ol>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="my-current-wont-dos">My Current "Won't Do's"<a href="https://benjaminheath.dev/blog/ai-anti-hype-edition#my-current-wont-dos" class="hash-link" aria-label="Direct link to My Current &quot;Won't Do's&quot;" title="Direct link to My Current &quot;Won't Do's&quot;" translate="no">​</a></h2>
<ul>
<li class="">Trust all actions for a session. I review every single action the agent wants to perform.</li>
<li class="">Allow agents to operate autonomously. If I need things automated I will build deterministic automation that can be trusted to run without constant oversight and have predictable outcomes.</li>
<li class="">Blindly trusting information that is provided. Always follow up with "Please provide references" and actually vet those references.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="non-determinism-in-practice">Non-Determinism in Practice<a href="https://benjaminheath.dev/blog/ai-anti-hype-edition#non-determinism-in-practice" class="hash-link" aria-label="Direct link to Non-Determinism in Practice" title="Direct link to Non-Determinism in Practice" translate="no">​</a></h2>
<p>For the non-deterministic automation I'm referring to tasks like "scaffold me a readme for this Github action I created" or "create an api endpoint based on this existing one". I'm open to at least trying AI tools to assist with things that don't depend on repeatable logic that is guaranteed to perform the exact same actions every single time. Often times I will use an AI tool to assist in scaffolding the code to build deterministic automation. There are many situations where the risk of having an agent do something completely off the wall based on a prompt is simply not worth it. The idea of the "DevOps" or "SRE" agents with the access required to resolve a production incident from a novel root cause gives me the heebs.</p>
<p>Take as an example (an overly simple one) the prompt:</p>
<p><code>create a local branch, fix error X, and push those changes</code></p>
<p>This could be executed in fundamentally different ways depending on the model being used, the context the agent has, or which way the wind is blowing. Even when you specify in the agents config, the specific way that it is supposed to complete specific tasks there is no guarantee that it will follow the exact same steps every single time.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-sands-of-time">The Sands of Time<a href="https://benjaminheath.dev/blog/ai-anti-hype-edition#the-sands-of-time" class="hash-link" aria-label="Direct link to The Sands of Time" title="Direct link to The Sands of Time" translate="no">​</a></h2>
<p>AI can speed up the completion of tasks that don't require critical thinking or deterministic outcomes; things like drafting a readme for a chunk of code. That being said reviewing AI generated code is a time suck which requires you to be constantly on guard. AI will confidently propose things that are total garbage and your knowledge/experience is the final safe guard.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-obsolescence-narrative">The Obsolescence Narrative<a href="https://benjaminheath.dev/blog/ai-anti-hype-edition#the-obsolescence-narrative" class="hash-link" aria-label="Direct link to The Obsolescence Narrative" title="Direct link to The Obsolescence Narrative" translate="no">​</a></h2>
<p>I may be biased from working in software engineering but the constant stream of <em>redacted</em> that "AI" will make software engineers(and related roles) obsolete is not occurring anytime soon based on the capabilities I have seen. There is a massive gap between vibe coding an app that would be at best considered a POC and successfully engineering a piece of software that can run for years on end. Software that can scale when needed, stable under load, adapt when a capability is missing, and valuable to the user.</p>
<p>It should also not be ignored that most of those hyping the capabilities of "AI" have a direct financial interest in the products success. That's not a reason to blindly dismiss the technology, but it is a reason to be skeptical of the marketing spiel.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="closing-thoughts">Closing thoughts<a href="https://benjaminheath.dev/blog/ai-anti-hype-edition#closing-thoughts" class="hash-link" aria-label="Direct link to Closing thoughts" title="Direct link to Closing thoughts" translate="no">​</a></h2>
<p>AI tools are interesting but hardly the magical thing they are hyped as. Do I use it? Yeah, where it makes sense; but if it disappeared tomorrow I'd be perfectly fine.</p>
<p>Always say "Please" and "Thank you" when prompting AI, you know, just in case... oh and never stop learning!</p>]]></content>
        <author>
            <name>Benjamin Heath</name>
            <uri>https://www.linkedin.com/in/benjamin-heath-738610132/</uri>
        </author>
        <category label="ai" term="ai"/>
        <category label="workflow" term="workflow"/>
        <category label="software-engineering" term="software-engineering"/>
        <category label="agentic-development" term="agentic-development"/>
        <category label="sre" term="sre"/>
        <category label="devops" term="devops"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[June 5th - Blog Subscription Service - Part 4: The Lambda Code]]></title>
        <id>https://benjaminheath.dev/blog/blog-subscription-service-part-4</id>
        <link href="https://benjaminheath.dev/blog/blog-subscription-service-part-4"/>
        <updated>2026-06-05T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[This post covers both Lambdas in the system: the subscribe Lambda that handles subscribe, confirm, and unsubscribe flows, and the poller that runs daily to email confirmed subscribers when a new post goes out.]]></summary>
        <content type="html"><![CDATA[<p>This post covers both Lambdas in the system: the subscribe Lambda that handles subscribe, confirm, and unsubscribe flows, and the poller that runs daily to email confirmed subscribers when a new post goes out.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="subscribe-lambda">Subscribe Lambda<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-4#subscribe-lambda" class="hash-link" aria-label="Direct link to Subscribe Lambda" title="Direct link to Subscribe Lambda" translate="no">​</a></h2>
<p>API Gateway routes all three endpoints to the same Lambda function. The handler reads the route from the event and dispatches:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#bfc7d5"><span class="token keyword" style="font-style:italic">def</span><span class="token plain"> </span><span class="token function" style="color:rgb(130, 170, 255)">handler</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token plain">event</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"> context</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    route </span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token plain"> event</span><span class="token punctuation" style="color:rgb(199, 146, 234)">.</span><span class="token plain">get</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token string" style="color:rgb(195, 232, 141)">"routeKey"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">""</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token keyword" style="font-style:italic">if</span><span class="token plain"> route </span><span class="token operator" style="color:rgb(137, 221, 255)">==</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"POST /subscribe"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">        </span><span class="token keyword" style="font-style:italic">return</span><span class="token plain"> handle_subscribe</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token plain">event</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token keyword" style="font-style:italic">elif</span><span class="token plain"> route </span><span class="token operator" style="color:rgb(137, 221, 255)">==</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"GET /confirm"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">        </span><span class="token keyword" style="font-style:italic">return</span><span class="token plain"> handle_confirm</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token plain">event</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token keyword" style="font-style:italic">elif</span><span class="token plain"> route </span><span class="token operator" style="color:rgb(137, 221, 255)">==</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"GET /unsubscribe"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">        </span><span class="token keyword" style="font-style:italic">return</span><span class="token plain"> handle_unsubscribe</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token plain">event</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token keyword" style="font-style:italic">return</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token string" style="color:rgb(195, 232, 141)">"statusCode"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token number" style="color:rgb(247, 140, 108)">404</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"body"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> json</span><span class="token punctuation" style="color:rgb(199, 146, 234)">.</span><span class="token plain">dumps</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token string" style="color:rgb(195, 232, 141)">"message"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"Not found."</span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><br></div></code></pre></div></div>
<p>Example code: <a href="https://gitlab.com/heathbar-public-assets/public-assets/-/blob/main/public/code/blog-subscription-service/part-4-lambdas/subscribe/index.py" target="_blank" rel="noopener noreferrer" class=""><code>subscribe/index.py</code></a></p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="subscribe-post-subscribe">Subscribe: POST /subscribe<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-4#subscribe-post-subscribe" class="hash-link" aria-label="Direct link to Subscribe: POST /subscribe" title="Direct link to Subscribe: POST /subscribe" translate="no">​</a></h3>
<p>The subscribe flow:</p>
<ol>
<li class="">Parse the email from the request body</li>
<li class="">Validate format (regex) and length (254-char cap per RFC 5321)</li>
<li class="">Validate the Cloudflare Turnstile token (more on this below)</li>
<li class="">Generate a UUID token</li>
<li class="">Write to DynamoDB with <code>confirmed = false</code></li>
<li class="">Send a confirmation email via SES with a link containing the token</li>
</ol>
<p>Duplicate signups return the same generic success response as a new signup. The initial code Claude generated returned <code>{"message": "Already subscribed."}</code> for duplicates, but that's visible in browser dev tools even when the frontend hides it, which leaks subscriber status to anyone who pokes the API directly. Fixed by returning an identical response either way.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="turnstile">Turnstile<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-4#turnstile" class="hash-link" aria-label="Direct link to Turnstile" title="Direct link to Turnstile" translate="no">​</a></h3>
<p>The subscribe endpoint is public, so it needs abuse protection beyond rate limiting. Cloudflare Turnstile (managed mode) is added to the subscribe form. The widget runs in the browser and produces a token that the Lambda verifies with Cloudflare's siteverify API before doing anything else.</p>
<p>The Turnstile secret is stored in SSM Parameter Store as a <code>SecureString</code>. It's fetched outside the Lambda handler so it's reused across warm invocations. SSM standard parameters have no per-call cost, but the fetch still adds latency on cold starts, so keeping it out of the hot path matters.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="confirm-get-confirm">Confirm: GET /confirm<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-4#confirm-get-confirm" class="hash-link" aria-label="Direct link to Confirm: GET /confirm" title="Direct link to Confirm: GET /confirm" translate="no">​</a></h3>
<p>The confirm link in the email points to <code>mydomain.com/confirm?token=...&amp;email=...</code>, a Docusaurus page that calls the API on load and renders a success or error message. (Claude's initial implementation pointed directly to the API endpoint, which just dumped raw JSON at the user.)</p>
<p>The Lambda confirm flow:</p>
<ol>
<li class="">Extract <code>token</code> and <code>email</code> from query string parameters</li>
<li class="">Validate UUID format on the token before touching DynamoDB</li>
<li class="">Look up the subscriber by email</li>
<li class="">Verify the stored token matches</li>
<li class="">Update <code>confirmed = true</code> in DynamoDB</li>
</ol>
<p>Token and email are URL-encoded in the confirmation link to handle special characters safely.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="unsubscribe-get-unsubscribe">Unsubscribe: GET /unsubscribe<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-4#unsubscribe-get-unsubscribe" class="hash-link" aria-label="Direct link to Unsubscribe: GET /unsubscribe" title="Direct link to Unsubscribe: GET /unsubscribe" translate="no">​</a></h3>
<p>The unsubscribe link in every email footer works the same way: <code>GET /unsubscribe?token=...&amp;email=...</code>. The Lambda validates the token, then deletes the item from DynamoDB.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="a-dynamodb-gotcha">A DynamoDB Gotcha<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-4#a-dynamodb-gotcha" class="hash-link" aria-label="Direct link to A DynamoDB Gotcha" title="Direct link to A DynamoDB Gotcha" translate="no">​</a></h3>
<p><code>token</code> is a DynamoDB reserved word. Using it directly in a <code>ConditionExpression</code> throws a <code>ValidationException</code> at runtime, not at deploy time and not in local testing, unless you're hitting real DynamoDB. The fix is to use <code>ExpressionAttributeNames</code>:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#bfc7d5"><span class="token plain">response </span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token plain"> table</span><span class="token punctuation" style="color:rgb(199, 146, 234)">.</span><span class="token plain">update_item</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    Key</span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token string" style="color:rgb(195, 232, 141)">"email"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> email</span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    ConditionExpression</span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token string" style="color:rgb(195, 232, 141)">"attribute_exists(email) AND #tok = :token"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    ExpressionAttributeNames</span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token string" style="color:rgb(195, 232, 141)">"#tok"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"token"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    ExpressionAttributeValues</span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token string" style="color:rgb(195, 232, 141)">":token"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> token</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">":confirmed"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token boolean" style="color:rgb(255, 88, 116)">True</span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    UpdateExpression</span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token string" style="color:rgb(195, 232, 141)">"SET confirmed = :confirmed"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><br></div></code></pre></div></div>
<p>This one only showed up after the first real confirmation attempt.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="ses-sandbox">SES Sandbox<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-4#ses-sandbox" class="hash-link" aria-label="Direct link to SES Sandbox" title="Direct link to SES Sandbox" translate="no">​</a></h3>
<p>On the first test send, SES returned <code>MessageRejected</code>. In sandbox mode, both the sender AND recipient address must be verified in the console: it's not enough to have a verified sending domain. Either verify the recipient manually or request production access. For a personal blog newsletter with double opt-in, production access was approved the same day within a few hours.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="cost">Cost<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-4#cost" class="hash-link" aria-label="Direct link to Cost" title="Direct link to Cost" translate="no">​</a></h3>
<p>At small scale, the subscribe Lambda costs essentially nothing:</p>
<ul>
<li class=""><strong>Lambda</strong> - invocations are on-demand and well within free tier</li>
<li class=""><strong>DynamoDB</strong> - a handful of reads and writes per subscriber action</li>
<li class=""><strong>SES</strong> - $0.10 per 1,000 emails; confirmation emails are one per signup</li>
<li class=""><strong>SSM</strong> - standard parameters are free; the Turnstile secret fetch adds no cost</li>
<li class=""><strong>Cloudflare Turnstile</strong> - free</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="poller-lambda">Poller Lambda<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-4#poller-lambda" class="hash-link" aria-label="Direct link to Poller Lambda" title="Direct link to Poller Lambda" translate="no">​</a></h2>
<p>The poller is a second Lambda, fired by EventBridge on a daily cron. It fetches the RSS feed, checks whether anything is new, emails every confirmed subscriber if so, and updates a cursor.</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#bfc7d5"><span class="token keyword" style="font-style:italic">def</span><span class="token plain"> </span><span class="token function" style="color:rgb(130, 170, 255)">handler</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token plain">event</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"> context</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    last_seen </span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token plain"> get_last_seen</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    new_posts </span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token plain"> fetch_new_posts</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token plain">last_seen</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token keyword" style="font-style:italic">if</span><span class="token plain"> </span><span class="token keyword" style="font-style:italic">not</span><span class="token plain"> new_posts</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">        </span><span class="token keyword" style="font-style:italic">return</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    subscribers </span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token plain"> get_confirmed_subscribers</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token keyword" style="font-style:italic">for</span><span class="token plain"> post </span><span class="token keyword" style="font-style:italic">in</span><span class="token plain"> new_posts</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">        send_to_all</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token plain">post</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"> subscribers</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">        update_last_seen</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token plain">post</span><span class="token punctuation" style="color:rgb(199, 146, 234)">[</span><span class="token string" style="color:rgb(195, 232, 141)">"published"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">]</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><br></div></code></pre></div></div>
<p>Example code: <a href="https://gitlab.com/heathbar-public-assets/public-assets/-/blob/main/public/code/blog-subscription-service/part-4-lambdas/poller/index.py" target="_blank" rel="noopener noreferrer" class=""><code>poller/index.py</code></a></p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="fetching-the-rss-feed">Fetching the RSS Feed<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-4#fetching-the-rss-feed" class="hash-link" aria-label="Direct link to Fetching the RSS Feed" title="Direct link to Fetching the RSS Feed" translate="no">​</a></h3>
<p>The feed is fetched with <code>urllib.request.urlopen</code> with a 10-second timeout. No third-party dependencies: the feed is straightforward XML and <code>xml.etree.ElementTree</code> handles it fine.</p>
<p>Error handling covers the two likely failure modes:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#bfc7d5"><span class="token keyword" style="font-style:italic">try</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token keyword" style="font-style:italic">with</span><span class="token plain"> urlopen</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token plain">RSS_URL</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"> timeout</span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token number" style="color:rgb(247, 140, 108)">10</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token plain"> </span><span class="token keyword" style="font-style:italic">as</span><span class="token plain"> response</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">        xml_content </span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token plain"> response</span><span class="token punctuation" style="color:rgb(199, 146, 234)">.</span><span class="token plain">read</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token keyword" style="font-style:italic">except</span><span class="token plain"> URLError </span><span class="token keyword" style="font-style:italic">as</span><span class="token plain"> e</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    logger</span><span class="token punctuation" style="color:rgb(199, 146, 234)">.</span><span class="token plain">error</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token string" style="color:rgb(195, 232, 141)">"Failed to fetch RSS feed: %s"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"> e</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token keyword" style="font-style:italic">raise</span><br></div></code></pre></div></div>
<p><code>ET.ParseError</code> is also caught separately in case the feed is reachable but returns malformed XML. In both cases the Lambda raises so EventBridge can log the failure. Silent swallowing would make it hard to notice the poller has stopped working.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="diffing-against-last_seen">Diffing Against last_seen<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-4#diffing-against-last_seen" class="hash-link" aria-label="Direct link to Diffing Against last_seen" title="Direct link to Diffing Against last_seen" translate="no">​</a></h3>
<p><code>last_seen</code> is stored as an ISO 8601 timestamp in DynamoDB, in a dedicated item with a fixed partition key (<code>SYSTEM#last_seen</code>). It's separate from subscriber records: same table, different key prefix.</p>
<p>On first run, <code>last_seen</code> doesn't exist yet. The poller treats a missing value as "never run" and emails all posts in the feed. In practice this means you should set <code>last_seen</code> manually (or let the first run email everything) before subscribing real users.</p>
<p>The diff is a simple comparison: <code>post["published"] &gt; last_seen</code>. Posts are sorted oldest-first so emails go out in chronological order if multiple new posts exist on the same day.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="querying-confirmed-subscribers">Querying Confirmed Subscribers<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-4#querying-confirmed-subscribers" class="hash-link" aria-label="Direct link to Querying Confirmed Subscribers" title="Direct link to Querying Confirmed Subscribers" translate="no">​</a></h3>
<p>The poller uses the <code>confirmed</code> GSI to query only confirmed subscribers, avoiding a full table scan:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#bfc7d5"><span class="token plain">response </span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token plain"> table</span><span class="token punctuation" style="color:rgb(199, 146, 234)">.</span><span class="token plain">query</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    IndexName</span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token string" style="color:rgb(195, 232, 141)">"confirmed-index"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    KeyConditionExpression</span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token plain">Key</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token string" style="color:rgb(195, 232, 141)">"confirmed"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token punctuation" style="color:rgb(199, 146, 234)">.</span><span class="token plain">eq</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token boolean" style="color:rgb(255, 88, 116)">True</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><br></div></code></pre></div></div>
<p>The query handles <code>LastEvaluatedKey</code> pagination so it works correctly as the subscriber list grows:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#bfc7d5"><span class="token plain">kwargs </span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">{</span><span class="token string" style="color:rgb(195, 232, 141)">"IndexName"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"confirmed-index"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">,</span><span class="token plain"> </span><span class="token string" style="color:rgb(195, 232, 141)">"KeyConditionExpression"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> Key</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token string" style="color:rgb(195, 232, 141)">"confirmed"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token punctuation" style="color:rgb(199, 146, 234)">.</span><span class="token plain">eq</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token boolean" style="color:rgb(255, 88, 116)">True</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token punctuation" style="color:rgb(199, 146, 234)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain"></span><span class="token keyword" style="font-style:italic">while</span><span class="token plain"> </span><span class="token boolean" style="color:rgb(255, 88, 116)">True</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    response </span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token plain"> table</span><span class="token punctuation" style="color:rgb(199, 146, 234)">.</span><span class="token plain">query</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token operator" style="color:rgb(137, 221, 255)">**</span><span class="token plain">kwargs</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    subscribers</span><span class="token punctuation" style="color:rgb(199, 146, 234)">.</span><span class="token plain">extend</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token plain">response</span><span class="token punctuation" style="color:rgb(199, 146, 234)">[</span><span class="token string" style="color:rgb(195, 232, 141)">"Items"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">]</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    last_key </span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token plain"> response</span><span class="token punctuation" style="color:rgb(199, 146, 234)">.</span><span class="token plain">get</span><span class="token punctuation" style="color:rgb(199, 146, 234)">(</span><span class="token string" style="color:rgb(195, 232, 141)">"LastEvaluatedKey"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    </span><span class="token keyword" style="font-style:italic">if</span><span class="token plain"> </span><span class="token keyword" style="font-style:italic">not</span><span class="token plain"> last_key</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">        </span><span class="token keyword" style="font-style:italic">break</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">    kwargs</span><span class="token punctuation" style="color:rgb(199, 146, 234)">[</span><span class="token string" style="color:rgb(195, 232, 141)">"ExclusiveStartKey"</span><span class="token punctuation" style="color:rgb(199, 146, 234)">]</span><span class="token plain"> </span><span class="token operator" style="color:rgb(137, 221, 255)">=</span><span class="token plain"> last_key</span><br></div></code></pre></div></div>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="sending-the-email">Sending the Email<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-4#sending-the-email" class="hash-link" aria-label="Direct link to Sending the Email" title="Direct link to Sending the Email" translate="no">​</a></h3>
<p>Each confirmed subscriber gets a plain-text and HTML email with the post title, a short excerpt from the RSS description, a link to the post, and an unsubscribe link in the footer. No tracking pixels, no open rates, no click tracking.</p>
<p>The unsubscribe link is constructed from the subscriber's stored token:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#bfc7d5"><span class="token plain">https://mydomain.com/unsubscribe?email=...&amp;token=...</span><br></div></code></pre></div></div>
<p>Both email and token are URL-encoded. The HTML email body escapes RSS content (title, description, link) before embedding, as RSS feeds occasionally contain characters that would break an HTML template.</p>
<p>SES failure is caught per-subscriber. If a send fails, the error is logged and <code>last_seen</code> is not advanced for that post, so the next run will retry. This means failed sends can result in duplicate emails if the failure was transient, but it's preferable to silently skipping subscribers.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="idempotency">Idempotency<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-4#idempotency" class="hash-link" aria-label="Direct link to Idempotency" title="Direct link to Idempotency" translate="no">​</a></h3>
<p>If EventBridge fires the Lambda twice in a day (rare but possible), the second run will find <code>last_seen</code> already advanced and send nothing. That's the correct behavior.</p>
<p><code>last_seen</code> only advances after a successful send batch. If the Lambda errors mid-run, it will retry the whole batch on the next invocation. At the scale of a personal blog newsletter, occasional duplicate emails are acceptable. The alternative (distributed transactions across SES and DynamoDB) isn't worth it.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="cost-1">Cost<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-4#cost-1" class="hash-link" aria-label="Direct link to Cost" title="Direct link to Cost" translate="no">​</a></h3>
<p>At small scale, the poller costs essentially nothing:</p>
<ul>
<li class=""><strong>Lambda</strong> - one invocation per day, well within free tier</li>
<li class=""><strong>DynamoDB</strong> - a handful of reads and writes per day</li>
<li class=""><strong>EventBridge</strong> - first 14 million events/month are free</li>
<li class=""><strong>SES</strong> - $0.10 per 1,000 emails; at 100 subscribers that's $0.01/day if posting daily</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="hardening">Hardening<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-4#hardening" class="hash-link" aria-label="Direct link to Hardening" title="Direct link to Hardening" translate="no">​</a></h2>
<p>The items below were either caught during review of Claude's generated code or checked for based on architecture:</p>
<p><strong>No SQL injection surface (both Lambdas):</strong> DynamoDB has no query language that accepts raw strings. All operations use the AWS SDK's structured API: keys, update expressions, and condition expressions are parameterized via <code>ExpressionAttributeValues</code>, so user input is always passed as a typed value and never interpolated into an expression. There is nothing to inject into.</p>
<p><strong>Input validation (subscribe Lambda):</strong> email format checked with a regex, hard-capped at 254 characters. UUID format checked on tokens before any DynamoDB call. This avoids wasting a DynamoDB read on obviously invalid input.</p>
<p><strong>Domain typo detection (subscribe Lambda):</strong> a lookup table of ~40 common misspellings across Gmail, Yahoo, Hotmail, Outlook, and iCloud is checked before anything else. If the submitted domain matches a known typo, the endpoint returns a 400 with a <code>"Did you mean user@gmail.com?"</code> message instead of silently accepting an address that will never receive the confirmation email. This is purely a UX call: a valid-format address that happens to be a fat-finger will pass regex validation just fine, so the only way to catch it is an explicit list.</p>
<p><strong>URL encoding (both Lambdas):</strong> <code>email</code> and <code>token</code> are URL-encoded in confirmation and unsubscribe links via <code>urllib.parse.quote</code>. Without this, special characters in email addresses (valid per RFC) would silently corrupt the links.</p>
<p><strong>HTML escaping (both Lambdas):</strong> confirmation URLs embedded in HTML email bodies are escaped before insertion. RSS content in the poller (title, description, link) is escaped before going into the HTML template.</p>
<p><strong>No email enumeration (subscribe Lambda):</strong> subscribe returns the same response whether the address is new or already exists.</p>
<p><strong>Token design (subscribe Lambda):</strong> tokens are UUIDs generated at subscribe time, stored in DynamoDB alongside the subscriber record, and validated on confirm and unsubscribe. They're not guessable, not reused, and not derived from the email address.</p>
<p><strong>SES failure handling (both Lambdas):</strong> Claude's initial code silently returned 200 if SES threw an exception. Both Lambdas now catch, log, and return 500.</p>
<p><strong>Reserved concurrency:</strong> not applied. AWS reserved concurrency caps and reserves at the same time, there's no way to just limit without also reserving capacity. The account's total concurrency limit is exactly 10, which is the minimum AWS requires to remain unreserved across the account, so setting any reserved concurrency at all fails with a 400. API Gateway throttling is the blast radius protection here instead.</p>
<p><strong><code>source_account</code> on Lambda permissions (both Lambdas):</strong> API Gateway and EventBridge are shared AWS services. Without a <code>source_account</code> condition on the Lambda invoke permission, the permission effectively says "any account's API Gateway can invoke this function." Adding <code>source_account = your_account_id</code> scopes it to your account only, preventing a confused deputy attack where a malicious actor with your Lambda ARN configures their own API Gateway to invoke it.</p>
<p><strong>Hardcoded domain (poller Lambda):</strong> the initial code suggested by Claude hardcoded the API domain directly in the Lambda. Fixed by reading <code>API_DOMAIN</code> from an environment variable, which Terraform sets from a variable. Keeps the code deployable to a different domain without touching the source.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="real-world-observation-junk-filtering">Real-World Observation: Junk Filtering<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-4#real-world-observation-junk-filtering" class="hash-link" aria-label="Direct link to Real-World Observation: Junk Filtering" title="Direct link to Real-World Observation: Junk Filtering" translate="no">​</a></h2>
<p>SPF, DKIM, and DMARC all passed on the first live send (Terraform configures all three). Despite that, Thunderbird flagged the confirmation email as junk (found this thanks to a brave volunteer). A brand new sending domain with zero history has no reputation signal for mail clients to work with, so some will err on the side of caution regardless of authentication results. Not much to do about this except send mail consistently over time and let the reputation build.</p>
<p>An improvement I made over Claude's initial code: adding a <code>List-Unsubscribe</code> header to all outbound emails. It gives mail clients a machine-readable unsubscribe path and is a strong signal that you're a legitimate sender. SES's <code>send_email</code> API doesn't support custom headers, so this required switching to <code>send_raw_email</code> and constructing the MIME message manually using Python's stdlib <code>email.mime</code> classes. More code, but no new dependencies. The <code>List-Unsubscribe-Post</code> header enables one-click unsubscribe (RFC 8058), which major mail clients now expect.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="wrapping-up">Wrapping Up<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-4#wrapping-up" class="hash-link" aria-label="Direct link to Wrapping Up" title="Direct link to Wrapping Up" translate="no">​</a></h2>
<p>That's the full system: four posts covering a weekend project that now actually runs in production. The architecture isn't novel, but the details in each layer are where the interesting decisions live: OIDC over static keys, GSI over scan, double opt-in over server-side trust, Turnstile over WAF.</p>
<p>I refuse to hide the fact that I leveraged Claude to scaffold out the starting code, but there are critical aspects to my workflow that I adhere to when using any AI tool(sounds like a good blog post topic for the future). The issues flagged in this post - email enumeration, raw JSON confirmation responses, silent SES failure, missing <code>List-Unsubscribe</code>, are a good illustration of what that collaboration actually looks like in practice. The generated code was a solid starting point, but knowing enough about the problem to spot where it fell short mattered just as much as the speed of getting there.</p>
<ul>
<li class=""><strong>Part 1:</strong> <a class="" href="https://benjaminheath.dev/blog/blog-subscription-service-part-1">The Why and the What</a></li>
<li class=""><strong>Part 2:</strong> <a class="" href="https://benjaminheath.dev/blog/blog-subscription-service-part-2">OIDC Authentication and the Terraform CI Pipeline</a></li>
<li class=""><strong>Part 3:</strong> <a class="" href="https://benjaminheath.dev/blog/blog-subscription-service-part-3">Terraform Infrastructure Deep Dive</a></li>
</ul>
<p><em>This is part of the <a class="" href="https://benjaminheath.dev/blog/tags/blog-subscription-service">Blog Subscription Service</a> series.</em></p>]]></content>
        <author>
            <name>Benjamin Heath</name>
            <uri>https://www.linkedin.com/in/benjamin-heath-738610132/</uri>
        </author>
        <category label="aws" term="aws"/>
        <category label="lambda" term="lambda"/>
        <category label="python" term="python"/>
        <category label="security" term="security"/>
        <category label="rss" term="rss"/>
        <category label="blog-subscription-service" term="blog-subscription-service"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[June 3rd - Blog Subscription Service - Part 3: Infrastructure and Terraform Deep Dive]]></title>
        <id>https://benjaminheath.dev/blog/blog-subscription-service-part-3</id>
        <link href="https://benjaminheath.dev/blog/blog-subscription-service-part-3"/>
        <updated>2026-06-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[With the CI pipeline handling authentication and execution, the Terraform itself can stay focused on what it actually needs to build. This post walks through every resource - what it is, how it connects to the rest of the system, and a few design decisions worth explaining.]]></summary>
        <content type="html"><![CDATA[<p>With the CI pipeline handling authentication and execution, the Terraform itself can stay focused on what it actually needs to build. This post walks through every resource - what it is, how it connects to the rest of the system, and a few design decisions worth explaining.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="overview">Overview<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-3#overview" class="hash-link" aria-label="Direct link to Overview" title="Direct link to Overview" translate="no">​</a></h2>
<p>A note on process: the architecture and design decisions here are my own, but I used Claude to help scaffold the initial Terraform and Lambda code. The example files linked throughout this post reflect that collaboration.</p>
<p>The infrastructure lives entirely in <code>infra/terraform/</code>. There's no module abstraction - everything is flat. A future post will go through refactoring the code into modules; I wanted to be able to show the progression from flat terraform to using modules because I know this was tricky to wrap my brain around when I started. For now, the flat structure makes it easier to see exactly what Terraform is doing before introducing that layer of organization.</p>
<p>Resources deployed:</p>
<ul>
<li class="">API Gateway (HTTP API)</li>
<li class="">Two Lambda functions (<code>subscribe</code>, <code>poller</code>)</li>
<li class="">DynamoDB table with a GSI</li>
<li class="">SES domain identity, DKIM, and custom MAIL FROM</li>
<li class="">EventBridge scheduled rule</li>
<li class="">ACM certificate and Route53 records for the custom API domain</li>
<li class="">IAM execution roles (one per Lambda)</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="api-gateway">API Gateway<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-3#api-gateway" class="hash-link" aria-label="Direct link to API Gateway" title="Direct link to API Gateway" translate="no">​</a></h2>
<p>The API uses the HTTP API type (v2), not REST API. HTTP APIs are simpler to configure and cheaper - REST APIs cost roughly $3.50/million requests, HTTP APIs cost $1/million. For a personal blog, the savings are negligible, but there's no reason to use the more expensive one.</p>
<p>Three routes, all pointing to the same Lambda:</p>
<ul>
<li class=""><code>POST /subscribe</code> - new subscriber signups</li>
<li class=""><code>GET /confirm</code> - email confirmation link target</li>
<li class=""><code>GET /unsubscribe</code> - unsubscribe link target</li>
</ul>
<p>The API sits behind a custom domain at <code>api.mydomain.com</code>, with an ACM certificate and a Route53 A record (alias) pointing at the API Gateway domain name. TLS is handled by ACM - no certificate management needed.</p>
<p>Throttling is configured on the <code>$default</code> stage via <code>default_route_settings</code> with low sustained and burst limits. This stops scripted abuse without needing WAF, which runs at minimum ~$5-6/month.</p>
<p>Example code: <a href="https://gitlab.com/heathbar-public-assets/public-assets/-/blob/main/public/code/blog-subscription-service/part-3-terraform/api_gateway.tf" target="_blank" rel="noopener noreferrer" class=""><code>api_gateway.tf</code></a></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="lambda">Lambda<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-3#lambda" class="hash-link" aria-label="Direct link to Lambda" title="Direct link to Lambda" translate="no">​</a></h2>
<p>Both Lambda functions are Python 3.12. Terraform zips the source at plan time using the <code>archive_file</code> data source, so the zip is built locally before any AWS API calls are made. The resulting zip paths are referenced by the Lambda resources.</p>
<p>Each function gets its configuration via environment variables - table name, SES sender address, domain - so nothing is hardcoded in the Python source.</p>
<p>Each function gets its own IAM execution role. Claude initially scaffolded a single shared role, but I split them in the spirit of least privilege - the poller has no business touching the Turnstile SSM parameter, and a shared role would give it that access for free. The subscribe role needs: DynamoDB read/write, SES <code>SendEmail</code>, and SSM <code>GetParameter</code> (for the Turnstile secret). The poller role needs: DynamoDB read/write and SES <code>SendEmail</code> - nothing else.</p>
<p>The Lambda zip artifacts from <code>tf:plan</code> are uploaded as CI artifacts and downloaded by <code>tf:apply</code> in the same pipeline. This is why <code>tf:apply</code> uses <code>dependencies</code> on <code>tf:plan</code> - artifacts don't persist across pipeline boundaries.</p>
<p>Example code: <a href="https://gitlab.com/heathbar-public-assets/public-assets/-/blob/main/public/code/blog-subscription-service/part-3-terraform/lambda.tf" target="_blank" rel="noopener noreferrer" class=""><code>lambda.tf</code></a> | <a href="https://gitlab.com/heathbar-public-assets/public-assets/-/blob/main/public/code/blog-subscription-service/part-3-terraform/iam.tf" target="_blank" rel="noopener noreferrer" class=""><code>iam.tf</code></a></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="dynamodb">DynamoDB<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-3#dynamodb" class="hash-link" aria-label="Direct link to DynamoDB" title="Direct link to DynamoDB" translate="no">​</a></h2>
<p>Single table (<code>blog-subscribers</code>) with email as the partition key. Each item stores:</p>
<ul>
<li class=""><code>email</code> - partition key</li>
<li class=""><code>confirmed</code> - boolean flag</li>
<li class=""><code>token</code> - UUID used to validate confirmation and unsubscribe links</li>
<li class=""><code>last_seen</code> - timestamp used by the poller (stored in a dedicated item, not on subscriber records)</li>
</ul>
<p>The poller needs to query only confirmed subscribers. A full table scan would work at small scale, but a GSI on the <code>confirmed</code> attribute is the right pattern - it lets the poller use a <code>Query</code> instead of a <code>Scan</code>, which is both cheaper and faster as the table grows.</p>
<p>Two sanity saving settings worth enabling via Terraform:</p>
<ul>
<li class=""><code>deletion_protection_enabled = true</code> - prevents the table from being accidentally deleted via the console or a Terraform mistake</li>
<li class="">Point-in-time recovery (<code>point_in_time_recovery { enabled = true }</code>) - allows restoring to any second in the last 35 days</li>
</ul>
<p>One gotcha: <code>token</code> is a DynamoDB reserved word. Any <code>ConditionExpression</code> that references <code>token</code> directly throws a <code>ValidationException</code>. The fix is to use <code>ExpressionAttributeNames={"#tok": "token"}</code> and reference <code>#tok</code> in the expression.</p>
<p>Example code: <a href="https://gitlab.com/heathbar-public-assets/public-assets/-/blob/main/public/code/blog-subscription-service/part-3-terraform/dynamodb.tf" target="_blank" rel="noopener noreferrer" class=""><code>dynamodb.tf</code></a></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="ses">SES<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-3#ses" class="hash-link" aria-label="Direct link to SES" title="Direct link to SES" translate="no">​</a></h2>
<p>SES handles all outbound email from <code>contact@mydomain.com</code>. Three things are configured via Terraform:</p>
<ul>
<li class=""><strong>Domain identity</strong> - verifies that the domain is owned and authorizes sending from it</li>
<li class=""><strong>DKIM</strong> - DNS records published to Route53 that let receiving servers verify email signatures</li>
<li class=""><strong>Custom MAIL FROM domain</strong> - sets <code>mail.mydomain.com</code> as the envelope sender domain, which improves deliverability</li>
</ul>
<p>SES starts in sandbox mode, where it can only send to verified addresses. In order to send to addresses that are not pre-verified, production access is required. Approval from AWS came through the same day within a few hours.</p>
<p>Example code: <a href="https://gitlab.com/heathbar-public-assets/public-assets/-/blob/main/public/code/blog-subscription-service/part-3-terraform/ses.tf" target="_blank" rel="noopener noreferrer" class=""><code>ses.tf</code></a></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="eventbridge">EventBridge<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-3#eventbridge" class="hash-link" aria-label="Direct link to EventBridge" title="Direct link to EventBridge" translate="no">​</a></h2>
<p>A single scheduled rule runs the poller Lambda daily. The rule uses a cron expression targeting a fixed UTC time. EventBridge needs permission to invoke the Lambda - this is granted via a <code>aws_lambda_permission</code> resource with <code>principal = "events.amazonaws.com"</code> and the rule ARN as the source.</p>
<p>Example code: <a href="https://gitlab.com/heathbar-public-assets/public-assets/-/blob/main/public/code/blog-subscription-service/part-3-terraform/eventbridge.tf" target="_blank" rel="noopener noreferrer" class=""><code>eventbridge.tf</code></a></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="remote-state">Remote State<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-3#remote-state" class="hash-link" aria-label="Direct link to Remote State" title="Direct link to Remote State" translate="no">​</a></h2>
<p>Terraform state lives in S3 (<code>mydomain-terraform-state</code>) with versioning enabled. Versioning is worth the negligible storage cost - it lets you roll back a corrupted state file without losing everything.</p>
<p>State locking uses S3 native locking (<code>use_lockfile = true</code>) rather than DynamoDB. DynamoDB-based locking was deprecated in Terraform 1.10.</p>
<p>Example code: <a href="https://gitlab.com/heathbar-public-assets/public-assets/-/blob/main/public/code/blog-subscription-service/part-3-terraform/backend.tf" target="_blank" rel="noopener noreferrer" class=""><code>backend.tf</code></a></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's Next<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-3#whats-next" class="hash-link" aria-label="Direct link to What's Next" title="Direct link to What's Next" translate="no">​</a></h2>
<p>Part 4 covers both Lambdas - the subscribe, confirm, and unsubscribe flows, the security decisions behind them, and how the daily poller fetches the RSS feed and emails confirmed subscribers.</p>
<ul>
<li class=""><strong>Part 1:</strong> <a class="" href="https://benjaminheath.dev/blog/blog-subscription-service-part-1">The Why and the What</a></li>
<li class=""><strong>Part 2:</strong> <a class="" href="https://benjaminheath.dev/blog/blog-subscription-service-part-2">OIDC Authentication and the Terraform CI Pipeline</a></li>
<li class=""><strong>Part 4:</strong> <a class="" href="https://benjaminheath.dev/blog/blog-subscription-service-part-4">The Lambda Code</a></li>
</ul>
<p><em>This is part of the <a class="" href="https://benjaminheath.dev/blog/tags/blog-subscription-service">Blog Subscription Service</a> series.</em></p>]]></content>
        <author>
            <name>Benjamin Heath</name>
            <uri>https://www.linkedin.com/in/benjamin-heath-738610132/</uri>
        </author>
        <category label="aws" term="aws"/>
        <category label="terraform" term="terraform"/>
        <category label="serverless" term="serverless"/>
        <category label="infrastructure" term="infrastructure"/>
        <category label="blog-subscription-service" term="blog-subscription-service"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[May 29th - Blog Subscription Service - Part 2: OIDC Auth and the Terraform Pipeline]]></title>
        <id>https://benjaminheath.dev/blog/blog-subscription-service-part-2</id>
        <link href="https://benjaminheath.dev/blog/blog-subscription-service-part-2"/>
        <updated>2026-05-29T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Getting Terraform to run in CI without storing AWS credentials anywhere is one of those things that sounds complicated until you understand the pattern. This post covers how GitLab authenticates to AWS using OIDC, and how the CI pipeline is structured to plan on any branch and apply only on main.]]></summary>
        <content type="html"><![CDATA[<p>Getting Terraform to run in CI without storing AWS credentials anywhere is one of those things that sounds complicated until you understand the pattern. This post covers how GitLab authenticates to AWS using OIDC, and how the CI pipeline is structured to plan on any branch and apply only on main.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-problem-with-static-credentials">The Problem with Static Credentials<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-2#the-problem-with-static-credentials" class="hash-link" aria-label="Direct link to The Problem with Static Credentials" title="Direct link to The Problem with Static Credentials" translate="no">​</a></h2>
<p>The lazy approach to Terraform in CI is to create an IAM user, generate an access key, and paste it into a CI variable. It works, but it comes with real downsides:</p>
<ul>
<li class="">Long-lived credentials that can leak</li>
<li class="">Manual rotation burden</li>
<li class="">No automatic expiry</li>
</ul>
<p>OIDC solves all of this. GitLab acts as an identity provider, and AWS grants temporary credentials to CI jobs that can prove they came from the right project and branch. The token expires when the job ends, nothing to rotate, and nothing to leak.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="how-oidc-works-here">How OIDC Works Here<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-2#how-oidc-works-here" class="hash-link" aria-label="Direct link to How OIDC Works Here" title="Direct link to How OIDC Works Here" translate="no">​</a></h2>
<p>When a CI job runs, GitLab injects a short-lived JWT into <code>$GITLAB_OIDC_TOKEN</code>. That token is a signed assertion from GitLab that says "this job ran in project X, on branch Y, triggered by event Z." AWS STS accepts that token via <code>AssumeRoleWithWebIdentity</code> and returns temporary credentials scoped to the role.</p>
<p>The CI job writes the token to a file and sets an environment variable so the AWS SDK picks it up automatically:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#bfc7d5"><span class="token key atrule">before_script</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(199, 146, 234)">-</span><span class="token plain"> echo $GITLAB_OIDC_TOKEN </span><span class="token punctuation" style="color:rgb(199, 146, 234)">&gt;</span><span class="token plain"> /tmp/web</span><span class="token punctuation" style="color:rgb(199, 146, 234)">-</span><span class="token plain">identity</span><span class="token punctuation" style="color:rgb(199, 146, 234)">-</span><span class="token plain">token</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(199, 146, 234)">-</span><span class="token plain"> export AWS_WEB_IDENTITY_TOKEN_FILE=/tmp/web</span><span class="token punctuation" style="color:rgb(199, 146, 234)">-</span><span class="token plain">identity</span><span class="token punctuation" style="color:rgb(199, 146, 234)">-</span><span class="token plain">token</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(199, 146, 234)">-</span><span class="token plain"> export AWS_ROLE_ARN=$AWS_ROLE_ARN</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(199, 146, 234)">-</span><span class="token plain"> export AWS_REGION=us</span><span class="token punctuation" style="color:rgb(199, 146, 234)">-</span><span class="token plain">east</span><span class="token punctuation" style="color:rgb(199, 146, 234)">-</span><span class="token number" style="color:rgb(247, 140, 108)">1</span><br></div></code></pre></div></div>
<p>Writing the token to a file rather than passing it as a shell argument keeps it out of process listings and CI logs. The AWS SDK exchanges it for short-lived credentials automatically on the first API call, no explicit <code>sts assume-role-with-web-identity</code> call needed.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="setting-up-the-trust-relationship">Setting Up the Trust Relationship<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-2#setting-up-the-trust-relationship" class="hash-link" aria-label="Direct link to Setting Up the Trust Relationship" title="Direct link to Setting Up the Trust Relationship" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="1-register-gitlab-as-an-oidc-provider-in-aws">1. Register GitLab as an OIDC provider in AWS<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-2#1-register-gitlab-as-an-oidc-provider-in-aws" class="hash-link" aria-label="Direct link to 1. Register GitLab as an OIDC provider in AWS" title="Direct link to 1. Register GitLab as an OIDC provider in AWS" translate="no">​</a></h3>
<p>Done once via the AWS Console: IAM → Identity providers → Add provider → OpenID Connect.</p>
<ul>
<li class=""><strong>Provider URL:</strong> <code>https://gitlab.com</code></li>
<li class=""><strong>Audience:</strong> <code>sts.amazonaws.com</code></li>
</ul>
<p>AWS validates well-known providers against its own root CA store, no manual thumbprint retrieval needed.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="2-create-the-iam-role">2. Create the IAM role<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-2#2-create-the-iam-role" class="hash-link" aria-label="Direct link to 2. Create the IAM role" title="Direct link to 2. Create the IAM role" translate="no">​</a></h3>
<p>IAM → Roles → Create role → Web identity. The role is named <code>your-role-name</code>, scoped to the <code>your-group/your-project</code> project. The reference path is left blank, meaning any branch can assume the role. Restricting apply to main is handled in CI rules instead, which lets <code>tf:plan</code> run on feature branches to validate changes before merging.</p>
<p>The trust condition this generates:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#bfc7d5"><span class="token plain">gitlab.com:sub = project_path:your-group/your-project:ref_type:branch:ref:*</span><br></div></code></pre></div></div>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="3-scope-permissions-with-a-boundary-and-inline-policy">3. Scope permissions with a boundary and inline policy<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-2#3-scope-permissions-with-a-boundary-and-inline-policy" class="hash-link" aria-label="Direct link to 3. Scope permissions with a boundary and inline policy" title="Direct link to 3. Scope permissions with a boundary and inline policy" translate="no">​</a></h3>
<p>The lazy part of my brain of course wanted to use <code>AdministratorAccess</code>, but we can do better. The role gets two things:</p>
<ul>
<li class=""><strong>Permission boundary</strong> (<code>your-role-name-boundary</code>): a hard cap on what the role can ever do, even if someone attaches a broader policy later. Scoped to the exact services this project uses.</li>
<li class=""><strong>Inline policy</strong> (<code>your-inline-policy-name</code>): the actual permissions needed to deploy: Lambda, API Gateway V2, DynamoDB, SES, EventBridge, ACM, Route53, CloudWatch Logs, IAM (scoped to <code>your-prefix-*</code> roles only), and S3 (scoped to the state bucket).</li>
</ul>
<p>The important thing about permission boundaries: <strong>both</strong> the boundary and the inline policy must allow an action for it to go through. When debugging, check both. I hit this multiple times: once when the boundary had a one character typo in the bucket name, and again when <code>iam:CreateServiceLinkedRole</code> was missing, both surfaced as 403 <code>AccessDeniedException</code> errors mid apply.</p>
<p>A few permissions that were missed until the first deploy (required in both policy and boundary):</p>
<ul>
<li class=""><code>events:ListTagsForResource</code>: Terraform reads tags when refreshing existing EventBridge rules</li>
<li class=""><code>iam:CreateServiceLinkedRole</code> (scoped to API Gateway SLR): API Gateway needs a service-linked role on first use in a region</li>
<li class="">Several Lambda read permissions (<code>lambda:ListVersionsByFunction</code>, <code>lambda:GetFunctionCodeSigningConfig</code>, etc.): all called by the AWS provider when refreshing existing Lambda state</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-ci-pipeline">The CI Pipeline<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-2#the-ci-pipeline" class="hash-link" aria-label="Direct link to The CI Pipeline" title="Direct link to The CI Pipeline" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="jobs">Jobs<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-2#jobs" class="hash-link" aria-label="Direct link to Jobs" title="Direct link to Jobs" translate="no">​</a></h3>
<ul>
<li class=""><strong><code>tf:validate</code></strong>: runs automatically on any branch when <code>infra/</code> changes. Catches syntax errors cheaply before a plan.</li>
<li class=""><strong><code>tf:plan</code></strong>: auto on MR pipelines and on main after merge when <code>infra/</code> changes (so the diff is visible in the MR UI before merging); manual on other branch pushes with <code>infra/</code> changes (this manual plan could be automatic but I do stupid things and I don't want plans running for no reason).</li>
<li class=""><strong><code>tf:apply</code></strong>: manual trigger, restricted to <code>main</code> only, when <code>infra/</code> changes. Depends on the <code>tf:plan</code> artifact from the same pipeline; artifacts don't cross pipeline boundaries, so plan and apply must run in the same main pipeline. (I also plan to create some policy checks in the future that will enable automatic apply in certain, non-destructive situations.)</li>
</ul>
<p>The plan produces a JSON report (<code>terraform show -json tfplan &gt; tfplan.json</code>) uploaded as a <code>reports: terraform</code> artifact. GitLab (in theory) renders this as a summary in the MR UI showing resources to add, change, or destroy.</p>
<p>One gotcha worth noting: the Terraform Docker image sets its entrypoint to <code>terraform</code>, which breaks all non-terraform shell commands in <code>before_script</code>. The fix:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#bfc7d5;--prism-background-color:#292d3e"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#bfc7d5;background-color:#292d3e"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#bfc7d5"><span class="token key atrule">image</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token key atrule">name</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> hashicorp/terraform</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain">1.15.2</span><br></div><div class="token-line" style="color:#bfc7d5"><span class="token plain">  </span><span class="token key atrule">entrypoint</span><span class="token punctuation" style="color:rgb(199, 146, 234)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(199, 146, 234)">[</span><span class="token string" style="color:rgb(195, 232, 141)">""</span><span class="token punctuation" style="color:rgb(199, 146, 234)">]</span><br></div></code></pre></div></div>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="variables">Variables<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-2#variables" class="hash-link" aria-label="Direct link to Variables" title="Direct link to Variables" translate="no">​</a></h3>
<p>Two variables stored in GitLab CI settings:</p>
<ul>
<li class=""><code>AWS_ROLE_ARN</code>: the ARN of the <code>your-role-name</code> role. Not a credential, so it's fine unprotected, but set as masked.</li>
<li class=""><code>TF_VAR_route53_zone_id</code>: the Route53 hosted zone ID for <code>mydomain.com</code>. Also unprotected and masked.</li>
</ul>
<p>Both must be <strong>unprotected</strong> because <code>tf:plan</code> runs on feature branches, and protected variables are only injected on protected branches. Restricting apply to main in CI rules is sufficient access control. There are ways to lock this down tighter but this should be sufficent to protect me, from myself.</p>
<p>The <code>TF_VAR_</code> prefix is the clean way to pass Terraform variables through CI; Terraform picks them up automatically without any extra configuration.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's Next<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-2#whats-next" class="hash-link" aria-label="Direct link to What's Next" title="Direct link to What's Next" translate="no">​</a></h2>
<p>Part 3 goes deeper into the Terraform itself: the actual resources, how they're organized, and a few design decisions worth explaining.</p>
<ul>
<li class=""><strong>Part 1:</strong> <a class="" href="https://benjaminheath.dev/blog/blog-subscription-service-part-1">The Why and the What</a></li>
<li class=""><strong>Part 3:</strong> <a class="" href="https://benjaminheath.dev/blog/blog-subscription-service-part-3">Terraform infrastructure deep dive</a></li>
<li class=""><strong>Part 4:</strong> <a class="" href="https://benjaminheath.dev/blog/blog-subscription-service-part-4">The Lambda Code</a></li>
</ul>
<p><em>This is part of the <a class="" href="https://benjaminheath.dev/blog/tags/blog-subscription-service">Blog Subscription Service</a> series.</em></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="references">References<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-2#references" class="hash-link" aria-label="Direct link to References" title="Direct link to References" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html" target="_blank" rel="noopener noreferrer" class="">AWS STS: AssumeRoleWithWebIdentity</a></li>
<li class=""><a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html" target="_blank" rel="noopener noreferrer" class="">AWS IAM: Creating OIDC identity providers</a></li>
<li class=""><a href="https://docs.gitlab.com/ci/cloud_services/aws/" target="_blank" rel="noopener noreferrer" class="">GitLab CI/CD: Connect to AWS with OpenID Connect</a></li>
</ul>]]></content>
        <author>
            <name>Benjamin Heath</name>
            <uri>https://www.linkedin.com/in/benjamin-heath-738610132/</uri>
        </author>
        <category label="aws" term="aws"/>
        <category label="terraform" term="terraform"/>
        <category label="gitlab" term="gitlab"/>
        <category label="ci-cd" term="ci-cd"/>
        <category label="oidc" term="oidc"/>
        <category label="blog-subscription-service" term="blog-subscription-service"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[May 27th - Blog Subscription Service - Part 1: The Why and the What]]></title>
        <id>https://benjaminheath.dev/blog/blog-subscription-service-part-1</id>
        <link href="https://benjaminheath.dev/blog/blog-subscription-service-part-1"/>
        <updated>2026-05-27T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[I wanted a way for readers to get notified when a new post drops. I looked at the SaaS options Mailchimp, ConvertKit, Buttondown, and they all work fine. But I'd be handing subscriber data to a third party and paying for a service I could build myself. So, I built it. This is the first in a series of posts covering how it works.]]></summary>
        <content type="html"><![CDATA[<p>I wanted a way for readers to get notified when a new post drops. I looked at the SaaS options Mailchimp, ConvertKit, Buttondown, and they all work fine. But I'd be handing subscriber data to a third party and paying for a service I could build myself. So, I built it. This is the first in a series of posts covering how it works.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-problem">The Problem<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-1#the-problem" class="hash-link" aria-label="Direct link to The Problem" title="Direct link to The Problem" translate="no">​</a></h2>
<p>The blog runs on Docusaurus, deployed to GitLab Pages. It already generates an RSS feed at <code>/blog/rss.xml</code>. What I needed was something to watch that feed and email subscribers when a new post drops, plus a way for people to sign up and manage their subscription.</p>
<p>The requirements were simple:</p>
<ul>
<li class="">Subscribers sign up with their email</li>
<li class="">They get a confirmation email and click a link to opt in (double opt-in)</li>
<li class="">When a new post drops, they get an email with a summary and a link</li>
<li class="">They can unsubscribe at any time</li>
</ul>
<p>No tracking pixels, no analytics, no third-party data sharing.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-not-a-saas">Why Not a SaaS?<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-1#why-not-a-saas" class="hash-link" aria-label="Direct link to Why Not a SaaS?" title="Direct link to Why Not a SaaS?" translate="no">​</a></h2>
<p>The honest answer is that it's more interesting to build. But there are more legit reasons too:</p>
<ul>
<li class=""><strong>Cost</strong> - at small scale, the AWS services involved are essentially free. SaaS tools charge per subscriber or per send.</li>
<li class=""><strong>Content</strong> - building it gave me something worth writing about.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-architecture">The Architecture<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-1#the-architecture" class="hash-link" aria-label="Direct link to The Architecture" title="Direct link to The Architecture" translate="no">​</a></h2>
<p>The whole thing runs on AWS, managed with Terraform (<em>I'm pretty new to mermaid so these should improve as time goes on</em>) :</p>
<!-- -->
<p><strong>API Gateway (HTTP API)</strong> sits at <code>api.mydomain.com</code> and routes three endpoints to a single Lambda: <code>POST /subscribe</code>, <code>GET /confirm</code>, and <code>GET /unsubscribe</code>.</p>
<p><strong>DynamoDB</strong> is a single table <code>blog-subscribers</code> with email as the partition key. Each item stores the email, a confirmed flag, and a UUID token used to validate confirmation and unsubscribe requests. A GSI(Global Secondary Index) on the <code>confirmed</code> attribute lets the poller query only confirmed subscribers efficiently.</p>
<p><strong>SES</strong> handles all outbound email from a custom domain address. Domain identity, DKIM, and a custom MAIL FROM domain are all configured via Terraform.</p>
<p><strong>EventBridge</strong> triggers the poller Lambda on a daily cron. The poller compares the RSS feed against a <code>last_seen</code> timestamp stored in DynamoDB and only sends emails for new posts.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-this-is-not">What This Is Not<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-1#what-this-is-not" class="hash-link" aria-label="Direct link to What This Is Not" title="Direct link to What This Is Not" translate="no">​</a></h2>
<p>This isn't a marketing platform. There's no open tracking, no click tracking, and no segmentation. It's a plain-text (and HTML) email that says "new post dropped, here's the link." That's it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's Next<a href="https://benjaminheath.dev/blog/blog-subscription-service-part-1#whats-next" class="hash-link" aria-label="Direct link to What's Next" title="Direct link to What's Next" translate="no">​</a></h2>
<p>The next post covers the CI/CD setup. How GitLab authenticates to AWS without storing any credentials and how the permission model is structured to limit blast radius.</p>
<ul>
<li class=""><strong>Part 2:</strong> <a class="" href="https://benjaminheath.dev/blog/blog-subscription-service-part-2">OIDC authentication and the Terraform CI pipeline</a></li>
<li class=""><strong>Part 3:</strong> <a class="" href="https://benjaminheath.dev/blog/blog-subscription-service-part-3">Terraform infrastructure deep dive</a></li>
<li class=""><strong>Part 4:</strong> <a class="" href="https://benjaminheath.dev/blog/blog-subscription-service-part-4">The Lambda Code</a></li>
</ul>
<p><em>This is part of the <a class="" href="https://benjaminheath.dev/blog/tags/blog-subscription-service">Blog Subscription Service</a> series.</em></p>]]></content>
        <author>
            <name>Benjamin Heath</name>
            <uri>https://www.linkedin.com/in/benjamin-heath-738610132/</uri>
        </author>
        <category label="aws" term="aws"/>
        <category label="terraform" term="terraform"/>
        <category label="serverless" term="serverless"/>
        <category label="blog-subscription-service" term="blog-subscription-service"/>
        <category label="architecture" term="architecture"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[May 12th - Small Beginnings]]></title>
        <id>https://benjaminheath.dev/blog/small-beginnings</id>
        <link href="https://benjaminheath.dev/blog/small-beginnings"/>
        <updated>2026-05-12T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Alrighty I wiped out the sample and testing blogs posts because I have figured out what the first real blog post will be about (well after this one).]]></summary>
        <content type="html"><![CDATA[<p>Alrighty I wiped out the sample and testing blogs posts because I have figured out what the first real blog post will be about (well after this one).</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-idea">The idea<a href="https://benjaminheath.dev/blog/small-beginnings#the-idea" class="hash-link" aria-label="Direct link to The idea" title="Direct link to The idea" translate="no">​</a></h2>
<p>After completing the intial setup of benjaminheath.dev I wandered around for a while in my mind wondering what do next? I could make any number of random projects to illustrate that I have used terraform, aws, etc before, but the question is WHY?</p>
<p>After sitting for a while I started to research adding either an RSS feed or a email subscription functionality to the blog for the weirdo's out there who actually find interest in my rambles. I found some SaaS solutions that would work for this functionality but then it dawned on me, what if I built it and use that as the content for one or more blog posts?</p>
<p>More to come on that in an upcoming post!</p>]]></content>
        <author>
            <name>Benjamin Heath</name>
            <uri>https://www.linkedin.com/in/benjamin-heath-738610132/</uri>
        </author>
        <category label="first" term="first"/>
        <category label="idea" term="idea"/>
        <category label="email" term="email"/>
        <category label="subscribe" term="subscribe"/>
    </entry>
</feed>