{"id":198,"date":"2026-08-13T16:00:00","date_gmt":"2026-08-13T13:00:00","guid":{"rendered":"https:\/\/john-nessime.com\/blog\/?p=198"},"modified":"2026-08-06T17:19:39","modified_gmt":"2026-08-06T14:19:39","slug":"reusable-terraform-modules","status":"publish","type":"post","link":"https:\/\/john-nessime.com\/blog\/devops\/reusable-terraform-modules\/","title":{"rendered":"Terraform Modules Worth Reusing (And the Ones That Cost You)"},"content":{"rendered":"\n<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>\n\n\n\n<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>\n\n\n\n<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>\n\n\n\n<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>\n\n\n\n<h2 class=\"wp-block-heading\">The bill for a module arrives at upgrade time<\/h2>\n\n\n\n<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>\n\n\n\n<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>\n\n\n\n<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>\n\n\n\n<pre class=\"wp-block-code\"><code># Shipped inside the module, in the same release as the rename.\n# Terraform rewrites the state address instead of planning a replacement.\n\nmoved {\n  from = aws_db_instance.this\n  to   = aws_db_instance.primary\n}<\/code><\/pre>\n\n\n\n<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>\n\n\n\n<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>\n\n\n\n<h2 class=\"wp-block-heading\">Five signals that reusable Terraform modules are worth adopting<\/h2>\n\n\n\n<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>\n\n\n\n<h3 class=\"wp-block-heading\">1. It keeps state addresses stable, or tells you when it does not<\/h3>\n\n\n\n<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>\n\n\n\n<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>\n\n\n\n<h3 class=\"wp-block-heading\">2. The input surface is narrow and typed<\/h3>\n\n\n\n<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>\n\n\n\n<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>\n\n\n\n<pre class=\"wp-block-code\"><code>variable \"logging\" {\n  description = \"Access log settings. Set to null to disable logging.\"\n  type = object({\n    bucket         = string\n    prefix         = optional(string, \"logs\/\")\n    retention_days = optional(number, 30)\n  })\n  default = null\n}\n\nvariable \"environment\" {\n  description = \"Deployment environment.\"\n  type        = string\n  nullable    = false\n\n  validation {\n    condition     = contains([\"dev\", \"staging\", \"prod\"], var.environment)\n    error_message = \"environment must be one of dev, staging or prod.\"\n  }\n}<\/code><\/pre>\n\n\n\n<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>\n\n\n\n<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>\n\n\n\n<h3 class=\"wp-block-heading\">3. You can pin it, and the pin means something<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This trips people up because the two common source types behave differently.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># Registry source. The version argument applies here.\nmodule \"vpc\" {\n  source  = \"terraform-aws-modules\/vpc\/aws\"\n  version = \"~&gt; 5.0\"   # allows any 5.x, refuses 6.0\n}\n\n# Git source. There is no version argument. Pin with ref.\nmodule \"internal_network\" {\n  source = \"git::https:\/\/git.example.com\/infra\/tf-network.git?ref=v2.3.1\"\n}<\/code><\/pre>\n\n\n\n<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>\n\n\n\n<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>\n\n\n\n<h3 class=\"wp-block-heading\">4. It has tests you can run without a cloud account<\/h3>\n\n\n\n<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>\n\n\n\n<pre class=\"wp-block-code\"><code># tests\/validation.tftest.hcl\n# Runs on plan only. Nothing is created and nothing is billed.\n\nrun \"rejects_unknown_environment\" {\n  command = plan\n\n  variables {\n    environment = \"produciton\"\n  }\n\n  expect_failures = [var.environment]\n}<\/code><\/pre>\n\n\n\n<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>\n\n\n\n<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>\n\n\n\n<h3 class=\"wp-block-heading\">5. Somebody answers issues<\/h3>\n\n\n\n<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>\n\n\n\n<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>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Three kinds of module, and which ones actually pay off<\/h2>\n\n\n\n<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>\n\n\n\n<h3 class=\"wp-block-heading\">The thin wrapper<\/h3>\n\n\n\n<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>\n\n\n\n<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>\n\n\n\n<h3 class=\"wp-block-heading\">The composition module<\/h3>\n\n\n\n<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>\n\n\n\n<p class=\"wp-block-paragraph\">Most modules worth reusing live here.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The landing zone<\/h3>\n\n\n\n<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>\n\n\n\n<h2 class=\"wp-block-heading\">Public modules or your own?<\/h2>\n\n\n\n<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>\n\n\n\n<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>\n\n\n\n<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>\n\n\n\n<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>\n\n\n\n<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>\n\n\n\n<h2 class=\"wp-block-heading\">Making your own modules worth reusing<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">If you are on the authoring side, this is the order I would do things in.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<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>\n\n\n\n<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>\n\n\n\n<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>\n\n\n\n<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>\n\n\n\n<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>\n\n\n\n<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>\n\n\n\n<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>\n<\/ol>\n\n\n\n<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>\n\n\n\n<h2 class=\"wp-block-heading\">Troubleshooting the plans that scare you<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">&#8220;Forces replacement&#8221; after a version bump<\/h3>\n\n\n\n<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>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform init -upgrade\nterraform plan -out=tfplan\n\n# List every address the plan intends to delete.\nterraform show -json tfplan | jq -r '.resource_changes[]\n  | select(.change.actions[] == \"delete\") | .address'<\/code><\/pre>\n\n\n\n<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>\n\n\n\n<h3 class=\"wp-block-heading\">A module with a provider block inside it<\/h3>\n\n\n\n<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>\n\n\n\n<h3 class=\"wp-block-heading\">Changes to the module source are not picked up<\/h3>\n\n\n\n<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>\n\n\n\n<h3 class=\"wp-block-heading\">Registry version list looks stale<\/h3>\n\n\n\n<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>\n\n\n\n<h2 class=\"wp-block-heading\">Common mistakes<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<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>\n\n\n\n<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>\n\n\n\n<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>\n\n\n\n<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>\n\n\n\n<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>\n\n\n\n<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>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">How I decide whether to adopt a module<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Roughly ten minutes, in this order:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Count the input variables. Over about thirty, I assume the abstraction is wrong and look for a narrower alternative.<\/li>\n\n\n\n<li>Grep for <code>moved<\/code>. Its presence tells me the author thinks about consumers.<\/li>\n\n\n\n<li>Read the last major release notes. Are breaking changes explained, with an upgrade path?<\/li>\n\n\n\n<li>Check for a <code>tests<\/code> directory and whether CI runs on pull requests.<\/li>\n\n\n\n<li>Look at the issue tracker&#8217;s recent activity, not its total count.<\/li>\n\n\n\n<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>\n<\/ol>\n\n\n\n<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>\n\n\n\n<h2 class=\"wp-block-heading\">Frequently asked questions<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">How many input variables is too many for a Terraform module?<\/h3>\n\n\n\n<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>\n\n\n\n<h3 class=\"wp-block-heading\">Should I use public modules or write my own?<\/h3>\n\n\n\n<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>\n\n\n\n<h3 class=\"wp-block-heading\">Do Terraform modules work with OpenTofu?<\/h3>\n\n\n\n<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>\n\n\n\n<h3 class=\"wp-block-heading\">How do I pin a module version from a Git source?<\/h3>\n\n\n\n<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>\n\n\n\n<h3 class=\"wp-block-heading\">What happens if a module removes a resource in a new version?<\/h3>\n\n\n\n<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>\n\n\n\n<h3 class=\"wp-block-heading\">Can I test a Terraform module without cloud credentials?<\/h3>\n\n\n\n<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>\n\n\n\n<h3 class=\"wp-block-heading\">Do I need a private registry to share modules internally?<\/h3>\n\n\n\n<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>\n\n\n\n<h2 class=\"wp-block-heading\">Conclusion<\/h2>\n\n\n\n<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>\n\n\n\n<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>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Need a second pair of eyes on your module library?<\/h2>\n\n\n\n<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>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Auditing an existing module library and saying plainly which modules earn their keep and which should be inlined back into root configurations<\/li>\n\n\n\n<li>Adding <code>moved<\/code> blocks and upgrade notes to in-house modules so version bumps stop being incidents<\/li>\n\n\n\n<li>Narrowing wide module interfaces with typed objects, <code>optional()<\/code> defaults and validation rules that fail with useful messages<\/li>\n\n\n\n<li>Setting up module CI: <code>fmt<\/code>, <code>validate<\/code>, <code>tflint<\/code>, policy scanning and generated docs on every pull request<\/li>\n\n\n\n<li>Writing <code>terraform test<\/code> suites that run on plan, including negative tests that prove your validation rules actually fire<\/li>\n\n\n\n<li>Planning a Terraform to OpenTofu migration across a module estate, or working out whether it is worth doing at all<\/li>\n<\/ul>\n\n\n\n<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>\n\n\n\n<div class=\"wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex\">\n<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>\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>A module&#8217;s real cost is not writing it, it&#8217;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>\n","protected":false},"author":1,"featured_media":199,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[25,24,52],"tags":[192,94,104,95,309,3,92,21,91,306,90,178,88,305,307,308,311,310],"class_list":["post-198","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-cloud-computing","category-devops","category-technical-guides","tag-architecture","tag-automation","tag-checkov","tag-ci-cd","tag-code-reuse","tag-devops","tag-hcl","tag-infrastructure","tag-infrastructure-as-code","tag-module-versioning","tag-opentofu","tag-platform-engineering","tag-terraform","tag-terraform-modules","tag-terraform-registry","tag-terraform-testing","tag-terraform-docs","tag-tflint","entry","has-media"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v28.2 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>Reusable Terraform Modules: What&#039;s Actually Worth It<\/title>\n<meta name=\"description\" content=\"Not every abstraction earns its keep. Judge reusable Terraform modules by their upgrade path, interface width and state stability before you adopt them.\" \/>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/john-nessime.com\/blog\/devops\/reusable-terraform-modules\/\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Reusable Terraform Modules: What&#039;s Actually Worth It\" \/>\n<meta property=\"og:description\" content=\"Not every abstraction earns its keep. Judge reusable Terraform modules by their upgrade path, interface width and state stability before you adopt them.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/john-nessime.com\/blog\/devops\/reusable-terraform-modules\/\" \/>\n<meta property=\"og:site_name\" content=\"John Nessime\" \/>\n<meta property=\"article:published_time\" content=\"2026-08-13T13:00:00+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/reusable-terraform-modules-upgrade-path.png\" \/>\n\t<meta property=\"og:image:width\" content=\"1200\" \/>\n\t<meta property=\"og:image:height\" content=\"627\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/png\" \/>\n<meta name=\"author\" content=\"John Nessime\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"John Nessime\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"15 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/reusable-terraform-modules\\\/#article\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/reusable-terraform-modules\\\/\"},\"author\":{\"name\":\"John Nessime\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#\\\/schema\\\/person\\\/ede0b56d0c808f123f57d5d796902105\"},\"headline\":\"Terraform Modules Worth Reusing (And the Ones That Cost You)\",\"datePublished\":\"2026-08-13T13:00:00+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/reusable-terraform-modules\\\/\"},\"wordCount\":3383,\"commentCount\":0,\"publisher\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#\\\/schema\\\/person\\\/ede0b56d0c808f123f57d5d796902105\"},\"image\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/reusable-terraform-modules\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/08\\\/reusable-terraform-modules-upgrade-path.png\",\"keywords\":[\"Architecture\",\"Automation\",\"Checkov\",\"CI\\\/CD\",\"Code Reuse\",\"DevOps\",\"HCL\",\"Infrastructure\",\"Infrastructure as Code\",\"Module Versioning\",\"OpenTofu\",\"Platform Engineering\",\"Terraform\",\"Terraform Modules\",\"Terraform Registry\",\"Terraform Testing\",\"terraform-docs\",\"tflint\"],\"articleSection\":[\"Cloud Computing\",\"DevOps\",\"Technical Guides\"],\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"CommentAction\",\"name\":\"Comment\",\"target\":[\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/reusable-terraform-modules\\\/#respond\"]}]},{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/reusable-terraform-modules\\\/\",\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/reusable-terraform-modules\\\/\",\"name\":\"Reusable Terraform Modules: What's Actually Worth It\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/reusable-terraform-modules\\\/#primaryimage\"},\"image\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/reusable-terraform-modules\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/08\\\/reusable-terraform-modules-upgrade-path.png\",\"datePublished\":\"2026-08-13T13:00:00+00:00\",\"description\":\"Not every abstraction earns its keep. Judge reusable Terraform modules by their upgrade path, interface width and state stability before you adopt them.\",\"breadcrumb\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/reusable-terraform-modules\\\/#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/reusable-terraform-modules\\\/\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/reusable-terraform-modules\\\/#primaryimage\",\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/08\\\/reusable-terraform-modules-upgrade-path.png\",\"contentUrl\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/08\\\/reusable-terraform-modules-upgrade-path.png\",\"width\":1200,\"height\":627,\"caption\":\"Diagram comparing two Terraform modules across five releases, with upgrade steps coloured by cost: one module stays current on clean plans, the other is pinned at an old version because upgrades require state moves.\"},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/reusable-terraform-modules\\\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Terraform Modules Worth Reusing (And the Ones That Cost You)\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#website\",\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/\",\"name\":\"John Nessime\",\"description\":\"Cloud, DevOps, Data &amp; AI \u2014 Built, Tested, Explained\",\"publisher\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#\\\/schema\\\/person\\\/ede0b56d0c808f123f57d5d796902105\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":[\"Person\",\"Organization\"],\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#\\\/schema\\\/person\\\/ede0b56d0c808f123f57d5d796902105\",\"name\":\"John Nessime\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/cropped-jn.png\",\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/cropped-jn.png\",\"contentUrl\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/cropped-jn.png\",\"width\":512,\"height\":512,\"caption\":\"John Nessime\"},\"logo\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/cropped-jn.png\"},\"description\":\"AWS Certified Solutions Architect helping businesses build reliable cloud, data, reporting, and automation solutions. I help startups, agencies, and growing businesses replace manual processes and disconnected data with practical AWS architectures, clean data pipelines, useful dashboards, and maintainable automation.\",\"sameAs\":[\"https:\\\/\\\/john-nessime.com\\\/blog\",\"https:\\\/\\\/www.linkedin.com\\\/in\\\/john-m-nessime\"],\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/author\\\/johnnessime\\\/\"}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"Reusable Terraform Modules: What's Actually Worth It","description":"Not every abstraction earns its keep. Judge reusable Terraform modules by their upgrade path, interface width and state stability before you adopt them.","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/john-nessime.com\/blog\/devops\/reusable-terraform-modules\/","og_locale":"en_US","og_type":"article","og_title":"Reusable Terraform Modules: What's Actually Worth It","og_description":"Not every abstraction earns its keep. Judge reusable Terraform modules by their upgrade path, interface width and state stability before you adopt them.","og_url":"https:\/\/john-nessime.com\/blog\/devops\/reusable-terraform-modules\/","og_site_name":"John Nessime","article_published_time":"2026-08-13T13:00:00+00:00","og_image":[{"width":1200,"height":627,"url":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/reusable-terraform-modules-upgrade-path.png","type":"image\/png"}],"author":"John Nessime","twitter_card":"summary_large_image","twitter_misc":{"Written by":"John Nessime","Est. reading time":"15 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/john-nessime.com\/blog\/devops\/reusable-terraform-modules\/#article","isPartOf":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/reusable-terraform-modules\/"},"author":{"name":"John Nessime","@id":"https:\/\/john-nessime.com\/blog\/#\/schema\/person\/ede0b56d0c808f123f57d5d796902105"},"headline":"Terraform Modules Worth Reusing (And the Ones That Cost You)","datePublished":"2026-08-13T13:00:00+00:00","mainEntityOfPage":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/reusable-terraform-modules\/"},"wordCount":3383,"commentCount":0,"publisher":{"@id":"https:\/\/john-nessime.com\/blog\/#\/schema\/person\/ede0b56d0c808f123f57d5d796902105"},"image":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/reusable-terraform-modules\/#primaryimage"},"thumbnailUrl":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/reusable-terraform-modules-upgrade-path.png","keywords":["Architecture","Automation","Checkov","CI\/CD","Code Reuse","DevOps","HCL","Infrastructure","Infrastructure as Code","Module Versioning","OpenTofu","Platform Engineering","Terraform","Terraform Modules","Terraform Registry","Terraform Testing","terraform-docs","tflint"],"articleSection":["Cloud Computing","DevOps","Technical Guides"],"inLanguage":"en-US","potentialAction":[{"@type":"CommentAction","name":"Comment","target":["https:\/\/john-nessime.com\/blog\/devops\/reusable-terraform-modules\/#respond"]}]},{"@type":"WebPage","@id":"https:\/\/john-nessime.com\/blog\/devops\/reusable-terraform-modules\/","url":"https:\/\/john-nessime.com\/blog\/devops\/reusable-terraform-modules\/","name":"Reusable Terraform Modules: What's Actually Worth It","isPartOf":{"@id":"https:\/\/john-nessime.com\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/reusable-terraform-modules\/#primaryimage"},"image":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/reusable-terraform-modules\/#primaryimage"},"thumbnailUrl":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/reusable-terraform-modules-upgrade-path.png","datePublished":"2026-08-13T13:00:00+00:00","description":"Not every abstraction earns its keep. Judge reusable Terraform modules by their upgrade path, interface width and state stability before you adopt them.","breadcrumb":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/reusable-terraform-modules\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/john-nessime.com\/blog\/devops\/reusable-terraform-modules\/"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/john-nessime.com\/blog\/devops\/reusable-terraform-modules\/#primaryimage","url":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/reusable-terraform-modules-upgrade-path.png","contentUrl":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/reusable-terraform-modules-upgrade-path.png","width":1200,"height":627,"caption":"Diagram comparing two Terraform modules across five releases, with upgrade steps coloured by cost: one module stays current on clean plans, the other is pinned at an old version because upgrades require state moves."},{"@type":"BreadcrumbList","@id":"https:\/\/john-nessime.com\/blog\/devops\/reusable-terraform-modules\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/john-nessime.com\/blog\/"},{"@type":"ListItem","position":2,"name":"Terraform Modules Worth Reusing (And the Ones That Cost You)"}]},{"@type":"WebSite","@id":"https:\/\/john-nessime.com\/blog\/#website","url":"https:\/\/john-nessime.com\/blog\/","name":"John Nessime","description":"Cloud, DevOps, Data &amp; AI \u2014 Built, Tested, Explained","publisher":{"@id":"https:\/\/john-nessime.com\/blog\/#\/schema\/person\/ede0b56d0c808f123f57d5d796902105"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/john-nessime.com\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":["Person","Organization"],"@id":"https:\/\/john-nessime.com\/blog\/#\/schema\/person\/ede0b56d0c808f123f57d5d796902105","name":"John Nessime","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/cropped-jn.png","url":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/cropped-jn.png","contentUrl":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/cropped-jn.png","width":512,"height":512,"caption":"John Nessime"},"logo":{"@id":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/cropped-jn.png"},"description":"AWS Certified Solutions Architect helping businesses build reliable cloud, data, reporting, and automation solutions. I help startups, agencies, and growing businesses replace manual processes and disconnected data with practical AWS architectures, clean data pipelines, useful dashboards, and maintainable automation.","sameAs":["https:\/\/john-nessime.com\/blog","https:\/\/www.linkedin.com\/in\/john-m-nessime"],"url":"https:\/\/john-nessime.com\/blog\/author\/johnnessime\/"}]}},"_links":{"self":[{"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/posts\/198","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/comments?post=198"}],"version-history":[{"count":1,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/posts\/198\/revisions"}],"predecessor-version":[{"id":200,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/posts\/198\/revisions\/200"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/media\/199"}],"wp:attachment":[{"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/media?parent=198"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/categories?post=198"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/tags?post=198"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}