<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Checkov | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/checkov/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/checkov/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Sun, 20 Sep 2026 15:49:58 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.4</generator>

<image>
	<url>https://john-nessime.com/blog/wp-content/uploads/2026/07/cropped-jn-32x32.png</url>
	<title>Checkov | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/checkov/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Terraform Modules Worth Reusing (And the Ones That Cost You)</title>
		<link>https://john-nessime.com/blog/devops/reusable-terraform-modules/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 13 Aug 2026 13:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Architecture]]></category>
		<category><![CDATA[Automation]]></category>
		<category><![CDATA[Checkov]]></category>
		<category><![CDATA[CI/CD]]></category>
		<category><![CDATA[Code Reuse]]></category>
		<category><![CDATA[HCL]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Infrastructure as Code]]></category>
		<category><![CDATA[Module Versioning]]></category>
		<category><![CDATA[OpenTofu]]></category>
		<category><![CDATA[Platform Engineering]]></category>
		<category><![CDATA[Terraform]]></category>
		<category><![CDATA[Terraform Modules]]></category>
		<category><![CDATA[Terraform Registry]]></category>
		<category><![CDATA[Terraform Testing]]></category>
		<category><![CDATA[terraform-docs]]></category>
		<category><![CDATA[tflint]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=198</guid>

					<description><![CDATA[<p>A module's real cost is not writing it, it's upgrading it. Five signals that separate reusable Terraform modules worth adopting from the ones your team will pin at an old version and never touch again, plus the state-address failure that makes version bumps dangerous.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/reusable-terraform-modules/">Terraform Modules Worth Reusing (And the Ones That Cost You)</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The pull request was tiny. A module version bump, one line, nothing else in the diff. Then the plan came back with something nobody wants to read late on a Thursday: the RDS instance <em>must be replaced</em>.</p>



<p class="wp-block-paragraph">Nothing about the database had changed. Same engine, same instance class, same subnet group. What changed was the resource&#8217;s name inside the module. The author had tidied up a <code>resource "aws_db_instance" "this"</code> into something more descriptive, shipped it as a minor release, and moved on. Terraform saw a resource at an address that no longer existed and a resource at an address that did not exist before. Its default reading of that is destroy and create.</p>



<p class="wp-block-paragraph">That is the real cost of a module, and it is invisible on day one. The first apply is always fine. Modules feel great the week you adopt them. The bill arrives at the third upgrade, when the person who wrote it has moved teams and a dozen repositories consume it.</p>



<p class="wp-block-paragraph">This post is about judging that cost before you pay it. What separates reusable Terraform modules that are genuinely worth adopting from the ones your team quietly pins at an old version and refuses to touch, how to make your own modules land on the right side of that line, and what to do when a plan comes back looking like the one above.</p>



<h2 class="wp-block-heading">The bill for a module arrives at upgrade time</h2>



<p class="wp-block-paragraph">Terraform correlates state to configuration by address. Not by tags, not by cloud resource ID, not by anything the provider knows. Just the address: <code>module.database.aws_db_instance.this</code>. Change any part of that path and, unless you say otherwise, Terraform concludes the old object should go and a new one should take its place.</p>



<p class="wp-block-paragraph">For a security group that is an annoyance. For a database, an object store, or anything holding data, it is an incident with a retro attached.</p>



<p class="wp-block-paragraph">The fix has existed for a long time and it is genuinely good: the <code>moved</code> block. A module author who renames something declares the rename in code, ships it with the release, and every consumer&#8217;s next plan updates state instead of destroying infrastructure.</p>



<pre class="wp-block-code"><code># Shipped inside the module, in the same release as the rename.
# Terraform rewrites the state address instead of planning a replacement.

moved {
  from = aws_db_instance.this
  to   = aws_db_instance.primary
}</code></pre>



<p class="wp-block-paragraph">The block is declarative and it chains. If a resource moves in one release and moves again two releases later, someone jumping several versions at once still lands correctly, because Terraform resolves the whole chain. That is why a module that keeps its <code>moved</code> blocks around is doing you a favour, and why one that strips them out after a release or two is quietly making every skipped upgrade riskier.</p>



<p class="wp-block-paragraph">So here is the whole thesis, and everything below is elaboration: <strong>a module is worth reusing if the next version bump is cheap. Everything else is secondary.</strong></p>



<h2 class="wp-block-heading">Five signals that reusable Terraform modules are worth adopting</h2>



<p class="wp-block-paragraph">These are the things I actually check before letting a module into a codebase. None of them take more than a few minutes. All of them predict the upgrade experience better than star counts do.</p>



<h3 class="wp-block-heading">1. It keeps state addresses stable, or tells you when it does not</h3>



<p class="wp-block-paragraph">Open the repository. Search for <code>moved</code>. Read the changelog for the last major release and see whether renames are called out.</p>



<p class="wp-block-paragraph">A module with no <code>moved</code> blocks and no changelog is not necessarily bad code. It just means every internal refactor becomes your problem, discovered in a plan output, usually under time pressure. A module that says &#8220;v3.0 renames the launch template resource, here are the moved blocks&#8221; is telling you it has thought about consumers.</p>



<h3 class="wp-block-heading">2. The input surface is narrow and typed</h3>



<p class="wp-block-paragraph">Count the variables. If a module needs forty inputs to cover its use cases, the abstraction was drawn in the wrong place. Usually that means two patterns got merged into one module because they looked similar from a distance.</p>



<p class="wp-block-paragraph">Width is not the only tell. Look at how the inputs are typed. A module that takes twelve loose strings is very different from one that takes three typed objects with sensible defaults baked in.</p>



<pre class="wp-block-code"><code>variable "logging" {
  description = "Access log settings. Set to null to disable logging."
  type = object({
    bucket         = string
    prefix         = optional(string, "logs/")
    retention_days = optional(number, 30)
  })
  default = null
}

variable "environment" {
  description = "Deployment environment."
  type        = string
  nullable    = false

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be one of dev, staging or prod."
  }
}</code></pre>



<p class="wp-block-paragraph">Three things are doing work there. <code>optional()</code> with a default means a caller who wants the simple case writes two lines instead of six. <code>nullable = false</code> means a caller cannot pass <code>null</code> and get a confusing failure three resources deep. The <code>validation</code> block means a typo fails at plan time with a sentence a human wrote, rather than as a provider error about an invalid parameter.</p>



<p class="wp-block-paragraph">That last one matters more than it looks. Every validation rule a module author writes is an error message you do not have to debug.</p>



<h3 class="wp-block-heading">3. You can pin it, and the pin means something</h3>



<p class="wp-block-paragraph">This trips people up because the two common source types behave differently.</p>



<pre class="wp-block-code"><code># Registry source. The version argument applies here.
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~&gt; 5.0"   # allows any 5.x, refuses 6.0
}

# Git source. There is no version argument. Pin with ref.
module "internal_network" {
  source = "git::https://git.example.com/infra/tf-network.git?ref=v2.3.1"
}</code></pre>



<p class="wp-block-paragraph">The <code>~&gt;</code> operator only allows the rightmost specified component to increase. Written against a major and minor, as above, it accepts patch and minor releases and blocks the next major. Written as <code>~&gt; 5.0.1</code> it accepts patches only. Which one you want depends entirely on whether you trust the author&#8217;s reading of semantic versioning, and the honest answer for third-party modules is usually &#8220;not enough to auto-accept minors in production&#8221;.</p>



<p class="wp-block-paragraph">For Git sources, pin to a tag, not a branch. A branch reference resolves to whatever the tip happens to be at <code>init</code> time, which turns your infrastructure into a moving target and makes a plan from two weeks ago unreproducible.</p>



<h3 class="wp-block-heading">4. It has tests you can run without a cloud account</h3>



<p class="wp-block-paragraph">Terraform&#8217;s built-in test framework uses <code>.tftest.hcl</code> files and the same HCL you already write. The critical detail for module authors is that a run block set to <code>command = plan</code> creates nothing. No credentials, no resources, no teardown, no cost. It just checks that the configuration produces what you claimed.</p>



<pre class="wp-block-code"><code># tests/validation.tftest.hcl
# Runs on plan only. Nothing is created and nothing is billed.

run "rejects_unknown_environment" {
  command = plan

  variables {
    environment = "produciton"
  }

  expect_failures = [var.environment]
}</code></pre>



<p class="wp-block-paragraph">Negative tests like that are the cheapest quality signal in the whole ecosystem. They prove the validation rules actually fire, which is the difference between a rule that catches typos and a rule that was written once and silently broken later.</p>



<p class="wp-block-paragraph">OpenTofu runs the same format through <code>tofu test</code>, and additionally recognises a <code>.tofutest.hcl</code> extension for tests that should only run there. Useful if you maintain modules that target both tools.</p>



<h3 class="wp-block-heading">5. Somebody answers issues</h3>



<p class="wp-block-paragraph">Least technical, most predictive. Open the issue tracker, sort by recently updated, and look at whether maintainers reply. A module with a hundred open issues and active triage is in far better shape than one with six open issues and no comments since a provider major release.</p>



<p class="wp-block-paragraph">Provider major versions are the stress test. When the AWS or Azure provider ships a breaking change, every module has to react. Modules without a maintainer simply stop being usable, and you find out when <code>init</code> refuses to resolve a version constraint.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Three kinds of module, and which ones actually pay off</h2>



<p class="wp-block-paragraph">Not everything that <em>can</em> be a module <em>should</em> be. It helps to name the three shapes you see in the wild.</p>



<h3 class="wp-block-heading">The thin wrapper</h3>



<p class="wp-block-paragraph">One resource, a handful of variables passed straight through, maybe a tag merge. This is the most common module and usually the least valuable. It adds a layer of indirection, a version to track and a state address prefix, in exchange for saving four lines.</p>



<p class="wp-block-paragraph">There is one case where it earns its place: when the wrapper is enforcing something, like mandatory encryption, a naming convention, or a tag schema your finance team depends on. Then it is not a wrapper, it is a policy with a friendly interface. If it enforces nothing, inline the resource and move on.</p>



<h3 class="wp-block-heading">The composition module</h3>



<p class="wp-block-paragraph">Five to fifteen resources that only make sense together. A VPC with its subnets, route tables and gateways. A service with its task definition, target group, listener rule and log group. This is where modules earn back their cost, because the value is in the wiring, and the wiring is exactly what people get wrong when they copy and paste.</p>



<p class="wp-block-paragraph">Most modules worth reusing live here.</p>



<h3 class="wp-block-heading">The landing zone</h3>



<p class="wp-block-paragraph">An entire environment behind one module call. Accounts, networking, IAM baseline, logging, the lot. These are genuinely powerful and genuinely expensive. They only work when a team owns them, tests them, and has a release process. A landing zone module maintained by one person as a side project becomes the single scariest thing in the repository, because nobody can upgrade it and nobody can replace it either.</p>



<h2 class="wp-block-heading">Public modules or your own?</h2>



<p class="wp-block-paragraph">The case for public modules is stronger than the &#8220;we have specific needs&#8221; crowd usually admits. The community AWS collections in particular have absorbed years of edge cases you have not thought of yet: the ordering constraints, the optional sub-resources, the arguments that only apply in certain regions. Reimplementing that from the provider docs is a real project, not an afternoon.</p>



<p class="wp-block-paragraph">The case against is equally real. Popular public modules are wide by necessity, because they serve everyone. That width shows up as a large input surface, a lot of conditional logic, and majors that shuffle internals. You inherit an upgrade cadence you do not control.</p>



<p class="wp-block-paragraph">My rough split: use public modules for undifferentiated infrastructure where the community has clearly solved it, and write your own for anything encoding a decision your organisation made. A VPC is undifferentiated. Your service deployment pattern is not.</p>



<p class="wp-block-paragraph">There is a licensing dimension worth knowing about. Terraform moved from an open source licence to the source-available Business Source License, which is what prompted the OpenTofu fork now under the Linux Foundation. Module code itself is unaffected by that change, and the two tools read the same HCL, so modules generally work with either. OpenTofu runs its own registry rather than HashiCorp&#8217;s. If your organisation has strict open source licensing requirements, that distinction is the one to escalate, and it is a legal question rather than a technical one.</p>



<p class="wp-block-paragraph">For sharing modules internally, a private registry gives you discovery and a version list in one place. HCP Terraform includes one, and the run platforms built around Terraform and OpenTofu, such as Spacelift, Scalr and env0, all offer module catalogs with policy enforcement attached. You can also get most of the benefit from tagged Git repositories on GitHub or GitLab and a README, which is where I would start before paying for anything. If you self-host your Git server and CI runners, they need somewhere to live, and a small VPS from a provider like InterServer, Hetzner or DigitalOcean handles module CI comfortably, since the plans are cheap and the tests never leave the plan phase.</p>



<h2 class="wp-block-heading">Making your own modules worth reusing</h2>



<p class="wp-block-paragraph">If you are on the authoring side, this is the order I would do things in.</p>



<ol class="wp-block-list">
<li><strong>Write it inline first, twice.</strong> Do not modularise a pattern you have seen once. The second copy tells you which parts vary and which parts are actually fixed. That is the module&#8217;s interface, discovered rather than guessed.</li>



<li><strong>Extract with <code>moved</code> blocks in the same commit.</strong> Moving a resource into a module changes its address. If the resource holds data, that is the difference between a refactor and an outage.</li>



<li><strong>Type the inputs properly.</strong> Objects with <code>optional()</code> defaults over flat strings, <code>nullable = false</code> where null makes no sense, <code>validation</code> blocks with error messages that name the fix.</li>



<li><strong>Prefer <code>for_each</code> over <code>count</code> for anything collection-shaped.</strong> With <code>count</code>, removing an element from the middle of a list shifts every index after it, and Terraform plans a rebuild of resources you never touched. Keyed addresses do not shift.</li>



<li><strong>Generate the docs.</strong> <code>terraform-docs</code> reads your variables and outputs and writes the README table. Wire it into CI so the docs cannot drift from the code, because hand-written input tables are always out of date.</li>



<li><strong>Lint and scan on every PR.</strong> <code>terraform fmt -check</code>, <code>terraform validate</code>, <code>tflint</code> for provider-specific mistakes, and a policy scanner such as Checkov or Trivy for the security defaults. All of these run in seconds on a plain GitHub Actions or GitLab CI runner.</li>



<li><strong>Tag releases, and mean it.</strong> A major for anything that changes an address or removes an input. A minor for new optional inputs. Consumers pin against your tags, so the tag is a promise.</li>
</ol>



<p class="wp-block-paragraph">One thing I would skip early on: building a full integration test suite that applies real infrastructure. It is slow, it costs money, and it needs credentials in CI. Plan-mode tests plus a policy scanner catch most of what actually breaks. Add applied tests later, for the two or three modules where the wiring is genuinely subtle.</p>



<h2 class="wp-block-heading">Troubleshooting the plans that scare you</h2>



<h3 class="wp-block-heading">&#8220;Forces replacement&#8221; after a version bump</h3>



<p class="wp-block-paragraph">Before you argue with it, find out what it wants to delete. Save the plan and read it as JSON rather than scrolling terminal output.</p>



<pre class="wp-block-code"><code>terraform init -upgrade
terraform plan -out=tfplan

# List every address the plan intends to delete.
terraform show -json tfplan | jq -r '.resource_changes[]
  | select(.change.actions[] == "delete") | .address'</code></pre>



<p class="wp-block-paragraph">If the list is empty, the replacement was an argument change, not an address change, and you should read the diff on that resource. If the list contains things you did not expect, compare the addresses against the new module version&#8217;s source. A rename with no <code>moved</code> block is the usual answer, and you can write the <code>moved</code> block yourself in your root configuration as a workaround.</p>



<h3 class="wp-block-heading">A module with a provider block inside it</h3>



<p class="wp-block-paragraph">Modules should declare which providers they need, not configure them. A module that contains a configured <code>provider</code> block rather than just a <code>required_providers</code> entry limits what callers can do with it, including using <code>for_each</code> or <code>count</code> on the module call. It also makes the module harder to destroy cleanly. If you hit that, the fix is on the module side: move provider configuration up to the root and pass configuration aliases in.</p>



<h3 class="wp-block-heading">Changes to the module source are not picked up</h3>



<p class="wp-block-paragraph">Remote modules are downloaded into the working directory during <code>init</code>. Changing a <code>ref</code> or a <code>version</code> constraint in your configuration does not re-download anything on its own. Run <code>terraform init -upgrade</code>. If your CI caches the module directory between runs, that cache is the first place to look when a pipeline plans against code you are sure you deleted.</p>



<h3 class="wp-block-heading">Registry version list looks stale</h3>



<p class="wp-block-paragraph">A registry&#8217;s list of versions is a synced view of your Git tags, not an independent database. Reconnecting a VCS integration, moving repositories, or pushing many tags at once can leave the registry out of step with the repository. Treat the tags as the source of truth and check the registry&#8217;s view after any change to that integration.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li><strong>Pinning to a branch.</strong> <code>?ref=main</code> is not a pin. It is an unannounced deployment scheduled for whenever someone next runs <code>init</code>.</li>



<li><strong>Modularising after one use.</strong> You end up encoding the first use case&#8217;s assumptions as the abstraction, then bolting flags onto it for every case after.</li>



<li><strong>Copying environments instead of parameterising them.</strong> Three near-identical directories drift within a quarter, and the fix that went into dev never reaches prod.</li>



<li><strong>Passing every provider argument straight through.</strong> If a module&#8217;s variables are a mirror of the resource schema, it is not an abstraction, it is a rename with extra steps.</li>



<li><strong>Deleting <code>moved</code> blocks too early.</strong> They only cost you a few lines. Removing them is safe only when you are certain every consumer has applied past that version, which you can rarely be sure of for a public module.</li>



<li><strong>Applying a plan you did not read.</strong> Nearly every module horror story ends with someone approving a plan whose destroy count they never looked at.</li>
</ul>



<h2 class="wp-block-heading">How I decide whether to adopt a module</h2>



<p class="wp-block-paragraph">Roughly ten minutes, in this order:</p>



<ol class="wp-block-list">
<li>Count the input variables. Over about thirty, I assume the abstraction is wrong and look for a narrower alternative.</li>



<li>Grep for <code>moved</code>. Its presence tells me the author thinks about consumers.</li>



<li>Read the last major release notes. Are breaking changes explained, with an upgrade path?</li>



<li>Check for a <code>tests</code> directory and whether CI runs on pull requests.</li>



<li>Look at the issue tracker&#8217;s recent activity, not its total count.</li>



<li>Adopt it in one non-production configuration first, then bump it once deliberately and watch the plan. That single bump tells you more than the other five steps combined.</li>
</ol>



<p class="wp-block-paragraph">And a standing rule regardless of the module: never let a plan with a non-zero destroy count through review without someone saying out loud what is being destroyed and why.</p>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">How many input variables is too many for a Terraform module?</h3>



<p class="wp-block-paragraph">There is no hard limit, but a wide input surface is a symptom rather than a style choice. If covering your use cases needs dozens of inputs, the variation between callers is probably larger than what they share, which means you have found two patterns rather than one. Splitting into two narrower modules usually reads better and upgrades more safely than adding a twelfth boolean flag.</p>



<h3 class="wp-block-heading">Should I use public modules or write my own?</h3>



<p class="wp-block-paragraph">Both, for different things. Public modules are a good trade for undifferentiated infrastructure where the community has already absorbed the edge cases. Write your own for anything that encodes a decision specific to your organisation, because that is the part nobody else will maintain for you and the part that changes on your schedule rather than someone else&#8217;s.</p>



<h3 class="wp-block-heading">Do Terraform modules work with OpenTofu?</h3>



<p class="wp-block-paragraph">Generally yes. A module is just a directory of HCL, and both tools read the same language and use compatible provider plugins, so registry modules, private modules and Git-sourced modules usually work unchanged. Where you can hit friction is version constraints and features that have diverged between the two projects since the fork. Test one workspace before committing an estate to a migration.</p>



<h3 class="wp-block-heading">How do I pin a module version from a Git source?</h3>



<p class="wp-block-paragraph">The <code>version</code> argument only applies to registry sources. For Git, append a <code>ref</code> query parameter to the source URL and point it at a tag or a commit SHA. Branch names resolve to whatever the tip is at <code>init</code> time, which defeats the purpose of pinning entirely.</p>



<h3 class="wp-block-heading">What happens if a module removes a resource in a new version?</h3>



<p class="wp-block-paragraph">Terraform plans to destroy it, because the resource is in state and no longer in configuration. Sometimes that is exactly what the author intended. Sometimes it means functionality moved somewhere you have not adopted yet. Read the release notes before applying, and if the object holds data, take a snapshot first regardless of what the notes say.</p>



<h3 class="wp-block-heading">Can I test a Terraform module without cloud credentials?</h3>



<p class="wp-block-paragraph">Yes, for a useful subset. Run blocks set to <code>command = plan</code> evaluate the configuration without creating anything, which is enough to assert on computed values, defaults and validation failures. Testing behaviour that only exists after creation still needs an apply and therefore real credentials, which is why most module suites are mostly plan tests with a small number of applied ones.</p>



<h3 class="wp-block-heading">Do I need a private registry to share modules internally?</h3>



<p class="wp-block-paragraph">No. Tagged Git repositories and a source URL cover the mechanics completely. A registry buys you discovery, a browsable version list and a place to attach policy, which starts mattering once enough teams are publishing that people cannot find what already exists. Below that point it is overhead you do not need yet.</p>



<h2 class="wp-block-heading">Conclusion</h2>



<p class="wp-block-paragraph">Most advice about reusable Terraform modules focuses on the writing: structure, naming, README quality. That is the easy part, and it is not where things go wrong.</p>



<p class="wp-block-paragraph">The modules that are actually worth reusing are the ones you can still upgrade in two years. Stable state addresses, a narrow typed interface, a pin that means something, tests that run on plan, and a maintainer who replies. If you only remember one thing, remember what a module is really promising you: not that the first apply will work, but that the next version bump will not wake anyone up.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need a second pair of eyes on your module library?</h2>



<p class="wp-block-paragraph">I work with teams on the boring, high-consequence parts of Terraform and OpenTofu estates. Things I help with regularly:</p>



<ul class="wp-block-list">
<li>Auditing an existing module library and saying plainly which modules earn their keep and which should be inlined back into root configurations</li>



<li>Adding <code>moved</code> blocks and upgrade notes to in-house modules so version bumps stop being incidents</li>



<li>Narrowing wide module interfaces with typed objects, <code>optional()</code> defaults and validation rules that fail with useful messages</li>



<li>Setting up module CI: <code>fmt</code>, <code>validate</code>, <code>tflint</code>, policy scanning and generated docs on every pull request</li>



<li>Writing <code>terraform test</code> suites that run on plan, including negative tests that prove your validation rules actually fire</li>



<li>Planning a Terraform to OpenTofu migration across a module estate, or working out whether it is worth doing at all</li>
</ul>



<p class="wp-block-paragraph">Send me a module&#8217;s <code>variables.tf</code> and the plan output from your last version bump. That pair is usually enough for me to tell you where the trouble is.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/reusable-terraform-modules/">Terraform Modules Worth Reusing (And the Ones That Cost You)</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>A Secure-by-Default AWS Account Baseline in Terraform</title>
		<link>https://john-nessime.com/blog/devops/aws-account-baseline-terraform/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 30 Jul 2026 19:30:45 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Web Security]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Checkov]]></category>
		<category><![CDATA[CI/CD]]></category>
		<category><![CDATA[Cloud]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[GitHub Actions]]></category>
		<category><![CDATA[IAM]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Infrastructure as Code]]></category>
		<category><![CDATA[OIDC]]></category>
		<category><![CDATA[Terraform]]></category>
		<category><![CDATA[VPC]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=42</guid>

					<description><![CDATA[<p>A fresh AWS account ships wide open by default. Here are the seven decisions that make one defensible from the start — OIDC instead of static keys, encrypted state, an isolated database tier, free gateway endpoints — with a working Terraform reference implementation on GitHub.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/aws-account-baseline-terraform/">A Secure-by-Default AWS Account Baseline in Terraform</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">A fresh AWS account is not a neutral starting point. It ships with a default VPC whose default security group allows every instance in it to talk to every other instance, a default route to the internet in every subnet, no logging of network traffic, and an expectation that you will create an access key and paste it into your CI system.</p>



<p class="wp-block-paragraph">None of that is a bug. AWS optimises the empty account for getting something running in ten minutes. The problem is that most accounts never get corrected afterwards, and eighteen months later there is production traffic sitting on top of decisions nobody made deliberately.</p>



<p class="wp-block-paragraph">An AWS account baseline is the set of things you lay down before the first workload, so the account starts from a defensible posture instead of a blank console. This walks through the decisions that actually matter, why each one is the way it is, and what it costs. I have put a working reference implementation on GitHub — <a href="https://github.com/JohnNessime/terraform-aws-baseline" target="_blank" rel="noreferrer noopener">terraform-aws-baseline</a> — so you can read the real Terraform rather than take my word for any of it.</p>



<h2 class="wp-block-heading">What a baseline is, and what it is not</h2>



<p class="wp-block-paragraph">A baseline is not a landing zone in the Control Tower sense, and it is not a compliance framework. It does not make you SOC 2 compliant and nothing that fits in one repository will.</p>



<p class="wp-block-paragraph">What it is: the boring foundational layer that everything else sits on. State storage, network segmentation, identity, and the guardrails that stop a careless afternoon becoming an incident. If you get these right once, every workload that lands afterwards inherits them for free.</p>



<p class="wp-block-paragraph">The structure that works is three layers. A bootstrap stack that creates the state backend and runs exactly once. Reusable modules for the things every environment needs. Thin per-environment roots that compose those modules with different parameters — dev gets one NAT gateway and seven-day log retention, prod gets one per availability zone and a year.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Decision one: no long-lived AWS credentials, anywhere</h2>



<p class="wp-block-paragraph">Static access keys in CI are the single most common way AWS accounts get compromised. They live in a secrets store, they get copied into a second one, somebody echoes one into a build log, and nobody rotates them because rotation breaks things.</p>



<p class="wp-block-paragraph">GitHub Actions can assume an AWS role using a short-lived OIDC token that GitHub signs and AWS verifies. There is no key to store, nothing to leak, and nothing to rotate.</p>



<p class="wp-block-paragraph">The important part is the trust policy, and one word in it decides whether this is a security improvement or a vulnerability:</p>



<pre class="wp-block-code"><code>data "aws_iam_policy_document" "ci_assume" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRoleWithWebIdentity"]

    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.github.arn]
    }

    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:aud"
      values   = ["sts.amazonaws.com"]
    }

    # StringEquals, not StringLike. This is the whole ballgame.
    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:sub"
      values = [
        "repo:${var.github_org}/${var.github_repo}:ref:refs/heads/${var.github_branch}"
      ]
    }
  }
}</code></pre>



