<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Terraform on PHP Boy Scout</title><link>https://phpboyscout.uk/tags/terraform/</link><description>Recent content in Terraform on PHP Boy Scout</description><generator>Hugo -- gohugo.io</generator><language>en-gb</language><copyright>Matt Cockayne</copyright><lastBuildDate>Tue, 14 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://phpboyscout.uk/tags/terraform/index.xml" rel="self" type="application/rss+xml"/><item><title>Wire up an AWS billing alarm before the surprise</title><link>https://phpboyscout.uk/wire-up-an-aws-billing-alarm-before-the-surprise/</link><pubDate>Tue, 14 Jul 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/wire-up-an-aws-billing-alarm-before-the-surprise/</guid><description>&lt;img src="https://phpboyscout.uk/wire-up-an-aws-billing-alarm-before-the-surprise/cover-wire-up-an-aws-billing-alarm-before-the-surprise.png" alt="Featured image of post Wire up an AWS billing alarm before the surprise" /&gt;&lt;p&gt;Everybody who has run anything on AWS carries the same low-grade dread: the bill you don&amp;rsquo;t see coming. A cron that spins up an instance and forgets to kill it, a misconfigured NAT gateway billing you by the gigabyte, a test that leaves a fleet running over a bank holiday weekend. The fix is well known and takes ten minutes: set a budget, and have it shout at you when spend crosses a line.&lt;/p&gt;
&lt;p&gt;The trap is subtler, and it&amp;rsquo;s the reason for this post. You can set that budget, wire it to a topic, see the notification sitting in the console exactly as you configured it, feel thoroughly responsible&amp;hellip; and then hear nothing at all on the day spend sails past your line. A billing alarm that doesn&amp;rsquo;t fire is worse than no alarm, because you&amp;rsquo;ve stopped watching for the thing yourself. So here&amp;rsquo;s how to wire one up properly, and the one piece AWS will actively nudge you into getting wrong.&lt;/p&gt;
&lt;h2 id="the-easy-part-the-budget"&gt;The easy part: the budget
&lt;/h2&gt;&lt;p&gt;A budget is one resource. Give it a monthly cost limit and a threshold to shout at:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-hcl" data-lang="hcl"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;aws_budgets_budget&amp;#34; &amp;#34;monthly_cost&amp;#34;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;monthly-cost&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; budget_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;COST&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; limit_amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;50&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; limit_unit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;USD&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; time_unit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;MONTHLY&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;notification&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; comparison_operator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;GREATER_THAN&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; threshold_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;PERCENTAGE&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; notification_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;ACTUAL&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; subscriber_sns_topic_arns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;aws_sns_topic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;arn&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;You can point a notification straight at an email address, and plenty of people do. I&amp;rsquo;d send it to an SNS topic instead, because a topic is a fan-out point: today it emails you, tomorrow it also pokes Slack, a Lambda, or your on-call rota, and you change none of the budget wiring to do it. If you&amp;rsquo;ve built anything security-conscious you probably already have such a topic. The public &lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-security-baseline" target="_blank" rel="noopener"
 &gt;&lt;code&gt;terraform-aws-security-baseline&lt;/code&gt;&lt;/a&gt; module, for instance, stands up an alerts topic wired to shout about GuardDuty findings and root-account activity. Hanging your cost alarm off the same wire, so everything that matters arrives in one place, is a tidy instinct.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s also exactly where the trap is waiting.&lt;/p&gt;
&lt;h2 id="the-hard-part-encryption-locks-the-alarm-out"&gt;The hard part: encryption locks the alarm out
&lt;/h2&gt;&lt;p&gt;A well-built alerts topic is encrypted, and it should be. The security-baseline one uses a customer-managed KMS key, so nothing sensitive ever sits in plaintext on the topic. Encrypt the topic, point your budget at it, and nothing about the setup will look wrong. The budget is real, the notification is configured against the topic, &lt;code&gt;terraform apply&lt;/code&gt; is happy. But the alarm won&amp;rsquo;t fire, and AWS is going to be spectacularly unhelpful about why.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s the mechanism, because it&amp;rsquo;s worth understanding rather than just pasting the fix. Most things that feed an alerts topic arrive through EventBridge, which publishes on your behalf. A budget doesn&amp;rsquo;t. AWS Budgets publishes to SNS directly, as its own service, under the principal &lt;code&gt;budgets.amazonaws.com&lt;/code&gt;. To put a message onto an &lt;em&gt;encrypted&lt;/em&gt; topic, whoever&amp;rsquo;s publishing has to encrypt it first, and that means being allowed to use the topic&amp;rsquo;s KMS key. EventBridge was granted that. The budget service was not.&lt;/p&gt;
&lt;p&gt;You can see the grant the topic already makes in the module&amp;rsquo;s key policy: EventBridge is named as a user of the key, allowed to generate a data key and decrypt so it can encrypt what it publishes (&lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-security-baseline/-/blob/v0.2.0/modules/alerts/main.tf#L42-L59" target="_blank" rel="noopener"
 &gt;&lt;code&gt;alerts/main.tf&lt;/code&gt;&lt;/a&gt;). There&amp;rsquo;s no equivalent line for &lt;code&gt;budgets.amazonaws.com&lt;/code&gt;, because the module was built for security findings, not cost. So the budget goes to publish, can&amp;rsquo;t touch the key, and the alert never leaves the building.&lt;/p&gt;
