{"id":2147,"date":"2026-02-27T23:56:59","date_gmt":"2026-02-27T10:56:59","guid":{"rendered":"https:\/\/www.ronella.xyz\/?p=2147"},"modified":"2026-02-27T23:56:59","modified_gmt":"2026-02-27T10:56:59","slug":"building-with-terraform-modules","status":"publish","type":"post","link":"https:\/\/www.ronella.xyz\/?p=2147","title":{"rendered":"Building With Terraform Modules"},"content":{"rendered":"<p>Terraform modules are how you turn raw Terraform into a reusable, versioned \u201clibrary\u201d of infrastructure components. In this article we\u2019ll go through what modules are, the types you\u2019ll see in practice, how to create them, when to factor code into a module, how to update them safely, how to publish them, and finally how to consume them from your stacks.<\/p>\n<hr \/>\n<h2>What is a Terraform module?<\/h2>\n<p>At its core, a module is just a directory containing Terraform configuration that can be called from other Terraform code.<\/p>\n<ul>\n<li>Any directory with <code>.tf<\/code> files is a module.<\/li>\n<li>The directory where you run <code>terraform init\/plan\/apply<\/code> is your <strong>root module<\/strong>.<\/li>\n<li>A root module can call <strong>child modules<\/strong> via <code>module<\/code> blocks, which is how you achieve reuse and composition.<\/li>\n<\/ul>\n<p>Conceptually, a module is like a function in code:<\/p>\n<ul>\n<li>Inputs \u2192 variables<\/li>\n<li>Logic \u2192 resources, locals, data sources<\/li>\n<li>Outputs \u2192 values other code can depend on<\/li>\n<\/ul>\n<p>Good modules hide internal complexity behind a clear, minimal interface, exactly as you\u2019d expect from a well\u2011designed API.<\/p>\n<hr \/>\n<h2>Types of modules you\u2019ll deal with<\/h2>\n<p>In practice you\u2019ll encounter several \u201ctypes\u201d or roles of modules:<\/p>\n<ol>\n<li><strong>Root module<\/strong>\n<ul>\n<li>The entrypoint of a stack (e.g. <code>envs\/prod<\/code>), where you configure providers, backends, and call other modules.<\/li>\n<li>Represents one deployable unit: a whole environment, a service, or a single app stack.<\/li>\n<\/ul>\n<\/li>\n<li><strong>Child \/ reusable modules<\/strong>\n<ul>\n<li>Reusable building blocks: VPCs, EKS clusters, RDS databases, S3 buckets, etc.<\/li>\n<li>Usually live under <code>modules\/<\/code> in a repo, or in a separate repo entirely.<\/li>\n<li>Called from root or other modules with <code>module &quot;name&quot; { ... }<\/code>.<\/li>\n<\/ul>\n<\/li>\n<li><strong>Public registry modules<\/strong>\n<ul>\n<li>Published to the public Terraform Registry, versioned and documented.<\/li>\n<li>Example: <code>terraform-aws-modules\/vpc\/aws<\/code><\/li>\n<li>Great for standard primitives (VPCs, security groups, S3, etc.), less so for business\u2011specific patterns.<\/li>\n<\/ul>\n<\/li>\n<li><strong>Private\/organizational modules<\/strong>\n<ul>\n<li>Hosted in private registries or Git repos.<\/li>\n<li>Usually represent your organization\u2019s conventions and guardrails (\u201ca compliant VPC\u201d, \u201ca hardened EKS cluster\u201d).<\/li>\n<\/ul>\n<\/li>\n<\/ol>\n<p>Architecturally, many teams settle on layers:<\/p>\n<ul>\n<li>Layer 0: cloud and providers (root module).<\/li>\n<li>Layer 1: platform modules (VPC, KMS, logging, IAM baselines).<\/li>\n<li>Layer 2: product\/service modules (service X, API Y) that compose platform modules.<\/li>\n<\/ul>\n<hr \/>\n<h2>Creating a Terraform module<\/h2>\n<h3>Standard structure<\/h3>\n<p>A well\u2011structured module typically has:<\/p>\n<ul>\n<li><code>main.tf<\/code> \u2013 core resources and module logic<\/li>\n<li><code>variables.tf<\/code> \u2013 input interface<\/li>\n<li><code>outputs.tf<\/code> \u2013 exported values<\/li>\n<li><code>versions.tf<\/code> (optional but recommended) \u2013 provider and Terraform version constraints<\/li>\n<li><code>README.md<\/code> \u2013 usage, inputs, outputs, examples<\/li>\n<\/ul>\n<p>This structure is not required by Terraform but is widely used because it keeps interfaces clear and tooling friendly.<\/p>\n<h3>Simple working example<\/h3>\n<p>Let\u2019s build a small AWS S3 bucket module and then consume it from a root module.<\/p>\n<h4>Module: <code>modules\/aws_s3_bucket<\/code><\/h4>\n<p><code>modules\/aws_s3_bucket\/versions.tf<\/code>:<\/p>\n<pre><code class=\"language-terraform\">terraform {\n  required_version = &quot;&gt;= 1.6.0&quot;\n\n  required_providers {\n    aws = {\n      source  = &quot;hashicorp\/aws&quot;\n      version = &quot;&gt;= 5.0&quot;\n    }\n  }\n}<\/code><\/pre>\n<p><code>modules\/aws_s3_bucket\/variables.tf<\/code>:<\/p>\n<pre><code class=\"language-terraform\">variable &quot;bucket_name&quot; {\n  type        = string\n  description = &quot;Name of the S3 bucket.&quot;\n}\n\nvariable &quot;environment&quot; {\n  type        = string\n  description = &quot;Environment name (e.g., dev, prod).&quot;\n  default     = &quot;dev&quot;\n}\n\nvariable &quot;extra_tags&quot; {\n  type        = map(string)\n  description = &quot;Additional tags to apply to the bucket.&quot;\n  default     = {}\n}<\/code><\/pre>\n<p><code>modules\/aws_s3_bucket\/main.tf<\/code>:<\/p>\n<pre><code class=\"language-terraform\">resource &quot;aws_s3_bucket&quot; &quot;this&quot; {\n  bucket = var.bucket_name\n\n  tags = merge(\n    {\n      Name        = var.bucket_name\n      Environment = var.environment\n    },\n    var.extra_tags\n  )\n}<\/code><\/pre>\n<p><code>modules\/aws_s3_bucket\/outputs.tf<\/code>:<\/p>\n<pre><code class=\"language-terraform\">output &quot;bucket_id&quot; {\n  description = &quot;The ID (name) of the bucket.&quot;\n  value       = aws_s3_bucket.this.id\n}\n\noutput &quot;bucket_arn&quot; {\n  description = &quot;The ARN of the bucket.&quot;\n  value       = aws_s3_bucket.this.arn\n}<\/code><\/pre>\n<p>Rationale:<\/p>\n<ul>\n<li><code>variables.tf<\/code> defines the module\u2019s public input contract.<\/li>\n<li><code>outputs.tf<\/code> defines the public output contract.<\/li>\n<li><code>versions.tf<\/code> protects you from incompatible provider\/Terraform versions.<\/li>\n<li><code>main.tf<\/code> stays focused on resources and any derived locals.<\/li>\n<\/ul>\n<h3>Root module consuming it<\/h3>\n<p>In your root directory (e.g. project root):<\/p>\n<p><code>versions.tf<\/code>:<\/p>\n<pre><code class=\"language-terraform\">terraform {\n  required_version = &quot;&gt;= 1.6.0&quot;\n\n  required_providers {\n    aws = {\n      source  = &quot;hashicorp\/aws&quot;\n      version = &quot;&gt;= 5.0&quot;\n    }\n  }\n}<\/code><\/pre>\n<p><code>providers.tf<\/code>:<\/p>\n<pre><code class=\"language-terraform\">provider &quot;aws&quot; {\n  region                      = &quot;us-east-1&quot;\n\n  # Fake credentials for LocalStack\n  access_key                  = &quot;test&quot;\n  secret_key                  = &quot;test&quot;\n\n  skip_credentials_validation = true\n  skip_metadata_api_check     = true\n  skip_requesting_account_id  = true\n  s3_use_path_style           = true\n\n  # Point AWS services at LocalStack\n  endpoints {\n    s3 = &quot;http:\/\/localhost:4566&quot;\n    # add more if needed, e.g. dynamodb = &quot;http:\/\/localhost:4566&quot;\n  }\n}<\/code><\/pre>\n<p><code>variables.tf<\/code>:<\/p>\n<pre><code class=\"language-terraform\">variable &quot;aws_region&quot; {\n  type        = string\n  description = &quot;AWS region to deploy into.&quot;\n  default     = &quot;ap-southeast-2&quot;\n}\n\nvariable &quot;environment&quot; {\n  type        = string\n  description = &quot;Environment name.&quot;\n  default     = &quot;dev&quot;\n}<\/code><\/pre>\n<p><code>main.tf<\/code>:<\/p>\n<pre><code class=\"language-terraform\">module &quot;logs_bucket&quot; {\n  source      = &quot;.\/modules\/aws_s3_bucket&quot;\n  bucket_name = &quot;my-org-logs-${var.environment}&quot;\n  environment = var.environment\n  extra_tags = {\n    owner = &quot;platform-team&quot;\n  }\n}\n\noutput &quot;logs_bucket_arn&quot; {\n  value       = module.logs_bucket.bucket_arn\n  description = &quot;Logs bucket ARN.&quot;\n}<\/code><\/pre>\n<h3>How to validate this example<\/h3>\n<p>From the root directory:<\/p>\n<ol>\n<li>\n<p><strong>Start LocalStack<\/strong> (for example, via Docker):<\/p>\n<pre><code class=\"language-bash\">docker run --rm -it -p 4566:4566 -p 4510-4559:4510-4559 localstack\/localstack<\/code><\/pre>\n<p>This exposes the LocalStack APIs on <code>http:\/\/localhost:4566<\/code> as expected by the provider config.<\/p>\n<\/li>\n<li>\n<p><code>terraform init<\/code><\/p>\n<\/li>\n<\/ol>\n<ul>\n<li>Ensures Terraform and the AWS provider are set up; discovers the local module.<\/li>\n<\/ul>\n<ol start=\"3\">\n<li><code>terraform validate<\/code><\/li>\n<\/ol>\n<ul>\n<li>Confirms syntax, types, required variables satisfied.<\/li>\n<\/ul>\n<ol start=\"4\">\n<li><code>terraform plan<\/code><\/li>\n<\/ol>\n<ul>\n<li>You should see one S3 bucket to be created, with the name <code>my-org-logs-dev<\/code> by default.<\/li>\n<li>Confirm that the tags include <code>Environment = dev<\/code> and <code>owner = platform-team<\/code>.<\/li>\n<\/ul>\n<ol start=\"5\">\n<li><code>terraform apply<\/code><\/li>\n<\/ol>\n<ul>\n<li>After apply, run <code>terraform output logs_bucket_arn<\/code> and check that:\n<ul>\n<li>The ARN looks correct for your region.<\/li>\n<li>The bucket exists in AWS with expected tags.<\/li>\n<\/ul>\n<\/li>\n<\/ul>\n<p>If these checks pass, your module and consumption pattern are wired correctly.<\/p>\n<hr \/>\n<h2>When to create a module<\/h2>\n<p>You should not modularise everything; the trick is to modularise at the right abstraction boundaries.<\/p>\n<h2>Good reasons to create a module<\/h2>\n<ul>\n<li><strong>You\u2019re copy\u2011pasting the same pattern<\/strong> across stacks or repos\n<ul>\n<li>Example: the same cluster pattern for dev, stage, prod.<\/li>\n<li>A module eliminates duplication and concentrates fixes in one place.<\/li>\n<\/ul>\n<\/li>\n<li><strong>You have a logical component<\/strong> with a clear responsibility\n<ul>\n<li>Examples: \u201cnetworking\u201d, \u201cobservability stack\u201d, \u201cGeneric service with ALB + ECS + RDS\u201d.<\/li>\n<li>Each becomes a module with focused inputs and outputs.<\/li>\n<\/ul>\n<\/li>\n<li><strong>You want to hide complexity and provide sane defaults<\/strong>\n<ul>\n<li>Consumers shouldn\u2019t need to know every IAM policy detail.<\/li>\n<li>Provide a small set of inputs; encode your standards inside the module.<\/li>\n<\/ul>\n<\/li>\n<li><strong>You want a contract between teams<\/strong>\n<ul>\n<li>Platform team maintains modules; product teams just configure inputs.<\/li>\n<li>This aligns nicely with how you manage APIs or libraries internally.<\/li>\n<\/ul>\n<\/li>\n<\/ul>\n<h2>When not to create a module (yet)<\/h2>\n<ul>\n<li>One\u2011off experiments or throwaway code.<\/li>\n<li>A single, simple resource that is unlikely to be reused.<\/li>\n<li>When you don\u2019t yet understand the pattern \u2014 premature modularisation leads to awkward, unstable interfaces.<\/li>\n<\/ul>\n<p>A good heuristic: if you\u2019d be comfortable writing a README with \u201cwhat this does, inputs, outputs\u201d and you expect re\u2011use, it\u2019s a good module candidate.<\/p>\n<hr \/>\n<h2>Updating a module safely<\/h2>\n<p>Updating modules has two dimensions: changing the module itself, and rolling out the updated version to consumers.<\/p>\n<h3>Evolving the module interface<\/h3>\n<p>Prefer <strong>backwards\u2011compatible<\/strong> changes when possible:<\/p>\n<ul>\n<li>Add new variables with sensible defaults instead of changing existing ones.<\/li>\n<li>Add new outputs without altering the meaning of existing outputs.<\/li>\n<li>If you must break behaviour, bump a major version and document the migration path.<\/li>\n<\/ul>\n<p>Internally you might refactor resources, adopt new provider versions, or change naming conventions, but keep the external contract as stable as you can.<\/p>\n<h3>Versioning strategy<\/h3>\n<p>For modules in a separate repo or registry:<\/p>\n<ul>\n<li>Use semantic versioning: <code>MAJOR.MINOR.PATCH<\/code>.\n<ul>\n<li>PATCH: bugfixes, no breaking changes.<\/li>\n<li>MINOR: new optional features, backwards compatible.<\/li>\n<li>MAJOR: breaking changes.<\/li>\n<\/ul>\n<\/li>\n<\/ul>\n<p>Tag releases (<code>v1.2.3<\/code>) and use those tags in consumers (Git or registry).<\/p>\n<h3>Rolling out updates to consumers<\/h3>\n<p>For a Git\u2011sourced module:<\/p>\n<pre><code class=\"language-terraform\">module &quot;logs_bucket&quot; {\n  source  = &quot;git::https:\/\/github.com\/my-org\/terraform-aws-s3-bucket.git?ref=v1.3.0&quot;\n  # ...\n}<\/code><\/pre>\n<p>To upgrade:<\/p>\n<ol>\n<li>Change <code>ref<\/code> from <code>v1.2.0<\/code> to <code>v1.3.0<\/code>.<\/li>\n<li>Run <code>terraform init -upgrade<\/code>.<\/li>\n<li>Run <code>terraform plan<\/code> and review changes carefully.<\/li>\n<li>Apply in lower environments first, then promote the same version to higher environments (via branch promotion, pipelines, or workspace variables).<\/li>\n<\/ol>\n<p>For a registry module, the pattern is the same but with a <code>version<\/code> argument:<\/p>\n<pre><code class=\"language-terraform\">module &quot;vpc&quot; {\n  source  = &quot;terraform-aws-modules\/vpc\/aws&quot;\n  version = &quot;~&gt; 5.3.0&quot;\n}<\/code><\/pre>\n<p>Pinning versions gives you reproducibility and avoids surprise changes across environments.<\/p>\n<hr \/>\n<h2>Publishing a module<\/h2>\n<p>Publishing is about making your module discoverable and consumable by others, with strong versioning and documentation.<\/p>\n<h3>Public registry (high\u2011level)<\/h3>\n<p>To publish a module publicly (e.g. to the Terraform Registry):<\/p>\n<ul>\n<li>Place the module in a public VCS repo (commonly GitHub).<\/li>\n<li>Name the repo using the convention: <code>terraform-&lt;PROVIDER&gt;-&lt;NAME&gt;<\/code>\n<ul>\n<li>Example: <code>terraform-aws-s3-bucket<\/code>.<\/li>\n<\/ul>\n<\/li>\n<li>Ensure the repo root contains your module (<code>main.tf<\/code>, <code>variables.tf<\/code>, <code>outputs.tf<\/code>, etc.).<\/li>\n<li>Tag a version (e.g. <code>v1.0.0<\/code>).<\/li>\n<li>Register the module on the registry UI (linking your VCS account).<\/li>\n<\/ul>\n<p>Once indexed, users can consume it as:<\/p>\n<pre><code class=\"language-terraform\">module &quot;logs_bucket&quot; {\n  source  = &quot;my-org\/s3-bucket\/aws&quot;\n  version = &quot;1.0.0&quot;\n\n  bucket_name = &quot;my-org-logs-prod&quot;\n  environment = &quot;prod&quot;\n}<\/code><\/pre>\n<h3>Private registries and Git<\/h3>\n<p>For internal usage, many organizations prefer:<\/p>\n<ul>\n<li><strong>Private registry<\/strong> (Terraform Cloud\/Enterprise, vendor platform, or self\u2011hosted).\n<ul>\n<li>Similar flow to the public registry, but scoped to your org.<\/li>\n<\/ul>\n<\/li>\n<li><strong>Direct Git usage<\/strong>\n<ul>\n<li>Modules are consumed from Git with <code>?ref=<\/code> pointing to tags or commits.<\/li>\n<li>Simpler setup, but you lose some of the browsing and discoverability that registries provide.<\/li>\n<\/ul>\n<\/li>\n<\/ul>\n<p>The key idea is the same: modules are versioned artefacts, and consumers should <strong>pin versions<\/strong> and upgrade intentionally.<\/p>\n<hr \/>\n<h2>Consuming modules (putting it all together)<\/h2>\n<p>To consume any module, you:<\/p>\n<ol>\n<li>Add a <code>module<\/code> block.<\/li>\n<li>Set <code>source<\/code> to a local path, Git URL, or registry identifier.<\/li>\n<li>Pass the required inputs as arguments.<\/li>\n<li>Use the module\u2019s outputs via <code>module.&lt;name&gt;.&lt;output_name&gt;<\/code>.<\/li>\n<\/ol>\n<p>Example: consuming a local network module and a registry VPC module side by side.<\/p>\n<pre><code class=\"language-terraform\"># Local module (your own)\nmodule &quot;network&quot; {\n  source = &quot;.\/modules\/network&quot;\n\n  vpc_cidr        = &quot;10.0.0.0\/16&quot;\n  public_subnets  = [&quot;10.0.1.0\/24&quot;, &quot;10.0.2.0\/24&quot;]\n  private_subnets = [&quot;10.0.11.0\/24&quot;, &quot;10.0.12.0\/24&quot;]\n}\n\n# Registry module (third-party)\nmodule &quot;logs_bucket&quot; {\n  source  = &quot;terraform-aws-modules\/s3-bucket\/aws&quot;\n  version = &quot;~&gt; 4.0&quot;\n\n  bucket = &quot;my-org-logs-prod&quot;\n\n  tags = {\n    Environment = &quot;prod&quot;\n  }\n}\n\noutput &quot;network_vpc_id&quot; {\n  value = module.network.vpc_id\n}\n\noutput &quot;logs_bucket_arn&quot; {\n  value = module.logs_bucket.s3_bucket_arn\n}<\/code><\/pre>\n<p>The root module becomes a composition layer, wiring together multiple modules rather than directly declaring many low\u2011level resources.<\/p>\n<hr \/>\n<h2>Summary of key practices<\/h2>\n<ul>\n<li>Treat modules as <strong>APIs<\/strong>: clear inputs, clear outputs, stable contracts.<\/li>\n<li>Use a predictable structure: <code>main.tf<\/code>, <code>variables.tf<\/code>, <code>outputs.tf<\/code>, <code>versions.tf<\/code>, <code>README.md<\/code>.<\/li>\n<li>Only create modules where there is clear reuse or a meaningful abstraction.<\/li>\n<li>Version modules and pin those versions when consuming them.<\/li>\n<li>Use lower environments and <code>terraform plan<\/code> to validate updates before promoting.<\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>Terraform modules are how you turn raw Terraform into a reusable, versioned \u201clibrary\u201d of infrastructure components. In this article we\u2019ll go through what modules are, the types you\u2019ll see in practice, how to create them, when to factor code into a module, how to update them safely, how to publish them, and finally how to [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":[],"categories":[94],"tags":[],"_links":{"self":[{"href":"https:\/\/www.ronella.xyz\/index.php?rest_route=\/wp\/v2\/posts\/2147"}],"collection":[{"href":"https:\/\/www.ronella.xyz\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.ronella.xyz\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.ronella.xyz\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.ronella.xyz\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=2147"}],"version-history":[{"count":1,"href":"https:\/\/www.ronella.xyz\/index.php?rest_route=\/wp\/v2\/posts\/2147\/revisions"}],"predecessor-version":[{"id":2148,"href":"https:\/\/www.ronella.xyz\/index.php?rest_route=\/wp\/v2\/posts\/2147\/revisions\/2148"}],"wp:attachment":[{"href":"https:\/\/www.ronella.xyz\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=2147"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.ronella.xyz\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=2147"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.ronella.xyz\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=2147"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}