<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Iac on 404 Code Not Found</title>
    <link>https://www.404-code-not-found.com/tags/iac/</link>
    <description>Recent content in Iac on 404 Code Not Found</description>
    <generator>Hugo</generator>
    <language>en-us</language>
    <lastBuildDate>Fri, 21 Aug 2026 09:00:00 -0600</lastBuildDate>
    <atom:link href="https://www.404-code-not-found.com/tags/iac/index.xml" rel="self" type="application/rss+xml"/>
    <item>
      <title>How I Structure a Terraform Module</title>
      <link>https://www.404-code-not-found.com/posts/terraform-module-structure/</link>
      <pubDate>Fri, 21 Aug 2026 09:00:00 -0600</pubDate>
      <guid>https://www.404-code-not-found.com/posts/terraform-module-structure/</guid>
      <category>terraform</category>
      <category>iac</category>
      <category>pre-commit</category>
      <category>terraform-docs</category>
      <description>&lt;p&gt;&lt;img src=&#34;https://www.404-code-not-found.com/posts/terraform-module-structure/header.png&#34; alt=&#34;How I Structure a Terraform Module&#34;&gt;&lt;/p&gt;&#xA;&lt;h2 id=&#34;introduction&#34;&gt;Introduction&lt;/h2&gt;&#xA;&lt;p&gt;Open a Terraform repository you have never seen before. There is a good chance&#xA;it looks like this: one &lt;code&gt;main.tf&lt;/code&gt; with eight hundred lines in it, a&#xA;&lt;code&gt;variables.tf&lt;/code&gt; where required and optional inputs are shuffled together in&#xA;alphabetical order, a provider block with no version constraint at all, and a&#xA;&lt;code&gt;README.md&lt;/code&gt; that documented the inputs accurately about four months ago.&lt;/p&gt;&#xA;&lt;p&gt;None of that is anybody&amp;rsquo;s fault. Terraform doesn&amp;rsquo;t care how you arrange your&#xA;files. Every file ending in &lt;code&gt;.tf&lt;/code&gt; in a directory gets concatenated before&#xA;anything is evaluated, so the language gives you no reason to prefer one layout&#xA;over another. The tutorials all put everything in &lt;code&gt;main.tf&lt;/code&gt; because they&amp;rsquo;re&#xA;teaching one resource at a time, and then that becomes the shape of the repo&#xA;forever.&lt;/p&gt;&#xA;&lt;p&gt;I&amp;rsquo;m not a developer by trade. I don&amp;rsquo;t have the muscle memory that lets someone&#xA;navigate an unfamiliar codebase by feel. What I have instead is a layout I use&#xA;every single time, so that six months from now, when I come back to a module I&#xA;half remember, I know where things are before I open anything.&lt;/p&gt;&#xA;&lt;p&gt;This post is that layout, and the reasoning behind each piece of it. Every&#xA;convention here is a response to a specific way I have watched a module go bad.&#xA;It&amp;rsquo;s all based on the&#xA;&lt;a href=&#34;https://developer.hashicorp.com/terraform/language/style&#34; target=&#34;_blank&#34;&gt;HashiCorp style guide&lt;/a&gt;,&#xA;with opinions layered on where the style guide leaves room.&lt;/p&gt;&#xA;&lt;p&gt;Everything below is scaffolded in&#xA;&lt;a href=&#34;https://github.com/404-code-not-found-com/terraform-module-template&#34; target=&#34;_blank&#34;&gt;terraform-module-template&lt;/a&gt;,&#xA;which I use for modules and root modules alike.&lt;/p&gt;&#xA;&lt;h2 id=&#34;tldr&#34;&gt;TL;DR&lt;/h2&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;code&gt;data.tf&lt;/code&gt;, &lt;code&gt;locals.tf&lt;/code&gt;, &lt;code&gt;providers.tf&lt;/code&gt;, &lt;code&gt;terraform.tf&lt;/code&gt;, &lt;code&gt;backend.tf&lt;/code&gt;,&#xA;&lt;code&gt;variables.tf&lt;/code&gt;, and &lt;code&gt;outputs.tf&lt;/code&gt; always exist, even when empty.&lt;/li&gt;&#xA;&lt;li&gt;Resources live in &lt;code&gt;main.tf&lt;/code&gt; until there are roughly twenty of them, then split&#xA;by service or function.&lt;/li&gt;&#xA;&lt;li&gt;&lt;code&gt;variables.tf&lt;/code&gt; is two sections, required before optional, alphabetized inside&#xA;each. &lt;code&gt;outputs.tf&lt;/code&gt; is one alphabetized list.&lt;/li&gt;&#xA;&lt;li&gt;&lt;code&gt;terraform.tf&lt;/code&gt; holds &lt;code&gt;required_version&lt;/code&gt; and nothing else. &lt;code&gt;required_providers&lt;/code&gt;&#xA;belongs in &lt;code&gt;providers.tf&lt;/code&gt;.&lt;/li&gt;&#xA;&lt;li&gt;Pin everything pinnable with &lt;code&gt;~&amp;gt; Maj.Min&lt;/code&gt;.&lt;/li&gt;&#xA;&lt;li&gt;Generate the README with &lt;code&gt;terraform-docs&lt;/code&gt;, do not write it.&lt;/li&gt;&#xA;&lt;li&gt;Run &lt;code&gt;fmt&lt;/code&gt;, &lt;code&gt;validate&lt;/code&gt;, &lt;code&gt;docs&lt;/code&gt;, &lt;code&gt;tflint&lt;/code&gt;, and &lt;code&gt;trivy&lt;/code&gt; in pre-commit, so you find&#xA;problems before a plan does.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;h2 id=&#34;problem-1-everything-ends-up-in-maintf&#34;&gt;Problem 1: Everything Ends Up in main.tf&lt;/h2&gt;&#xA;&lt;p&gt;A module starts with three resources, so of course they go in &lt;code&gt;main.tf&lt;/code&gt;. Then it&#xA;grows. Nobody ever decides to put eight hundred lines in one file. It happens&#xA;because the alternative requires a decision, and there&amp;rsquo;s never a good moment to&#xA;make it.&lt;/p&gt;&#xA;&lt;h3 id=&#34;the-fix-files-by-role-first-size-second&#34;&gt;The Fix: Files by Role First, Size Second&lt;/h3&gt;&#xA;&lt;p&gt;Some file names are reserved for a role, and those files always exist:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;backend.tf      state backend configuration&#xA;data.tf         data sources&#xA;locals.tf       local values&#xA;main.tf         resources&#xA;outputs.tf      module outputs&#xA;providers.tf    required_providers, plus provider configuration&#xA;terraform.tf    required_version&#xA;variables.tf    input variables&#xA;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Resources stay in &lt;code&gt;main.tf&lt;/code&gt; until there are around twenty of them. Past that,&#xA;&lt;code&gt;main.tf&lt;/code&gt; splits by service or function: &lt;code&gt;network.tf&lt;/code&gt;, &lt;code&gt;iam.tf&lt;/code&gt;, &lt;code&gt;database.tf&lt;/code&gt;.&#xA;The number isn&amp;rsquo;t sacred. The point is that the split is triggered by size, and&#xA;only resources ever get split. Data sources, locals, and provider configuration&#xA;stay in their own files no matter how few or how many there are.&lt;/p&gt;&#xA;&lt;p&gt;That asymmetry is deliberate. Resource count varies enormously between modules,&#xA;so where resources live has to scale. The other categories are small and&#xA;bounded, and their value comes from being findable at a fixed address rather&#xA;than from being organized well.&lt;/p&gt;&#xA;&lt;h3 id=&#34;why-empty-files-stay&#34;&gt;Why Empty Files Stay&lt;/h3&gt;&#xA;&lt;p&gt;In a fresh module, &lt;code&gt;data.tf&lt;/code&gt; and &lt;code&gt;locals.tf&lt;/code&gt; are empty except for a header&#xA;comment:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;code class=&#34;language-hcl&#34; data-lang=&#34;hcl&#34;&gt;###############################################################################&#xA;# data.tf&#xA;#&#xA;# Contains any data sources&#xA;###############################################################################&#xA;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This is the convention people push back on most, and I&amp;rsquo;ll keep defending it. An&#xA;absent file carries no information. You can&amp;rsquo;t tell whether this module has no&#xA;data sources or whether someone put them in &lt;code&gt;main.tf&lt;/code&gt; without looking. A&#xA;present, empty file answers both questions at once: there are no data sources&#xA;yet, and here is where the next one goes.&lt;/p&gt;&#xA;&lt;p&gt;Most structural decay in a repo doesn&amp;rsquo;t come from someone disagreeing with the&#xA;layout. It comes from someone adding a data source at 5pm, not seeing an obvious&#xA;home for it, and dropping it at the bottom of &lt;code&gt;main.tf&lt;/code&gt;. An empty &lt;code&gt;data.tf&lt;/code&gt;&#xA;removes the decision, which is the only reliable way to make a convention&#xA;survive contact with a deadline.&lt;/p&gt;&#xA;&lt;h2 id=&#34;problem-2-you-cant-tell-required-from-optional&#34;&gt;Problem 2: You Can&amp;rsquo;t Tell Required From Optional&lt;/h2&gt;&#xA;&lt;p&gt;Alphabetical ordering in &lt;code&gt;variables.tf&lt;/code&gt; sounds obviously correct, and it&amp;rsquo;s the&#xA;default advice. It also means that the first thing you want to know about a&#xA;module - what do I have to give it? - is the one thing the file won&amp;rsquo;t tell you.&#xA;You have to read every block and check each one for a &lt;code&gt;default&lt;/code&gt;.&lt;/p&gt;&#xA;&lt;h3 id=&#34;the-fix-two-sections-alphabetized-inside-each&#34;&gt;The Fix: Two Sections, Alphabetized Inside Each&lt;/h3&gt;&#xA;&lt;p&gt;&lt;code&gt;variables.tf&lt;/code&gt; is split into required variables, meaning no &lt;code&gt;default&lt;/code&gt;, followed&#xA;by optional variables, meaning there is one. Each section is alphabetized&#xA;independently:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;code class=&#34;language-hcl&#34; data-lang=&#34;hcl&#34;&gt;###############################################################################&#xA;# Required Variables (no default values)&#xA;###############################################################################&#xA;variable &amp;#34;image_id&amp;#34; {&#xA;  type        = string&#xA;  description = &amp;#34;The id of the machine image (AMI) to use for the server.&amp;#34;&#xA;&#xA;  validation {&#xA;    condition     = length(var.image_id) &amp;gt; 4 &amp;amp;&amp;amp; substr(var.image_id, 0, 4) == &amp;#34;ami-&amp;#34;&#xA;    error_message = &amp;#34;The image_id value must be a valid AMI id, starting with \&amp;#34;ami-\&amp;#34;.&amp;#34;&#xA;  }&#xA;}&#xA;&#xA;###############################################################################&#xA;# Optional Variables (has a default value)&#xA;###############################################################################&#xA;variable &amp;#34;instance_type&amp;#34; {&#xA;  type        = string&#xA;  description = &amp;#34;Instance Type&amp;#34;&#xA;  default     = &amp;#34;t3.micro&amp;#34;&#xA;}&#xA;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Now the top of the file is the module&amp;rsquo;s contract. Everything above the second&#xA;banner is what a caller must supply. Everything below it is a knob they may&#xA;choose to turn. Sorting alphabetically inside each section keeps lookup fast&#xA;without giving up that distinction.&lt;/p&gt;&#xA;&lt;p&gt;&lt;code&gt;outputs.tf&lt;/code&gt; gets plain alphabetical ordering, because outputs have no&#xA;equivalent of the required/optional split. There is only one axis to sort on, so&#xA;sort on it.&lt;/p&gt;&#xA;&lt;h3 id=&#34;make-the-generated-docs-agree&#34;&gt;Make the Generated Docs Agree&lt;/h3&gt;&#xA;&lt;p&gt;A convention that only lives in the source file will drift the moment something&#xA;else renders the same information in a different order. &lt;code&gt;terraform-docs&lt;/code&gt;&#xA;produces the inputs table in the README, and by default it sorts by name, which&#xA;would immediately contradict &lt;code&gt;variables.tf&lt;/code&gt;.&lt;/p&gt;&#xA;&lt;p&gt;So &lt;code&gt;.terraform-docs.yml&lt;/code&gt; is configured to sort the same way:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;code class=&#34;language-yaml&#34; data-lang=&#34;yaml&#34;&gt;sort:&#xA;  enabled: true&#xA;  by: required&#xA;&#xA;settings:&#xA;  anchor: false&#xA;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Required inputs come first in the generated table, exactly as they do in the&#xA;file that generated it. It&amp;rsquo;s a small thing, but it is the difference between a&#xA;convention that is asserted once in a style guide and one that is enforced in&#xA;two places and therefore holds.&lt;/p&gt;&#xA;&lt;h2 id=&#34;problem-3-one-file-for-every-version-constraint&#34;&gt;Problem 3: One File for Every Version Constraint&lt;/h2&gt;&#xA;&lt;p&gt;The common layout is a single &lt;code&gt;versions.tf&lt;/code&gt; holding both &lt;code&gt;required_version&lt;/code&gt; and&#xA;&lt;code&gt;required_providers&lt;/code&gt;, or a &lt;code&gt;terraform&lt;/code&gt; block sitting at the top of &lt;code&gt;main.tf&lt;/code&gt;&#xA;with everything in it. Both work. Both bury two unrelated decisions in the same&#xA;place.&lt;/p&gt;&#xA;&lt;h3 id=&#34;the-fix-three-files-three-concerns&#34;&gt;The Fix: Three Files, Three Concerns&lt;/h3&gt;&#xA;&lt;p&gt;The version of Terraform itself, alone in its own file:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;code class=&#34;language-hcl&#34; data-lang=&#34;hcl&#34;&gt;# terraform.tf&#xA;terraform {&#xA;  required_version = &amp;#34;~&amp;gt; 1.0&amp;#34;&#xA;}&#xA;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The providers, their versions, and their configuration, together in another:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;code class=&#34;language-hcl&#34; data-lang=&#34;hcl&#34;&gt;# providers.tf&#xA;terraform {&#xA;  required_providers {&#xA;    google = {&#xA;      source  = &amp;#34;hashicorp/google&amp;#34;&#xA;      version = &amp;#34;~&amp;gt; 6.28&amp;#34;&#xA;    }&#xA;  }&#xA;}&#xA;&#xA;provider &amp;#34;google&amp;#34; {&#xA;  # Configuration options&#xA;}&#xA;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And where state lives, in a third:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;code class=&#34;language-hcl&#34; data-lang=&#34;hcl&#34;&gt;# backend.tf&#xA;terraform {&#xA;  cloud {&#xA;    organization = &amp;#34;example_corp&amp;#34;&#xA;    hostname     = &amp;#34;app.terraform.io&amp;#34;&#xA;&#xA;    workspaces {&#xA;      tags = [&amp;#34;app&amp;#34;]&#xA;    }&#xA;  }&#xA;}&#xA;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Yes, that&amp;rsquo;s three separate &lt;code&gt;terraform&lt;/code&gt; blocks across three files. Terraform&#xA;merges them, so this costs nothing at evaluation time and buys a clean&#xA;separation:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;terraform.tf&lt;/code&gt;&lt;/strong&gt; is about the tool. It changes when I decide to adopt a new&#xA;Terraform version, which is a deliberate, infrequent, repo-wide decision.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;providers.tf&lt;/code&gt;&lt;/strong&gt; is about dependencies. It changes when a provider ships&#xA;something I need, which happens on somebody else&amp;rsquo;s schedule and much more&#xA;often. It also holds provider configuration, which is what I&amp;rsquo;m editing most of&#xA;the time.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;backend.tf&lt;/code&gt;&lt;/strong&gt; is about where state lives. It&amp;rsquo;s the one file most likely to&#xA;differ between environments or to be supplied at init time, and the one most&#xA;likely to be the reason a &lt;code&gt;terraform init&lt;/code&gt; behaves differently on someone&#xA;else&amp;rsquo;s machine.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;Keeping them apart means a diff tells you which kind of change you are looking&#xA;at before you read a single line of it.&lt;/p&gt;&#xA;&lt;h2 id=&#34;problem-4-pins-that-are-too-loose-or-too-tight&#34;&gt;Problem 4: Pins That Are Too Loose or Too Tight&lt;/h2&gt;&#xA;&lt;p&gt;There are two failure modes here and they look nothing alike.&lt;/p&gt;&#xA;&lt;p&gt;Too loose, usually meaning no constraint at all, gives you a plan that worked&#xA;yesterday and doesn&amp;rsquo;t work today, because a provider released a major version&#xA;overnight and &lt;code&gt;terraform init&lt;/code&gt; on a clean checkout happily took it.&lt;/p&gt;&#xA;&lt;p&gt;Too tight, meaning an exact pin like &lt;code&gt;version = &amp;quot;6.28.0&amp;quot;&lt;/code&gt;, gives you a module&#xA;that never picks up a bug fix unless someone edits it, and a fleet of repos&#xA;pinned to a scatter of slightly different patch releases.&lt;/p&gt;&#xA;&lt;h3 id=&#34;the-fix-pessimistic-constraints-on-everything-pinnable&#34;&gt;The Fix: Pessimistic Constraints on Everything Pinnable&lt;/h3&gt;&#xA;&lt;p&gt;Everything that can be pinned gets pinned with the pessimistic operator at the&#xA;major-minor level: Terraform core, every provider, every module call.&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;code class=&#34;language-hcl&#34; data-lang=&#34;hcl&#34;&gt;version = &amp;#34;~&amp;gt; 6.28&amp;#34;    # &amp;gt;= 6.28.0, &amp;lt; 7.0.0&#xA;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Minor and patch releases flow in. A major version, which is where the breaking&#xA;changes live by convention, does not. That&amp;rsquo;s the tradeoff I want almost&#xA;everywhere: I get fixes without being asked, and I get to schedule the upgrades&#xA;that cost me something.&lt;/p&gt;&#xA;&lt;p&gt;One edge case. For a provider still on &lt;code&gt;0.x&lt;/code&gt;, &lt;code&gt;~&amp;gt; 0.104&lt;/code&gt; expands to&#xA;&lt;code&gt;&amp;gt;= 0.104.0, &amp;lt; 1.0.0&lt;/code&gt;, and pre-1.0 providers routinely make breaking changes on&#xA;a minor bump. If a dependency matters and is still pre-1.0, tighten it to&#xA;&lt;code&gt;~&amp;gt; 0.104.0&lt;/code&gt; and accept the manual bumps.&lt;/p&gt;&#xA;&lt;h3 id=&#34;commit-the-lock-file&#34;&gt;Commit the Lock File&lt;/h3&gt;&#xA;&lt;p&gt;&lt;code&gt;~&amp;gt; 6.28&lt;/code&gt; is a statement about what you will tolerate. &lt;code&gt;.terraform.lock.hcl&lt;/code&gt; is&#xA;a record of what you actually got, and it belongs in version control.&lt;/p&gt;&#xA;&lt;p&gt;The lock file holds on to a provider entry as long as state still references&#xA;that provider, even after you&amp;rsquo;ve deleted every last resource for it from your&#xA;configuration. The instinct is to open the lock file and delete the stanza.&#xA;Don&amp;rsquo;t. Apply first so the resources leave state, and the next &lt;code&gt;terraform init&lt;/code&gt;&#xA;will prune the entry on its own. Hand-editing a lock file to fix a problem that&#xA;an apply would have fixed tends to produce a second, stranger problem.&lt;/p&gt;&#xA;&lt;h2 id=&#34;problem-5-the-readme-drifts&#34;&gt;Problem 5: The README Drifts&lt;/h2&gt;&#xA;&lt;p&gt;Every Terraform README starts with an accurate inputs table. Then someone adds a&#xA;variable in a hurry. The code is right and the documentation is wrong, and now&#xA;the README is worse than no README, because people trust it.&lt;/p&gt;&#xA;&lt;h3 id=&#34;the-fix-generate-it&#34;&gt;The Fix: Generate It&lt;/h3&gt;&#xA;&lt;p&gt;&lt;code&gt;terraform-docs&lt;/code&gt; reads the actual variable and output blocks and writes the&#xA;tables into the README between marker comments. It runs as a pre-commit hook, so&#xA;it is not something anyone has to remember:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;code class=&#34;language-yaml&#34; data-lang=&#34;yaml&#34;&gt;- id: terraform_docs&#xA;  args:&#xA;    - --args=--lockfile=false&#xA;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The one thing that catches everybody the first time: the hook rewrites&#xA;&lt;code&gt;README.md&lt;/code&gt; during the commit, which means the commit fails, because the file it&#xA;just changed was not part of what you staged. This is correct behavior and it&#xA;looks like a broken hook. Re-stage and commit again and it passes. It only&#xA;happens when the generated content actually changed, so after the first time it&#xA;mostly disappears.&lt;/p&gt;&#xA;&lt;h2 id=&#34;problem-6-you-find-the-error-at-apply-time&#34;&gt;Problem 6: You Find the Error at Apply Time&lt;/h2&gt;&#xA;&lt;p&gt;The expensive version of every mistake in this post is the one you discover&#xA;after &lt;code&gt;terraform apply&lt;/code&gt; has already created four of seven resources.&lt;/p&gt;&#xA;&lt;h3 id=&#34;the-fix-a-pre-commit-chain-in-order&#34;&gt;The Fix: A Pre-Commit Chain, in Order&lt;/h3&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;code class=&#34;language-yaml&#34; data-lang=&#34;yaml&#34;&gt;- repo: https://github.com/antonbabenko/pre-commit-terraform&#xA;  rev: v1.108.1&#xA;  hooks:&#xA;    - id: terraform_fmt&#xA;    - id: terraform_validate&#xA;      args:&#xA;        - --hook-config=--retry-once-with-cleanup=true&#xA;    - id: terraform_docs&#xA;      args:&#xA;        - --args=--lockfile=false&#xA;    - id: terraform_tflint&#xA;      args:&#xA;        - --args=--config=__GIT_WORKING_DIR__/.tflint.hcl&#xA;    - id: terraform_trivy&#xA;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The order is cheapest and most mechanical first, so that later hooks are reading&#xA;code that is already well formed:&lt;/p&gt;&#xA;&lt;ol&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;terraform_fmt&lt;/code&gt;&lt;/strong&gt; normalizes whitespace and alignment. Running it first&#xA;means no other hook, and no future diff, is ever about formatting.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;terraform_validate&lt;/code&gt;&lt;/strong&gt; catches syntax and internal consistency errors.&#xA;&lt;code&gt;--retry-once-with-cleanup=true&lt;/code&gt; handles the common case where validation&#xA;fails only because of a stale &lt;code&gt;.terraform&lt;/code&gt; directory: it clears it and tries&#xA;once more instead of reporting a problem you don&amp;rsquo;t have.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;terraform_docs&lt;/code&gt;&lt;/strong&gt; regenerates the README, now that the code it is&#xA;documenting is known to be valid.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;terraform_tflint&lt;/code&gt;&lt;/strong&gt; catches the things &lt;code&gt;validate&lt;/code&gt; structurally cannot -&#xA;deprecated syntax, unused declarations, provider-specific mistakes such as an&#xA;instance type that doesn&amp;rsquo;t exist.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;terraform_trivy&lt;/code&gt;&lt;/strong&gt; scans for security misconfigurations. It goes last&#xA;because it is the slowest and the most likely to need a human judgment call&#xA;about whether a finding applies.&lt;/li&gt;&#xA;&lt;/ol&gt;&#xA;&lt;p&gt;Alongside those, the standard hygiene hooks run on everything, and one of them&#xA;earns a special mention:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;code class=&#34;language-yaml&#34; data-lang=&#34;yaml&#34;&gt;- id: no-commit-to-branch&#xA;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That blocks commits directly to &lt;code&gt;main&lt;/code&gt;. It&amp;rsquo;s saved me from myself more than any&#xA;of the Terraform-specific hooks.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-directories-you-dont-need-yet&#34;&gt;The Directories You Don&amp;rsquo;t Need Yet&lt;/h2&gt;&#xA;&lt;p&gt;The template ships five directories that are empty apart from a &lt;code&gt;README.md&lt;/code&gt;:&#xA;&lt;code&gt;examples/&lt;/code&gt;, &lt;code&gt;modules/&lt;/code&gt;, &lt;code&gt;tests/&lt;/code&gt;, &lt;code&gt;files/&lt;/code&gt;, and &lt;code&gt;templates/&lt;/code&gt;.&lt;/p&gt;&#xA;&lt;p&gt;The placeholder README exists because git won&amp;rsquo;t track an empty directory, so&#xA;without a file in it the directory would not survive a clone. But the reason to&#xA;scaffold them at all is the same reason the empty &lt;code&gt;data.tf&lt;/code&gt; earns its place.&#xA;When it&amp;rsquo;s time to write the first test, there is no decision to make about where&#xA;tests go. When someone needs a usage example, &lt;code&gt;examples/&lt;/code&gt; is already there with&#xA;a note explaining what belongs in it.&lt;/p&gt;&#xA;&lt;p&gt;Scaffolding an empty directory costs nothing today. Adding one to a repo that&#xA;has already grown around its absence means moving files and updating every path&#xA;that pointed at them.&lt;/p&gt;&#xA;&lt;h2 id=&#34;conclusion&#34;&gt;Conclusion&lt;/h2&gt;&#xA;&lt;p&gt;None of this is clever, and that&amp;rsquo;s the point. It&amp;rsquo;s a set of decisions made once&#xA;so that they do not have to be made again while tired:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Reserved file names, always present&lt;/strong&gt; - &lt;code&gt;data.tf&lt;/code&gt;, &lt;code&gt;locals.tf&lt;/code&gt;,&#xA;&lt;code&gt;providers.tf&lt;/code&gt;, &lt;code&gt;terraform.tf&lt;/code&gt;, &lt;code&gt;backend.tf&lt;/code&gt;, &lt;code&gt;variables.tf&lt;/code&gt;, &lt;code&gt;outputs.tf&lt;/code&gt;.&#xA;Empty is fine, and an empty file still tells you something.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Only resources scale&lt;/strong&gt; - &lt;code&gt;main.tf&lt;/code&gt; splits by service past roughly twenty&#xA;resources. Nothing else splits, ever.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Required before optional&lt;/strong&gt; in &lt;code&gt;variables.tf&lt;/code&gt;, alphabetized within each&#xA;section, with &lt;code&gt;terraform-docs&lt;/code&gt; configured to sort the same way so the&#xA;generated table cannot contradict the source.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Three files for three kinds of version decision&lt;/strong&gt; - the tool, its&#xA;dependencies, and where state lives.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;~&amp;gt; Maj.Min&lt;/code&gt; on everything pinnable&lt;/strong&gt;, with the pre-1.0 caveat, and the lock&#xA;file committed as the record of what you actually resolved.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Generate the README&lt;/strong&gt;, never hand-maintain it.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;fmt&lt;/code&gt;, &lt;code&gt;validate&lt;/code&gt;, &lt;code&gt;docs&lt;/code&gt;, &lt;code&gt;tflint&lt;/code&gt;, &lt;code&gt;trivy&lt;/code&gt;&lt;/strong&gt; in that order in pre-commit,&#xA;because the cheapest place to find any of these problems is before the commit,&#xA;and the most expensive is halfway through an apply.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;The whole thing is in&#xA;&lt;a href=&#34;https://github.com/404-code-not-found-com/terraform-module-template&#34; target=&#34;_blank&#34;&gt;terraform-module-template&lt;/a&gt;&#xA;if you would rather clone it than assemble it.&lt;/p&gt;&#xA;&lt;p&gt;If you disagree with a specific convention here, that&amp;rsquo;s fine and probably&#xA;healthy. What matters far more than which layout you pick is that you pick one&#xA;and it&amp;rsquo;s the same in every repo. Decide once, and you stop spending attention on&#xA;the question.&lt;/p&gt;&#xA;&lt;h2 id=&#34;up-next-terraform-in-practice&#34;&gt;Up Next: Terraform in Practice&lt;/h2&gt;&#xA;&lt;p&gt;This kicks off a series on the practices that sit around the Terraform code&#xA;rather than in it. Next up: getting static credentials out of your&#xA;infrastructure entirely - a Vault SSH certificate authority instead of&#xA;distributing key pairs, OIDC instead of long-lived AWS access keys, and dynamic&#xA;database credentials instead of a shared application user. Three substitutions,&#xA;and the gotcha that bites on each one.&lt;/p&gt;&#xA;</description>
    </item>
  </channel>
</rss>