&lt;p&gt;And here&amp;rsquo;s the bit that turns a five-minute fix into a lost afternoon: AWS&amp;rsquo;s own guidance points you the wrong way. Try to attach a budget to an encrypted topic in the console and it stops you with &lt;em&gt;&amp;ldquo;The SNS topic is encrypted. The SNS topic won&amp;rsquo;t work without additional permissions. Disable encryption on the topic.&amp;rdquo;&lt;/em&gt; Read that twice. Faced with an encrypted topic a budget can&amp;rsquo;t use, AWS&amp;rsquo;s headline suggestion is to turn the encryption off. Don&amp;rsquo;t. You don&amp;rsquo;t rip out a good security control to make a billing alert work. And the belated warning you might get when a budget can&amp;rsquo;t publish, an out-of-band note asking you to make sure budgets is on the topic&amp;rsquo;s list of allowed publishers, points straight back at the topic&amp;rsquo;s access policy. Neither message ever mentions the key. Every signal AWS hands you is aimed at the wrong thing, and the real fix is to grant the alert access to the key, exactly the way the module already did for EventBridge.&lt;/p&gt;
&lt;h2 id="the-two-grants-that-fix-it"&gt;The two grants that fix it
&lt;/h2&gt;&lt;p&gt;The budget needs the same treatment EventBridge got, in two places. First, on the KMS key policy, name the budget service as a user of the key (&lt;a class="link" href="https://docs.aws.amazon.com/cost-management/latest/userguide/budgets-sns-policy.html" target="_blank" rel="noopener"
 &gt;AWS documents this exact requirement&lt;/a&gt;):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-hcl" data-lang="hcl"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;statement&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; sid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;AllowBudgetsPublishViaTopic&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; effect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Allow&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;principals&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Service&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; identifiers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;budgets.amazonaws.com&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; actions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;kms:GenerateDataKey*&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;kms:Decrypt&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; resources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;*&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Second, on the SNS topic policy, let the budget publish:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-hcl" data-lang="hcl"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;statement&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; sid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;AllowBudgetsPublish&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; effect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Allow&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;principals&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Service&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; identifiers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;budgets.amazonaws.com&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; actions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;sns:Publish&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; resources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;aws_sns_topic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;arn&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;condition&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; test&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;StringEquals&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; variable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;aws:SourceAccount&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;aws_caller_identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;condition&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; test&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;ArnLike&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; variable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;aws:SourceArn&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;arn:aws:budgets::${data.aws_caller_identity.current.account_id}:*&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Those two &lt;code&gt;aws:SourceAccount&lt;/code&gt; and &lt;code&gt;aws:SourceArn&lt;/code&gt; conditions are AWS&amp;rsquo;s documented shape for the budget topic policy, and they&amp;rsquo;re worth keeping: they stop any other account&amp;rsquo;s budget from ever using your topic (the confused-deputy guard the baseline already applies to its EventBridge statements). The pair of grants above are otherwise twins of what the module writes for EventBridge, with one principal swapped. Add them and the message flows.&lt;/p&gt;
&lt;p&gt;One thing that catches people out: this only works with a &lt;strong&gt;customer-managed&lt;/strong&gt; key. If your topic uses the default AWS-managed &lt;code&gt;aws/sns&lt;/code&gt; key, you can&amp;rsquo;t edit its policy to add a service principal, so a budget can never publish to it. That&amp;rsquo;s a large part of why the security-baseline module brings its own CMK, and it&amp;rsquo;s the difference between &amp;ldquo;encrypted&amp;rdquo; and &amp;ldquo;encrypted and actually usable by the things that need it&amp;rdquo;.&lt;/p&gt;
&lt;h2 id="watch-it-fire-once"&gt;Watch it fire once
&lt;/h2&gt;&lt;p&gt;The most important step is the one people skip, and it&amp;rsquo;s the whole reason I wrote this down. I won&amp;rsquo;t believe an alarm works until I&amp;rsquo;ve watched it fire, and a billing alarm is no exception. Temporarily drop the threshold to something you&amp;rsquo;re certain you&amp;rsquo;ve already crossed this month, a single percent if you like, apply, and wait for the message to actually land in your inbox or your Slack. When it does, you know the whole chain is live: budget, topic policy, key policy, subscription. Then put the threshold back.&lt;/p&gt;
&lt;p&gt;It takes five minutes and it&amp;rsquo;s the difference between an alarm and a decoration. The budget was always the easy 80%. The KMS key policy is the fiddly 20% that decides whether, on the day a runaway resource starts eating your account, the thing pages you or just watches.&lt;/p&gt;</description></item><item><title>Sign your own binaries with go-tool-base, part 2: a signing key in AWS KMS</title><link>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-2/</link><pubDate>Wed, 17 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-2/</guid><description>&lt;img src="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-2/cover-sign-your-own-binaries-with-go-tool-base-part-2.png" alt="Featured image of post Sign your own binaries with go-tool-base, part 2: a signing key in AWS KMS" /&gt;&lt;p&gt;&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-1/" &gt;Part 1&lt;/a&gt;
left you with a working signing loop and one glaring weakness: the private key
was a &lt;code&gt;.pem&lt;/code&gt; on your laptop, and files get copied. This part fixes that. You&amp;rsquo;ll
generate the production signing key inside AWS KMS, where it&amp;rsquo;s created and never
comes out, and stand up a role that can sign with it. The key itself is the only
thing we build here; wiring CI in over OIDC is Part 3.&lt;/p&gt;
&lt;p&gt;The big idea is the same one the
&lt;a class="link" href="https://phpboyscout.uk/a-signing-key-that-never-leaves-kms/" &gt;deep-dive&lt;/a&gt;
spends its whole length on, so I&amp;rsquo;ll keep it short: you never hold the private
key and you never sign &lt;em&gt;with&lt;/em&gt; it. You ask KMS to sign on your behalf with
&lt;code&gt;kms:Sign&lt;/code&gt;, and the private half stays inside the HSM for its entire life. There
is no export, no download, no &amp;ldquo;just this once&amp;rdquo; copy onto a runner. An attacker
who owns your CI still can&amp;rsquo;t walk away with the key, because the key was never on
the runner to begin with.&lt;/p&gt;
&lt;h2 id="what-youll-need-first"&gt;What you&amp;rsquo;ll need first
&lt;/h2&gt;&lt;p&gt;This part is OpenTofu (or Terraform, the module is fine with either). You&amp;rsquo;ll
need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;An AWS account you can apply infrastructure into.&lt;/li&gt;
&lt;li&gt;An IAM OIDC identity provider already registered in that account. KMS doesn&amp;rsquo;t
need it, but the signer role we create trusts it, so it has to exist. If you
haven&amp;rsquo;t got one, the sibling module
&lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-bootstrap" target="_blank" rel="noopener"
 &gt;&lt;code&gt;terraform-aws-bootstrap&lt;/code&gt;&lt;/a&gt;
provisions it (it&amp;rsquo;s the same family as the
&lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-security-baseline" target="_blank" rel="noopener"
 &gt;&lt;code&gt;terraform-aws-security-baseline&lt;/code&gt;&lt;/a&gt;
module). Its &lt;code&gt;oidc_provider_arn&lt;/code&gt; output is exactly what we feed in below.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You don&amp;rsquo;t need a CLI, a pipeline or a public key yet. This is just the vault and
the key that lives in it.&lt;/p&gt;
&lt;h2 id="the-module"&gt;The module
&lt;/h2&gt;&lt;p&gt;The key, its alias, the signer role and the full key policy come from one public
module,
&lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-signing-kms" target="_blank" rel="noopener"
 &gt;&lt;code&gt;terraform-aws-signing-kms&lt;/code&gt;&lt;/a&gt;.