<p class="wp-block-paragraph">Swap <code>StringEquals</code> for <code>StringLike</code> and add an asterisk, and you have widened that trust from one branch of one repository to whatever the wildcard covers. Get sloppy enough — a bare <code>repo:*</code> — and <em>any repository on GitHub</em> can assume your role. This is not theoretical; it is a recurring finding in real audits.</p>



<p class="wp-block-paragraph">Scope it to one repository and one branch. If you need more, add more statements, explicitly.</p>



<h2 class="wp-block-heading">Decision two: treat Terraform state as a secret, because it is</h2>



<p class="wp-block-paragraph">Terraform state contains every attribute of every resource it manages. Database passwords, generated keys, connection strings. Marking a variable <code>sensitive</code> redacts it from console output and changes nothing about what is written to the state file.</p>



<p class="wp-block-paragraph">So the state bucket gets four things: server-side encryption with a customer-managed KMS key that rotates, versioning so a corrupted state can be recovered, all four public access block flags, and a bucket policy that refuses plaintext connections outright.</p>



<pre class="wp-block-code"><code>resource "aws_s3_bucket_public_access_block" "state" {
  bucket                  = aws_s3_bucket.state.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

data "aws_iam_policy_document" "deny_insecure_transport" {
  statement {
    sid       = "DenyInsecureTransport"
    effect    = "Deny"
    actions   = ["s3:*"]
    resources = [
      aws_s3_bucket.state.arn,
      "${aws_s3_bucket.state.arn}/*",
    ]

    principals {
      type        = "*"
      identifiers = ["*"]
    }

    condition {
      test     = "Bool"
      variable = "aws:SecureTransport"
      values   = ["false"]
    }
  }
}</code></pre>



<p class="wp-block-paragraph">All four public access block flags, not two. They cover different paths — ACLs and bucket policies, existing objects and future ones — and setting a subset leaves a gap that reads as protected on a dashboard.</p>



<p class="wp-block-paragraph">There is a chicken-and-egg problem here that catches everyone: the stack that creates the state bucket cannot store its own state in a bucket that does not exist yet. You run it once with local state, then migrate:</p>



<pre class="wp-block-code"><code>cd bootstrap
terraform init
terraform apply
terraform output          # bucket, lock table, KMS key, region

# Now move bootstrap's own state into the bucket it just created
terraform init -migrate-state</code></pre>



<h2 class="wp-block-heading">Decision three: shut the default security group</h2>



<p class="wp-block-paragraph">Every VPC gets a default security group that permits all traffic between members and all outbound traffic. Anything launched without an explicit security group lands on it. That is a quiet failure mode — nothing errors, the instance works, and it has more network reach than anyone intended.</p>



<p class="wp-block-paragraph">Terraform can adopt and empty it:</p>



<pre class="wp-block-code"><code>resource "aws_default_security_group" "default" {
  vpc_id = aws_vpc.this.id

  # No ingress and no egress blocks. Terraform strips AWS's rules,
  # so anything that accidentally lands here can reach nothing.
}</code></pre>



<p class="wp-block-paragraph">Four lines, and misconfiguration becomes loud instead of silent.</p>



<h2 class="wp-block-heading">Decision four: three subnet tiers, and the bottom one has no way out</h2>



<p class="wp-block-paragraph">The standard two-tier split — public and private — leaves databases in the same tier as application servers. Both can reach the internet through NAT. That is fine right up until something in the data tier is compromised and quietly exfiltrates over 443, which looks like ordinary egress traffic.</p>



<ul class="wp-block-list">
<li><strong>Public</strong> — NAT gateways and load balancers. Route to the internet gateway.</li>

<li><strong>Private</strong> — workloads. Outbound through NAT, no inbound from the internet.</li>

<li><strong>Database</strong> — data stores. No default route at all.</li>
</ul>



<p class="wp-block-paragraph">The third tier is enforced by absence rather than by a rule:</p>



<pre class="wp-block-code"><code>resource "aws_route_table" "database" {
  vpc_id = aws_vpc.this.id

  # Deliberately no 0.0.0.0/0 route. Not to the IGW, not to NAT.
  # Nothing here can reach the internet, and nothing can reach it.
}</code></pre>



<p class="wp-block-paragraph">No security group to misconfigure, no NACL to get wrong. The route simply does not exist. That is a much stronger guarantee than a rule someone can widen during an incident and forget to narrow afterwards.</p>



<h2 class="wp-block-heading">Decision five: gateway endpoints, the one genuinely free win</h2>



<p class="wp-block-paragraph">S3 and DynamoDB gateway endpoints cost nothing. Not &#8220;cheap&#8221; — zero. They add a route so traffic to those services stays on the AWS backbone rather than going out through NAT.</p>



<pre class="wp-block-code"><code>resource "aws_vpc_endpoint" "s3" {
  vpc_id            = aws_vpc.this.id
  service_name      = "com.amazonaws.${var.region}.s3"
  vpc_endpoint_type = "Gateway"

  route_table_ids = concat(
    aws_route_table.private[*].id,
    aws_route_table.database[*].id,
  )
}</code></pre>



<p class="wp-block-paragraph">Two benefits, and the second is the one people miss. Traffic to S3 never traverses NAT, so you stop paying per-gigabyte processing on it. And the isolated database tier can still reach S3 and DynamoDB despite having no internet route, because the endpoint is a route within the VPC rather than a path out of it.</p>



<p class="wp-block-paragraph">Your Terraform state lives in S3. Without this endpoint, every plan and apply pushes state through the NAT gateway and you are billed for the privilege.</p>



<h2 class="wp-block-heading">Decision six: SSM Session Manager, not SSH</h2>



<p class="wp-block-paragraph">Port 22 open anywhere means key pairs to distribute, a bastion host to patch, and an audit trail that is whatever <code>auth.log</code> happens to contain.</p>



<pre class="wp-block-code"><code>resource "aws_iam_role_policy_attachment" "ssm_core" {
  role       = aws_iam_role.instance.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}</code></pre>



<p class="wp-block-paragraph">That one attachment gets you shell access with no inbound ports, no key pairs, no bastion. Access is IAM-authorised, so revoking someone is a permissions change rather than a key rotation, and every session can be logged centrally.</p>



<p class="wp-block-paragraph">Worth knowing the caveat: SSM agents reach the SSM endpoints over the internet by default, so instances in the private tier still need NAT for this. Removing NAT entirely means adding interface endpoints for <code>ssm</code>, <code>ssmmessages</code> and <code>ec2messages</code>, and those cost roughly seven dollars a month per availability zone each.</p>



<h2 class="wp-block-heading">Decision seven: pin actions to commit SHAs</h2>



<p class="wp-block-paragraph">A Git tag is a movable pointer. If an action&#8217;s repository is compromised, an attacker can repoint <code>v4</code> at whatever they like, and every workflow referencing that tag runs it on the next build — with your OIDC role attached.</p>



<pre class="wp-block-code"><code>steps:
  # A tag can be moved. A commit SHA cannot.
  # Dependabot updates these pins and shows you the diff.
  - uses: actions/checkout@&lt;full-40-character-sha&gt;   # v4.2.2</code></pre>



<p class="wp-block-paragraph">The comment matters as much as the pin. Without it nobody can tell at a glance which version they are looking at, and the pin becomes something people update blindly.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">What it actually costs</h2>



<p class="wp-block-paragraph">Almost all of it is NAT gateways. Everything else is rounding.</p>



<ul class="wp-block-list">
<li><strong>NAT gateways</strong> — roughly $32 a month each, plus about $0.045 per gigabyte processed. One in dev, one per availability zone in prod.</li>

<li><strong>VPC, subnets, route tables, internet gateway</strong> — free.</li>

<li><strong>S3 and DynamoDB gateway endpoints</strong> — free.</li>

<li><strong>KMS keys</strong> — about a dollar a month each.</li>

<li><strong>Flow logs</strong> — around $0.50 per gigabyte ingested. Retention is the lever: seven days in dev, a year in prod.</li>

<li><strong>State bucket and lock table</strong> — cents.</li>
</ul>



<p class="wp-block-paragraph">Ballpark, that lands around $35 a month for dev and $105 for prod, dominated entirely by NAT. If you only need the isolated tiers and nothing requires outbound internet, dropping NAT takes dev close to free.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">Per-AZ NAT in production is an availability decision, not a security one. A single NAT gateway is a single point of failure for outbound traffic across every AZ that routes through it.</p>
</blockquote>



<h2 class="wp-block-heading">Gates that run before any credentials exist</h2>



<p class="wp-block-paragraph">Most of what goes wrong in Terraform is catchable statically. Formatting, syntax, provider misuse, and known-insecure patterns can all be found without touching an AWS account, which means they can run on every commit and in CI without the pipeline holding any cloud credentials at all.</p>



<pre class="wp-block-code"><code># On every commit, locally
pre-commit install        # gitleaks, fmt, validate, tflint, checkov, terraform-docs

# The same gates CI runs
make all                  # fmt + validate + tflint + checkov</code></pre>



<p class="wp-block-paragraph">Running the identical command locally and in CI is the part that matters. When those two drift, people stop trusting the local check and start pushing to see what the pipeline says, and the fast feedback loop you built quietly stops existing.</p>



<p class="wp-block-paragraph">Note also what is missing: there is no <code>terraform plan</code> in that pipeline. Plan needs real credentials, and a static-only pipeline is one that cannot leak anything. A production setup would add a plan job running under the OIDC role and posting to the pull request — but that is a deliberate step up in blast radius, not a default.</p>



<h2 class="wp-block-heading">What a baseline like this deliberately leaves out</h2>



<p class="wp-block-paragraph">Being clear about scope is more useful than pretending completeness. The reference implementation covers preventative controls and stops there. Missing, on purpose:</p>



<ul class="wp-block-list">
<li><strong>Detective controls.</strong> No CloudTrail, Config, GuardDuty or Security Hub. A complete account baseline includes them; this is the preventative half.</li>

<li><strong>Compute.</strong> There is an instance role and profile, but nothing uses them. No ASG, no ECS or EKS, no load balancer.</li>

<li><strong>Interface endpoints.</strong> Only the free gateway ones. Fully private SSM needs paid interface endpoints.</li>

<li><strong>Multi-region and DR.</strong> Versioning covers state recovery. There is no cross-region replication and no failover story.</li>

<li><strong>Policy-as-code tests.</strong> Checkov catches known misconfigurations; there are no Terratest or Conftest assertions on module contracts.</li>
</ul>



<p class="wp-block-paragraph">A baseline you can describe the edges of is more useful than one advertised as complete. You know exactly what you still have to build.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button is-style-outline is-style-outline--1"><a class="wp-block-button__link wp-element-button" href="https://github.com/JohnNessime/terraform-aws-baseline" target="_blank" rel="noreferrer noopener">Read the Terraform on GitHub</a></div>
</div>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Using <code>StringLike</code> with a wildcard in an OIDC trust policy. Scope to one repository and one branch.</li>

<li>Assuming <code>sensitive = true</code> keeps values out of state. It does not.</li>

<li>Setting two of the four S3 public access block flags and considering the bucket locked down.</li>

<li>Leaving the default security group as AWS shipped it.</li>

<li>Putting databases in the same tier as application servers because two tiers felt like enough.</li>

<li>Skipping gateway endpoints and paying NAT processing charges on your own state file.</li>

<li>Referencing actions by tag and assuming a tag is immutable.</li>

<li>Committing <code>backend.hcl</code> or <code>terraform.tfvars</code>. State bucket names embed the account ID.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Is an AWS account baseline the same as a landing zone?</h3>



<p class="wp-block-paragraph">No. A landing zone in the Control Tower sense governs many accounts — organisational units, service control policies, account vending. A baseline is what you lay down inside a single account. Baselines are what you need when you have one or two accounts and Control Tower would be enormous overkill.</p>



<h3 class="wp-block-heading">Why not just use Control Tower?</h3>



<p class="wp-block-paragraph">Control Tower is a good answer for a multi-account organisation with governance requirements. It is a lot of machinery for a team running a handful of accounts, and it hides the decisions rather than making them legible. Writing the baseline yourself means you can read every choice and change any of them.</p>



<h3 class="wp-block-heading">Do I really need a separate database subnet tier?</h3>



<p class="wp-block-paragraph">It costs nothing and closes an entire exfiltration path. If your data tier has no route to the internet, a compromised database cannot phone home regardless of what the attacker does with it. Two tiers is not wrong, but the third is close to free.</p>



<h3 class="wp-block-heading">How do I cut the NAT gateway cost?</h3>



<p class="wp-block-paragraph">Use one NAT for non-production instead of one per AZ. Add gateway endpoints so S3 and DynamoDB traffic bypasses NAT entirely. Beyond that, work out what genuinely needs outbound internet — often less than you assume — and consider dropping NAT for environments that do not.</p>



<h3 class="wp-block-heading">Can I use this with OpenTofu?</h3>



<p class="wp-block-paragraph">Yes. It is standard HCL against the AWS provider, so it runs on either. Swap <code>terraform</code> for <code>tofu</code> in the commands and everything else is the same.</p>



<h3 class="wp-block-heading">Where does the OIDC provider itself live?</h3>



<p class="wp-block-paragraph">It is account-global, so only one environment can create it and the others reference it. That is workable but awkward. In a real organisation it belongs in a separate account-level stack that runs before any environment.</p>



<h2 class="wp-block-heading">Wrapping up</h2>



<p class="wp-block-paragraph">None of the decisions in an AWS account baseline are individually clever. Scope the trust policy. Encrypt the state. Empty the default security group. Give the data tier no way out. Take the free endpoints. Use SSM instead of SSH. Pin your actions. Each one is a few lines of Terraform.</p>



<p class="wp-block-paragraph">What makes them valuable is doing them before the first workload arrives, because retrofitting network segmentation into a running account is a project, and doing it at the start is an afternoon.</p>



<p class="wp-block-paragraph">The full implementation is on GitHub at <a href="https://github.com/JohnNessime/terraform-aws-baseline" target="_blank" rel="noreferrer noopener">JohnNessime/terraform-aws-baseline</a>, MIT licensed. It is a reference implementation rather than something that has served production traffic — a worked example of how I would lay an account down, with the reasoning written out in the docs. Read it, disagree with parts of it, take what is useful.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need a baseline laid down properly?</h2>



<p class="wp-block-paragraph">I work with teams on AWS foundations — account structure, network segmentation, IAM that does not rely on long-lived keys, Terraform layouts that survive more than one engineer, and CI pipelines that catch problems before they reach an account.</p>



<p class="wp-block-paragraph">If you would rather have this done once and done right, you can hire me on Upwork.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/aws-account-baseline-terraform/">A Secure-by-Default AWS Account Baseline in Terraform</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