Here&amp;rsquo;s the whole consumer block:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-hcl" data-lang="hcl"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;aws_iam_openid_connect_provider&amp;#34; &amp;#34;gitlab&amp;#34;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;https://gitlab.com&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;signing_kms&amp;#34;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;gitlab.com/phpboyscout/signing-kms/aws&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;0.1.2&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;acme-release-signing-v1&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Acme release binary signing&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; oidc_provider_arn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;aws_iam_openid_connect_provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;gitlab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;arn&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; ci_subject_filters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;project_path:acme/acme-cli:ref_type:tag:ref:v*&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; key_administrator_arns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="cm"&gt; /* operator role + account root */&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; automation_role_arn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;automation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;arn&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}&lt;span class="c1"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Carry these forward: Part 3 wires the signer role into CI, Part 4 mints
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# the public key from the alias.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;signer_role_arn&amp;#34;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;signing_kms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;signer_role_arn&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;signing_key_alias&amp;#34;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;signing_kms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;key_alias_name&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A few of those values are doing more than they look, so let&amp;rsquo;s walk them.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;name&lt;/code&gt; is &lt;code&gt;acme-release-signing-v1&lt;/code&gt;, with the &lt;code&gt;v1&lt;/code&gt; on the end deliberately. The
name derives the role (&lt;code&gt;&amp;lt;name&amp;gt;-signer&lt;/code&gt;) and the alias (&lt;code&gt;alias/&amp;lt;name&amp;gt;&lt;/code&gt;), and both
of those want to outlive the key. When you rotate to a new key in Part 7 you&amp;rsquo;ll
mint a &lt;code&gt;-v2&lt;/code&gt; and repoint things, so bake the version in now rather than wishing
you had.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ci_subject_filters&lt;/code&gt; is in GitLab&amp;rsquo;s OIDC &lt;code&gt;sub&lt;/code&gt; format here, and it&amp;rsquo;s the line
that says &lt;em&gt;which&lt;/em&gt; pipeline is allowed to assume the signer role: tag pipelines
for any &lt;code&gt;v*&lt;/code&gt; ref on &lt;code&gt;acme/acme-cli&lt;/code&gt;, and nothing else (no branch builds, no merge
requests). It&amp;rsquo;s the heart of Part 3, so I&amp;rsquo;ll leave the full explanation there.
For now, know it&amp;rsquo;s not optional: an empty list would trust every token from the
issuer, and the module refuses to let you do that.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;key_administrator_arns&lt;/code&gt; and &lt;code&gt;automation_role_arn&lt;/code&gt; are the two roles that &lt;em&gt;manage&lt;/em&gt;
the key, and the distinction between them matters. More on that in a moment.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tofu init
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tofu apply
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="what-you-just-built"&gt;What you just built
&lt;/h2&gt;&lt;p&gt;A single asymmetric KMS key, plus the IAM scaffolding around it. The key is
created with &lt;code&gt;key_usage = SIGN_VERIFY&lt;/code&gt; and the spec &lt;code&gt;RSA_4096&lt;/code&gt;, which is the
module&amp;rsquo;s default and the one you want.&lt;/p&gt;
&lt;p&gt;The obvious question, if you&amp;rsquo;ve signed things before, is why RSA-4096 and not
Ed25519, which is smaller and faster. Two reasons, and neither is preference. The
first is that AWS KMS simply doesn&amp;rsquo;t offer Ed25519 for asymmetric signing, so
it&amp;rsquo;s off the table the moment you decide the key lives in KMS. The second is that
OpenPGP, the format your signatures end up in, ties its packet encoding to the
signing algorithm: the algorithm isn&amp;rsquo;t a detail you can swap underneath, it&amp;rsquo;s
written into the bytes. RSA-4096 is the spec that satisfies both constraints, so
it&amp;rsquo;s the secure default and you shouldn&amp;rsquo;t need to touch it.&lt;/p&gt;
&lt;p&gt;Two more things to note about the key itself:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;enable_key_rotation&lt;/code&gt; is off, and that&amp;rsquo;s intentional. AWS&amp;rsquo;s automatic yearly
rotation only works on symmetric keys; an asymmetric &lt;code&gt;SIGN_VERIFY&lt;/code&gt; key can&amp;rsquo;t be
auto-rotated, because a new key would mean a new public half and every embedded
trust anchor breaking at once. Rotation for signing keys is a deliberate,
staged operation (mint a new key, publish it, repoint the alias), which is its
own part later in the series.&lt;/li&gt;
&lt;li&gt;The deletion window defaults to 30 days, the longest AWS allows. For a key this
important, the longest possible &amp;ldquo;oops, undo&amp;rdquo; window is the safe choice.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The module also creates a stable alias, &lt;code&gt;alias/acme-release-signing-v1&lt;/code&gt;. Always
reference the key through its alias, never the raw key ID. The alias is what
survives rotation: when v2 arrives, the alias gets repointed and everything
calling through it keeps working.&lt;/p&gt;
&lt;h2 id="four-principals-one-key-policy"&gt;Four principals, one key policy
&lt;/h2&gt;&lt;p&gt;This is the part worth slowing down for. A KMS key is governed by its &lt;em&gt;key
policy&lt;/em&gt;, and this module writes one policy that names four classes of principal,
each with deliberately different reach:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The account root&lt;/strong&gt; keeps &lt;code&gt;kms:*&lt;/code&gt;. That&amp;rsquo;s AWS&amp;rsquo;s recommended break-glass: if
every other path is locked out, the account owner can still recover.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The key administrators&lt;/strong&gt; (&lt;code&gt;key_administrator_arns&lt;/code&gt;, typically your operator
role plus root) can administer the key, schedule its deletion, that sort of
thing. They are &lt;em&gt;not&lt;/em&gt; signers, and they can&amp;rsquo;t sign.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The automation role&lt;/strong&gt; (&lt;code&gt;automation_role_arn&lt;/code&gt;, the role your infra apply
pipeline assumes) can manage the key as a Terraform resource, read it, tag it,
even change its policy. What it deliberately &lt;em&gt;cannot&lt;/em&gt; do is &lt;code&gt;kms:Sign&lt;/code&gt;. Think
about why: the role that applies your infrastructure runs on every change, so
if owning that role let an attacker mint signatures, you&amp;rsquo;d have handed the
whole point of the exercise back. Managing the key and using the key are two
different powers, and only the signer gets the second one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The signer role&lt;/strong&gt; can call &lt;code&gt;kms:Sign&lt;/code&gt;, &lt;code&gt;kms:GetPublicKey&lt;/code&gt; and
&lt;code&gt;kms:DescribeKey&lt;/code&gt;, on this one key, and that&amp;rsquo;s the entire list. It can&amp;rsquo;t read
other keys, can&amp;rsquo;t administer this one, can&amp;rsquo;t delete anything.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Here&amp;rsquo;s the detail that catches people out: the signer role has &lt;strong&gt;no attached IAM
policy at all&lt;/strong&gt;. Its permissions live entirely in the key policy. That&amp;rsquo;s not an
oversight, it&amp;rsquo;s the design. One document, the key policy, is the single source of
truth for who can do what to this key, so there&amp;rsquo;s no second place to check and no
way for an attached role policy to drift out of sync with the key policy and
quietly grant something nobody intended. If you want to know who can sign, you
read one file.&lt;/p&gt;
&lt;p&gt;The signer role is assumable via OIDC (those &lt;code&gt;ci_subject_filters&lt;/code&gt; again), which
is what lets a CI job step into it without any stored credential. That federation
is Part 3&amp;rsquo;s whole job.&lt;/p&gt;
&lt;h2 id="the-outputs-youll-carry-forward"&gt;The outputs you&amp;rsquo;ll carry forward
&lt;/h2&gt;&lt;p&gt;The module hands back everything the later parts consume. The ones you&amp;rsquo;ll
actually use:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;key_id&lt;/code&gt; and &lt;code&gt;key_arn&lt;/code&gt;, the bare ID and full ARN of the key.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;key_alias_name&lt;/code&gt; and &lt;code&gt;key_alias_arn&lt;/code&gt;, the stable alias. &lt;code&gt;key_alias_name&lt;/code&gt; is
the one you&amp;rsquo;ll pass to &lt;code&gt;gtb&lt;/code&gt; as the KMS key reference, because it survives
rotation.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;signer_role_arn&lt;/code&gt; and &lt;code&gt;signer_role_name&lt;/code&gt;, the role CI assumes to sign.
&lt;code&gt;signer_role_arn&lt;/code&gt; becomes an environment variable in your pipeline next part.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Keep these to hand. Part 3 needs &lt;code&gt;signer_role_arn&lt;/code&gt;; Part 4 needs
&lt;code&gt;key_alias_name&lt;/code&gt; to mint the public key out of KMS.&lt;/p&gt;
&lt;h2 id="what-this-costs"&gt;What this costs
&lt;/h2&gt;&lt;p&gt;Worth a quick word, because this is the one part of the series that puts a line
on an AWS bill. An asymmetric KMS key runs about a dollar a month, plus a tiny
per-signature charge on the &lt;code&gt;kms:Sign&lt;/code&gt; calls. For release signing, where you sign
a handful of checksums files a month, the per-signature cost rounds to nothing.
A dollar a month for a key that can&amp;rsquo;t be stolen off a laptop is the cheapest
security control in this entire series.&lt;/p&gt;
&lt;h2 id="where-this-leaves-you"&gt;Where this leaves you
&lt;/h2&gt;&lt;p&gt;The key exists, it lives somewhere it can never leave, and there&amp;rsquo;s a role that
can sign with it (and a separate role that pointedly can&amp;rsquo;t). What there &lt;em&gt;isn&amp;rsquo;t&lt;/em&gt;
yet is any way for your release pipeline to become that role without a
long-lived credential sitting in CI.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the gap &lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-3/" &gt;Part 3&lt;/a&gt;
closes: federating GitLab and GitHub into the signer role over OIDC, so a tagged
release can assume it for the length of one job and nothing is stored anywhere.
The &lt;code&gt;ci_subject_filters&lt;/code&gt; line we glossed over here is where it starts.&lt;/p&gt;</description></item><item><title>One graph, not micro-stacks</title><link>https://phpboyscout.uk/one-graph-not-micro-stacks/</link><pubDate>Sun, 17 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/one-graph-not-micro-stacks/</guid><description>&lt;img src="https://phpboyscout.uk/one-graph-not-micro-stacks/cover-one-graph-not-micro-stacks.png" alt="Featured image of post One graph, not micro-stacks" /&gt;&lt;p&gt;Once an infrastructure repo has a few concerns in it (account hardening, the security baseline, the signing stack still to come) there&amp;rsquo;s a steady pressure to split them into separate stacks with separate state, and Terragrunt is right there to help you do it. The &lt;code&gt;infra&lt;/code&gt; repo keeps everything in one OpenTofu graph instead. The reason comes down to who enforces your dependency ordering: the engine, or you.&lt;/p&gt;
&lt;h2 id="the-pressure-to-split"&gt;The pressure to split
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;infra&lt;/code&gt; repo&amp;rsquo;s &lt;code&gt;src/&lt;/code&gt; has several concerns in it, and more coming, the signing stack among them. Once a repo reaches that point, there&amp;rsquo;s a steady pressure to split: one stack per concern, each with its own state file.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s an appealing pressure. Separate stacks feel modular. Each &lt;code&gt;apply&lt;/code&gt; touches less, so the blast radius of any one run is smaller. And Terragrunt exists, popular and well-regarded, precisely to orchestrate a fleet of separate stacks. The path is well trodden.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;infra&lt;/code&gt; didn&amp;rsquo;t take it. &lt;code&gt;src/&lt;/code&gt; is a single OpenTofu root stack: each concern is a &lt;code&gt;module&lt;/code&gt; block, in its own &lt;code&gt;main.&amp;lt;concern&amp;gt;.tf&lt;/code&gt; file, all sharing one state and one graph.&lt;/p&gt;
&lt;h2 id="what-one-graph-gives-you"&gt;What one graph gives you
&lt;/h2&gt;&lt;p&gt;The thing a single graph gives you is engine-enforced truth about ordering and data.&lt;/p&gt;
&lt;p&gt;Inside one OpenTofu graph, the tool builds the full dependency DAG itself. When the signing stack needs a value the security baseline produced, you reference it directly, &lt;code&gt;module.baseline.something&lt;/code&gt;, and OpenTofu &lt;em&gt;guarantees&lt;/em&gt; two things: the baseline is created before the thing that depends on it, and the value handed across is the current one from this same apply. Ordering and data-passing aren&amp;rsquo;t things you arranged. They&amp;rsquo;re facts the engine checks and enforces, every plan, every apply.&lt;/p&gt;
&lt;h2 id="what-splitting-costs"&gt;What splitting costs
&lt;/h2&gt;&lt;p&gt;Split &lt;code&gt;src/&lt;/code&gt; into per-concern stacks with separate state, and that guarantee is the thing you spend.&lt;/p&gt;
&lt;p&gt;Now one stack reads another&amp;rsquo;s outputs through &lt;code&gt;terraform_remote_state&lt;/code&gt;. That&amp;rsquo;s a lookup of a &lt;em&gt;snapshot&lt;/em&gt;: the other stack&amp;rsquo;s last applied state, whatever it was, whenever that was. It&amp;rsquo;s not a live edge in a graph. Ordering is no longer enforced by the engine either; it becomes something you arrange yourself, in CI stage sequencing or in Terragrunt&amp;rsquo;s own dependency blocks.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the trade, stated plainly. You give up a strong, engine-checked guarantee, and you buy back a weaker, hand-arranged imitation of it. Terragrunt is a good tool for managing that weaker world tidily. But the question worth asking first is whether you should be in the weaker world at all.&lt;/p&gt;
&lt;h2 id="when-splitting-is-genuinely-right"&gt;When splitting is genuinely right
&lt;/h2&gt;&lt;p&gt;This isn&amp;rsquo;t an argument that splitting is always wrong. Separate states genuinely earn their place when concerns have different change cadences, different access boundaries, or different teams owning them: when you actively &lt;em&gt;want&lt;/em&gt; an apply of one to be unable to touch another, and you want different people holding different state.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;infra&lt;/code&gt; has none of those. It&amp;rsquo;s a single account, a single operator, one cohesive set of concerns. The only thing splitting would buy here is a smaller per-apply blast radius, and that&amp;rsquo;s better handled by reviewing the plan before it applies, which &lt;a class="link" href="https://phpboyscout.uk/reviewed-then-applied/" &gt;the next post&lt;/a&gt; is about, than by fragmenting the dependency graph. So &lt;code&gt;src/&lt;/code&gt; stays one graph, and Terragrunt was considered and deliberately not adopted.&lt;/p&gt;
&lt;h2 id="if-ordering-between-graphs-is-ever-needed"&gt;If ordering between graphs is ever needed
&lt;/h2&gt;&lt;p&gt;If &lt;code&gt;infra&lt;/code&gt; ever does genuinely need more than one stack, the plan isn&amp;rsquo;t Terragrunt. It&amp;rsquo;s to keep each stack a single strong graph internally, and to sequence the stacks with CI stages. Keep the engine-enforced guarantee where it&amp;rsquo;s strongest, inside each graph, and reach for hand-arranged ordering only at the one seam where it&amp;rsquo;s unavoidable.&lt;/p&gt;
&lt;h2 id="boiling-it-down"&gt;Boiling it down
&lt;/h2&gt;&lt;p&gt;A multi-concern infrastructure repo feels like it should be split into per-concern stacks, and Terragrunt is right there to manage the result. &lt;code&gt;infra&lt;/code&gt; keeps &lt;code&gt;src/&lt;/code&gt; as one OpenTofu graph instead.&lt;/p&gt;
&lt;p&gt;Inside one graph, OpenTofu enforces dependency ordering and passes current values across module boundaries as checked facts. Split into separate states and that becomes a &lt;code&gt;terraform_remote_state&lt;/code&gt; snapshot lookup plus ordering you arrange by hand: a weaker version of what you gave up. Splitting is right when concerns have different cadences, boundaries or owners; for a single-account, single-operator repo none of that applies, so the strong guarantee is worth keeping, and Terragrunt is the tool for a problem &lt;code&gt;infra&lt;/code&gt; chose not to have.&lt;/p&gt;</description></item><item><title>Why I hand-rolled every module</title><link>https://phpboyscout.uk/why-i-hand-rolled-every-module/</link><pubDate>Sun, 10 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/why-i-hand-rolled-every-module/</guid><description>&lt;img src="https://phpboyscout.uk/why-i-hand-rolled-every-module/cover-why-i-hand-rolled-every-module.png" alt="Featured image of post Why I hand-rolled every module" /&gt;&lt;p&gt;There are well-known community module libraries for AWS: Cloud Posse, the &lt;code&gt;terraform-aws-modules&lt;/code&gt; collection, plenty more. Both &lt;code&gt;terraform-aws-bootstrap&lt;/code&gt; and &lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-security-baseline/-/tree/v0.2.0/modules" target="_blank" rel="noopener"
 &gt;&lt;code&gt;terraform-aws-security-baseline&lt;/code&gt;&lt;/a&gt; use almost none of them. Every sub-module is hand-rolled from raw AWS resources, and before you accuse me of not-invented-here syndrome (a perfectly fair first guess), hear me out, because the same evaluation kept landing the same way for a real reason.&lt;/p&gt;
&lt;h2 id="the-promise-of-a-wrapper-module"&gt;The promise of a wrapper module
&lt;/h2&gt;&lt;p&gt;The community module ecosystem makes an appealing offer. Don&amp;rsquo;t write raw &lt;code&gt;aws_s3_bucket&lt;/code&gt; and &lt;code&gt;aws_s3_bucket_policy&lt;/code&gt; and &lt;code&gt;aws_s3_bucket_public_access_block&lt;/code&gt; and the rest. Call a tested, popular module, pass it a handful of inputs, and get a correct, well-configured bucket. Less code in your repo, and the code you don&amp;rsquo;t write has been exercised by thousands of other users.&lt;/p&gt;
&lt;p&gt;For a lot of infrastructure that&amp;rsquo;s a genuinely good deal, and I take it often. For the two infrastructure modules in this series, I took it almost never. Every sub-module is built from raw AWS resources. That wasn&amp;rsquo;t a reflex. It was the same evaluation, made over and over, landing the same way.&lt;/p&gt;
&lt;h2 id="what-kept-going-wrong"&gt;What kept going wrong
&lt;/h2&gt;&lt;p&gt;For each place a wrapper module could have fitted, I looked at the wrapper. And the recurring finding was one of two things. Either using the wrapper &lt;em&gt;correctly&lt;/em&gt;, with all the overrides my posture needed, came to more configuration than the raw resources would have. Or the wrapper&amp;rsquo;s abstraction leaked the instant I needed something it hadn&amp;rsquo;t anticipated, and I was now writing code to fight it.&lt;/p&gt;
&lt;h2 id="the-cloudtrail-bucket-concretely"&gt;The CloudTrail bucket, concretely
&lt;/h2&gt;&lt;p&gt;The clearest example is the bucket that holds CloudTrail logs.&lt;/p&gt;
&lt;p&gt;There are popular modules that set up CloudTrail and bundle an S3 bucket for the logs. Convenient. But that bundled bucket isn&amp;rsquo;t the bucket I want. It doesn&amp;rsquo;t carry &lt;code&gt;lifecycle { prevent_destroy = true }&lt;/code&gt;, and its bucket policy is weaker than the one &lt;a class="link" href="https://phpboyscout.uk/a-state-bucket-that-defends-itself/" &gt;the state bucket&lt;/a&gt; taught me to want: TLS-only, SSE-KMS-only, wrong-key-denied.&lt;/p&gt;
&lt;p&gt;So to use the wrapper I had two options. Accept a weaker audit-log bucket than the rest of the account, which rather defeats the point of an audit log. Or fight the wrapper: disable its bucket, create my own, wire it back in. Fighting the wrapper is &lt;em&gt;more&lt;/em&gt; work than simply writing the fifty-odd lines of raw &lt;code&gt;aws_s3_bucket&lt;/code&gt; plus policy that give me exactly the posture I&amp;rsquo;d already designed once. The wrapper didn&amp;rsquo;t save code. It added a negotiation.&lt;/p&gt;
&lt;h2 id="a-wrapper-is-a-deal-and-deals-have-terms"&gt;A wrapper is a deal, and deals have terms
&lt;/h2&gt;&lt;p&gt;This isn&amp;rsquo;t an argument that community modules are bad. It&amp;rsquo;s an argument about when the deal is good.&lt;/p&gt;
&lt;p&gt;A wrapper module is a good deal while its abstraction holds: while what it assumes you want matches what you want. The moment you need something it didn&amp;rsquo;t anticipate, the deal inverts. Now you&amp;rsquo;re working &lt;em&gt;against&lt;/em&gt; the abstraction, and an abstraction you&amp;rsquo;re fighting costs more than no abstraction at all. (Regular readers will recognise that line from &lt;a class="link" href="https://phpboyscout.uk/an-ai-interface-that-fits-on-one-screen/" &gt;the LangChain argument&lt;/a&gt;; it&amp;rsquo;s the same principle in a very different language.)&lt;/p&gt;
&lt;p&gt;Infrastructure that holds signing keys is precisely the case where you need to control the specifics: every encryption setting, every lifecycle rule, every line of every bucket policy. That&amp;rsquo;s a domain where wrapper abstractions leak fast, because the whole job &lt;em&gt;is&lt;/em&gt; the details the wrapper smoothed over.&lt;/p&gt;
&lt;h2 id="the-cost-paid-on-purpose"&gt;The cost, paid on purpose
&lt;/h2&gt;&lt;p&gt;Hand-rolling isn&amp;rsquo;t free. It&amp;rsquo;s more lines of HCL in the repo, up front, than a one-line module call.&lt;/p&gt;
&lt;p&gt;What those lines buy is worth the price &lt;em&gt;for this kind of infrastructure&lt;/em&gt;. There&amp;rsquo;s no transitive module-version churn to track. There&amp;rsquo;s no abstraction between me and the resource when something behaves oddly. And every line is one I can read, and defend, in a security review, because I wrote it and it says exactly what it does. For a foundation that will hold the most sensitive key in the system, &amp;ldquo;readable and mine&amp;rdquo; beats &amp;ldquo;short and someone else&amp;rsquo;s&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s a deliberate trade, not a universal rule. For an internal tool on a deadline, reach for the wrapper. For the security-critical base of everything else, the raw resources won every time I checked.&lt;/p&gt;
&lt;h2 id="to-sum-up"&gt;To sum up
&lt;/h2&gt;&lt;p&gt;The community module ecosystem offers less code that more people have tested, and for plenty of infrastructure that&amp;rsquo;s the right call. For &lt;code&gt;terraform-aws-bootstrap&lt;/code&gt; and &lt;code&gt;terraform-aws-security-baseline&lt;/code&gt; it almost never was, because each wrapper turned out to be more configuration than the raw resources once my posture was accounted for, or it leaked the moment I needed a specific.&lt;/p&gt;
&lt;p&gt;The CloudTrail log bucket is the pattern in miniature: the bundled bucket lacked &lt;code&gt;prevent_destroy&lt;/code&gt; and a strong policy, so using the wrapper meant either a weaker bucket or fighting the module. A wrapper is a good deal while its abstraction holds and a bad one the moment you fight it, and security-critical foundation infrastructure is all specifics. Hand-rolling cost more lines and bought code I can read and defend. For this, that was the trade worth making.&lt;/p&gt;</description></item><item><title>The chicken-and-egg of remote state</title><link>https://phpboyscout.uk/the-chicken-and-egg-of-remote-state/</link><pubDate>Wed, 06 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-chicken-and-egg-of-remote-state/</guid><description>&lt;img src="https://phpboyscout.uk/the-chicken-and-egg-of-remote-state/cover-the-chicken-and-egg-of-remote-state.png" alt="Featured image of post The chicken-and-egg of remote state" /&gt;&lt;p&gt;Here&amp;rsquo;s a puzzle that every infrastructure-as-code setup hits exactly once, right at the very beginning, and then never again. An OpenTofu stack stores its state in a backend. &lt;a class="link" href="https://phpboyscout.uk/the-bootstrap-that-does-almost-nothing/" &gt;The bootstrap stack&lt;/a&gt; I wrote about last time has a particular job, and part of that job is to &lt;em&gt;create&lt;/em&gt; the backend that remote state lives in. So where does the bootstrap stack store its own state, on the very first run, before it&amp;rsquo;s built the place state is supposed to go?&lt;/p&gt;
&lt;h2 id="where-does-the-state-of-the-thing-that-makes-the-state-store-live"&gt;Where does the state of the thing that makes the state store live?
&lt;/h2&gt;&lt;p&gt;That&amp;rsquo;s the puzzle, and it&amp;rsquo;s a real ordering deadlock rather than a riddle.&lt;/p&gt;
&lt;p&gt;An OpenTofu stack keeps a state file, and for anything shared that state file lives in a remote backend: on AWS, an S3 bucket. Fine. But the bootstrap stack has a particular job, and part of that job is to &lt;em&gt;create the S3 bucket that remote state lives in&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;So walk through the first run. Bootstrap has never been applied. The state bucket doesn&amp;rsquo;t exist, because creating it is what bootstrap is for. Bootstrap needs somewhere to store its own state. The only place that would make sense is the bucket it&amp;rsquo;s about to create, which isn&amp;rsquo;t there yet. The thing that builds the state store can&amp;rsquo;t store its state in the state store.&lt;/p&gt;
&lt;h2 id="run-local-then-migrate"&gt;Run local, then migrate
&lt;/h2&gt;&lt;p&gt;The way out is a two-step that OpenTofu supports directly.&lt;/p&gt;
&lt;p&gt;Bootstrap starts configured with a &lt;em&gt;local&lt;/em&gt; backend: &lt;code&gt;backend &amp;quot;local&amp;quot; {}&lt;/code&gt;. State is just a file on the operator&amp;rsquo;s machine. With that in place, the first &lt;code&gt;tofu apply&lt;/code&gt; runs. It creates the S3 bucket and the KMS key, and records all of it in the local state file.&lt;/p&gt;
&lt;p&gt;Now the bucket exists. So the backend configuration is rewritten to point at it: an &lt;code&gt;s3&lt;/code&gt; backend block naming the new bucket. Then &lt;code&gt;tofu init -migrate-state&lt;/code&gt;. OpenTofu sees the backend has changed, picks up the local state file, and copies it into the S3 bucket. From that point on, bootstrap&amp;rsquo;s own state lives in the bucket that bootstrap created. The egg has laid the chicken.&lt;/p&gt;
&lt;p&gt;The local backend was a scaffold. It existed for exactly one apply, to break the ordering deadlock, and then the state moved off it and it was never used again.&lt;/p&gt;
&lt;h2 id="it-happened-twice"&gt;It happened twice
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;infra&lt;/code&gt; repo actually did this migration twice, and the second time is the proof that the pattern is general rather than a one-off trick.&lt;/p&gt;
&lt;p&gt;The first migration was the one above: local to S3, at the very start. The second came later, during the &lt;a class="link" href="https://phpboyscout.uk/why-we-left-github-for-gitlab/" &gt;move from GitHub to GitLab&lt;/a&gt;. GitLab offers a managed HTTP state backend, and &lt;code&gt;infra&lt;/code&gt; chose to use it. So the backend block was rewritten again, this time from &lt;code&gt;s3&lt;/code&gt; to &lt;code&gt;http&lt;/code&gt;, and &lt;code&gt;tofu init -migrate-state&lt;/code&gt; ran again, copying the state from the S3 bucket to GitLab&amp;rsquo;s backend.&lt;/p&gt;
&lt;p&gt;The same move, twice, against three different backends. That&amp;rsquo;s the useful lesson hiding in the chicken-and-egg story. State is portable. The backend is just &lt;em&gt;where you currently keep it&lt;/em&gt;, not a property of the stack itself, and moving it is a routine, supported operation rather than surgery.&lt;/p&gt;
&lt;h2 id="why-this-is-the-honest-answer-not-a-hack"&gt;Why this is the honest answer, not a hack
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s easy to look at &amp;ldquo;apply once with a local backend, then migrate&amp;rdquo; and feel it&amp;rsquo;s a bit of a smell, a workaround for something that should have been cleaner.&lt;/p&gt;
&lt;p&gt;It isn&amp;rsquo;t. It&amp;rsquo;s the honest answer to a real ordering problem, and the alternatives are worse.&lt;/p&gt;
&lt;p&gt;The obvious alternative is to create the state bucket by hand, in the console, before running bootstrap at all. But then the most important bucket in the account is unmanaged. It exists outside every OpenTofu graph, nobody&amp;rsquo;s code describes it, its encryption and policy and &lt;code&gt;prevent_destroy&lt;/code&gt; are whatever someone clicked that day, and it drifts. The local-then-migrate dance avoids exactly that. The bucket is created &lt;em&gt;by bootstrap&lt;/em&gt;, described in code, and tracked in bootstrap&amp;rsquo;s own state from its very first apply. It&amp;rsquo;s managed from birth.&lt;/p&gt;
&lt;p&gt;The chicken-and-egg isn&amp;rsquo;t a flaw to be embarrassed about. It&amp;rsquo;s just the shape of the problem when a stack has to build its own foundations, and OpenTofu&amp;rsquo;s &lt;code&gt;-migrate-state&lt;/code&gt; is the supported tool for exactly that shape.&lt;/p&gt;
&lt;h2 id="pulling-it-together"&gt;Pulling it together
&lt;/h2&gt;&lt;p&gt;Every OpenTofu stack needs a backend to store state, and the bootstrap stack&amp;rsquo;s job is to &lt;em&gt;create&lt;/em&gt; the backend, so on its first run the bucket it needs doesn&amp;rsquo;t yet exist.&lt;/p&gt;
&lt;p&gt;The resolution is to run bootstrap once with a local backend, let that apply create the bucket and key, then rewrite the backend configuration and &lt;code&gt;tofu init -migrate-state&lt;/code&gt; the state into the bucket bootstrap just made. The &lt;code&gt;infra&lt;/code&gt; repo did it twice, local to S3 and later S3 to GitLab, which shows the real point: state is portable, and the backend is just where you keep it. Doing it this way, rather than hand-creating the bucket, is what keeps that critical bucket managed in code from its very first day.&lt;/p&gt;</description></item><item><title>The bootstrap that does almost nothing</title><link>https://phpboyscout.uk/the-bootstrap-that-does-almost-nothing/</link><pubDate>Fri, 01 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-bootstrap-that-does-almost-nothing/</guid><description>&lt;img src="https://phpboyscout.uk/the-bootstrap-that-does-almost-nothing/cover-the-bootstrap-that-does-almost-nothing.png" alt="Featured image of post The bootstrap that does almost nothing" /&gt;&lt;p&gt;A brand-new AWS account is a slightly nerve-wracking thing. It can do almost anything, it&amp;rsquo;s hardened against almost nothing, and the list of stuff you ought to set up before you trust it with anything real is long. The natural instinct is to write one big &amp;ldquo;set up the account&amp;rdquo; module that does the whole list in a single apply. I want to talk you out of that, because the bootstrap module I&amp;rsquo;m happiest with does almost nothing, on purpose.&lt;/p&gt;
&lt;h2 id="the-first-apply-problem"&gt;The first-apply problem
&lt;/h2&gt;&lt;p&gt;A brand-new AWS account is not ready for anything serious. Before you&amp;rsquo;d responsibly run real infrastructure into it, you want an account baseline: a password policy, account-wide S3 public-access blocking, default EBS encryption, CloudTrail, AWS Config, GuardDuty, alerting, a sensible human operator role. It&amp;rsquo;s a long list, and all of it matters.&lt;/p&gt;
&lt;p&gt;The instinct, faced with that list, is to write one big &amp;ldquo;set up the account&amp;rdquo; module and have it do everything. One &lt;code&gt;tofu apply&lt;/code&gt;, a fully prepared account, done.&lt;/p&gt;
&lt;p&gt;That instinct is worth resisting, and &lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-bootstrap/-/tree/v0.2.0/modules" target="_blank" rel="noopener"
 &gt;&lt;code&gt;terraform-aws-bootstrap&lt;/code&gt;&lt;/a&gt; resists it deliberately.&lt;/p&gt;
&lt;h2 id="three-things-and-a-hard-line"&gt;Three things, and a hard line
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;terraform-aws-bootstrap&lt;/code&gt; does three things:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;state-backend&lt;/code&gt;&lt;/strong&gt;, an S3 bucket and a customer-managed KMS key to hold remote Terraform state.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;automation-iam&lt;/code&gt;&lt;/strong&gt;, an OIDC identity provider and an IAM role that CI assumes to apply everything else.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;nuke-config&lt;/code&gt;&lt;/strong&gt;, which renders an &lt;a class="link" href="https://github.com/ekristen/aws-nuke" target="_blank" rel="noopener"
 &gt;aws-nuke&lt;/a&gt; configuration scoped to the account, for tearing a throwaway account back down.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That&amp;rsquo;s the whole module. Account hardening, CloudTrail, AWS Config, GuardDuty, the operator role, the alerting: none of it is in here. And it&amp;rsquo;s not absent by accident. The README has a section headed &amp;ldquo;what&amp;rsquo;s deliberately NOT in scope&amp;rdquo; that lists those exclusions out loud. The boundary is written down, because the boundary is the design.&lt;/p&gt;
&lt;h2 id="why-the-line-is-exactly-there"&gt;Why the line is exactly there
&lt;/h2&gt;&lt;p&gt;The reason the line sits where it does is the most useful idea in the module.&lt;/p&gt;
&lt;p&gt;Everything bootstrap excludes belongs in a &lt;em&gt;separate&lt;/em&gt; stack, applied &lt;em&gt;through the automation role bootstrap creates&lt;/em&gt;. Bootstrap&amp;rsquo;s only job is to get the account to the point where the next &lt;code&gt;tofu apply&lt;/code&gt; can run properly: somewhere to store state, and an identity to run as. Once those two things exist, hardening the account isn&amp;rsquo;t a special bootstrapping act. It&amp;rsquo;s just another apply, done the normal way: in CI, reviewed, versioned, deployed through the role.&lt;/p&gt;
&lt;p&gt;So the account baseline doesn&amp;rsquo;t need to be bundled into the bootstrap. It needs to be &lt;em&gt;downstream&lt;/em&gt; of it. Bootstrap builds the on-ramp; it doesn&amp;rsquo;t also have to be the motorway.&lt;/p&gt;
&lt;h2 id="a-narrow-module-stays-re-runnable"&gt;A narrow module stays re-runnable
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s a practical payoff to the narrowness, and it&amp;rsquo;s about fear.&lt;/p&gt;
&lt;p&gt;Bootstrap is the one stack that &lt;em&gt;can&amp;rsquo;t&lt;/em&gt; be applied through CI, because it&amp;rsquo;s what creates the CI identity in the first place. It runs locally, by a human, rarely. That&amp;rsquo;s exactly the kind of operation you want to be small, boring, and safe to repeat.&lt;/p&gt;
&lt;p&gt;A bootstrap module that also did account hardening would be a large, stateful thing managing dozens of resources. Re-running it would be a held-breath operation. Keeping it to three concerns keeps it the opposite: a small stack you can read top to bottom, re-run without anxiety, and reason about completely. The narrowness isn&amp;rsquo;t minimalism for its own sake. It&amp;rsquo;s what keeps the one human-applied stack trustworthy.&lt;/p&gt;
&lt;h2 id="the-boundary-is-the-feature"&gt;The boundary is the feature
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s tempting to judge a module by how much it does. A bootstrap module is the case where that&amp;rsquo;s exactly backwards. Its value is in how cleanly it stops.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;terraform-aws-bootstrap&lt;/code&gt; does the bare minimum to make an account ready for the next apply, writes down everything it refuses to do, and hands off to a downstream stack for all of it. The next post follows the trickiest of its three jobs: the state backend has a &lt;a class="link" href="https://phpboyscout.uk/the-chicken-and-egg-of-remote-state/" &gt;genuine chicken-and-egg problem&lt;/a&gt;, because it has to store Terraform state in a bucket Terraform hasn&amp;rsquo;t created yet.&lt;/p&gt;
&lt;h2 id="where-this-leaves-us"&gt;Where this leaves us
&lt;/h2&gt;&lt;p&gt;A fresh AWS account needs a long list of things before it&amp;rsquo;s safe, and the obvious move is one big module that does the lot. &lt;code&gt;terraform-aws-bootstrap&lt;/code&gt; deliberately does only three: a state backend, a CI identity, and an account-scrub config. Everything else is written down as out of scope.&lt;/p&gt;
&lt;p&gt;The boundary is the design. The excluded work belongs in a downstream stack applied through the CI role bootstrap creates, so hardening is just a normal reviewed apply rather than a bootstrapping special case. And keeping the one human-run, locally-applied stack small is what keeps it safe to re-run. A bootstrap module is judged by where it stops.&lt;/p&gt;</description></item></channel></rss>