How to Write Reusable Terraform Modules: Step-by-Step (2026)
This guide is for engineers who have a Terraform repo full of copied resource blocks and want to turn them into a module that other teams can call without reading its source. By the end you will have a small AWS log-bucket module with typed inputs, optional() defaults, validation rules, documented outputs, a terraform test suite that runs without cloud credentials, a generated README, and a tagged release that environments pin. Commands target Terraform 1.17.1 and the AWS provider 6.72.0; most of it also applies to OpenTofu 1.13, but I tested against Terraform only. You need Terraform installed, Git, and a repository to publish to. If you are still deciding how environments should be laid out around these modules, read the companion post on Terraform project structure for multiple environments first.
Step 1: Lay out the module with the standard structure
A module is just a directory of .tf files, but the registry and tooling expect the standard module structure: a root with main.tf, variables.tf and outputs.tf, a README, and optional examples/ and modules/ subdirectories. Following it costs nothing and means terraform-docs, the registry and your colleagues all find things where they expect them.
mkdir -p terraform-aws-log-bucket/{examples/basic,tests}
cd terraform-aws-log-bucket
touch main.tf variables.tf outputs.tf versions.tf README.md
git init
terraform-aws-log-bucket/
├── main.tf # resources
├── variables.tf # inputs: the public API
├── outputs.tf # outputs: the other half of the API
├── versions.tf # required_version and required_providers
├── README.md # generated in Step 6
├── examples/basic/ # a runnable root module that calls this one
└── tests/ # *.tftest.hcl files
The repository name follows the registry convention terraform-<PROVIDER>-<NAME>, which the publishing docs require if you ever want it in a public or private registry.
Step 2: Pin versions without configuring providers
A reusable module declares which providers and Terraform versions it needs, but never configures a provider itself. No provider "aws" { region = ... } inside the module: the caller owns regions, credentials and aliases. A module with its own provider block also cannot be used with for_each or count.
# versions.tf
terraform {
# Lowest version whose features you use. 1.9 is the floor for cross-variable validation.
required_version = ">= 1.9.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 6.0, < 7.0" # a range, not an exact pin: the root module pins exactly
}
}
}
Modules use ranges; root modules and their lock files pin exact versions. If every module pins = 6.72.0, upgrading the provider means releasing every module at once.
Step 3: Design inputs with types, optional() defaults and validation
Inputs are the module's public API, so make them strict. Three features do most of the work:
- Object types with
optional(), available since Terraform 1.3.optional(TYPE, DEFAULT)fills in a default when the caller omits the attribute or sets it tonull, per the type constraints docs. validationblocks, which since Terraform 1.9 can refer to other variables, not just the one being validated.nullable = falseso callers cannot passnulland bypass your default.
# variables.tf
variable "name" {
type = string
description = "Bucket name prefix. Lowercase letters, digits and hyphens."
validation {
condition = can(regex("^[a-z0-9][a-z0-9-]{1,40}[a-z0-9]$", var.name))
error_message = "name must be 3-42 chars of lowercase letters, digits and hyphens."
}
}
variable "environment" {
type = string
description = "Deployment environment. Used in the bucket name and tags."
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be one of dev, staging, prod."
}
}
variable "versioning" {
type = bool
description = "Enable object versioning. Cannot be disabled in prod."
default = true
nullable = false
validation {
# Cross-variable reference: requires Terraform 1.9+
condition = var.environment != "prod" || var.versioning
error_message = "versioning cannot be disabled in prod."
}
}
variable "lifecycle_rules" {
type = list(object({
id = string
enabled = optional(bool, true)
prefix = optional(string, "")
expiration_days = optional(number)
}))
description = "Lifecycle rules. Only id is required."
default = []
nullable = false
}
variable "tags" {
type = map(string)
description = "Extra tags merged onto every resource."
default = {}
nullable = false
}
Write error_message as a sentence the caller can act on. This is what they see when they get it wrong (real output from a local run):
Error: Invalid value for variable
on variables.tf line 22:
22: variable "versioning" {
├────────────────
│ var.environment is "prod"
│ var.versioning is false
versioning cannot be disabled in prod.
This was checked by the validation rule at variables.tf:28,3-13.
Two newer arguments are worth knowing once you have consumers. Terraform 1.15 added deprecated = "reason" on variables and outputs, which warns callers still using an input you plan to remove. It also added const = true, which marks a variable as known at init time so it can be used in a module's source or version. Both are Terraform 1.15+ only, so raise required_version if you use them.
Step 4: Write resources and outputs
Keep main.tf boring. Derive names in locals, turn lists into maps for for_each so that reordering the list does not destroy resources, and merge tags in one place.
# main.tf
locals {
bucket_name = "${var.name}-${var.environment}"
rules = { for r in var.lifecycle_rules : r.id => r }
tags = merge(var.tags, { environment = var.environment, managed-by = "terraform" })
}
resource "aws_s3_bucket" "this" {
bucket = local.bucket_name
tags = local.tags
}
resource "aws_s3_bucket_public_access_block" "this" {
bucket = aws_s3_bucket.this.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_versioning" "this" {
bucket = aws_s3_bucket.this.id
versioning_configuration {
status = var.versioning ? "Enabled" : "Suspended"
}
}
resource "aws_s3_bucket_lifecycle_configuration" "this" {
count = length(local.rules) > 0 ? 1 : 0
bucket = aws_s3_bucket.this.id
dynamic "rule" {
for_each = local.rules
content {
id = rule.key
status = rule.value.enabled ? "Enabled" : "Disabled"
filter {
prefix = rule.value.prefix
}
dynamic "expiration" {
for_each = rule.value.expiration_days == null ? [] : [rule.value.expiration_days]
content {
days = expiration.value
}
}
}
}
}
# outputs.tf
output "bucket_name" {
description = "Final bucket name, including the environment suffix."
value = aws_s3_bucket.this.bucket
}
output "bucket_arn" {
description = "Bucket ARN, for IAM policies in the calling module."
value = aws_s3_bucket.this.arn
}
Output what callers need to wire things together (IDs, ARNs, names), not every attribute. Each output is a promise; removing one later is a breaking change.
Step 5: Compose modules instead of nesting them deeply
Composition means the root module calls several flat modules and passes outputs between them, rather than module A calling B calling C. Deep nesting hides inputs: if the caller needs to change a setting three levels down, every layer has to re-expose it.
# examples/basic/main.tf (a root module, so it configures the provider)
provider "aws" {
region = "eu-west-1"
}
module "logs" {
source = "../.."
name = "acme-logs"
environment = "dev"
lifecycle_rules = [
{ id = "expire-tmp", prefix = "tmp/", expiration_days = 7 },
]
}
module "reader_role" {
source = "app.terraform.io/acme/iam-reader/aws" # another small module
version = "~> 1.2"
bucket_arn = module.logs.bucket_arn # composition: output of one, input of the next
}
If you rename resources inside a module, add a moved block so existing callers are not told to destroy and recreate:
moved {
from = aws_s3_bucket.logs
to = aws_s3_bucket.this
}
Step 6: Test the module with terraform test and mock providers
terraform test (generally available since 1.6) runs .tftest.hcl files. Since 1.7, mock_provider lets tests run without cloud credentials, which is what you want in a module's CI.
# tests/inputs.tftest.hcl
mock_provider "aws" {}
variables {
name = "acme-logs"
environment = "dev"
}
run "defaults_applied" {
command = plan
variables {
lifecycle_rules = [{ id = "expire-tmp", prefix = "tmp/", expiration_days = 7 }]
}
assert {
condition = aws_s3_bucket_lifecycle_configuration.this[0].rule[0].status == "Enabled"
error_message = "optional enabled should default to true"
}
assert {
condition = aws_s3_bucket.this.bucket == "acme-logs-dev"
error_message = "unexpected bucket name"
}
}
run "rejects_bad_name" {
command = plan
variables { name = "Acme_Logs" }
expect_failures = [var.name]
}
run "prod_requires_versioning" {
command = plan
variables {
environment = "prod"
versioning = false
}
expect_failures = [var.versioning]
}
terraform init -backend=false
terraform test
I ran the same three cases against a provider-free copy of these inputs with a local Terraform binary, and the output looks like this:
tests/inputs.tftest.hcl... in progress
run "defaults_applied"... pass
run "rejects_bad_name"... pass
run "prod_requires_versioning"... pass
tests/inputs.tftest.hcl... tearing down
tests/inputs.tftest.hcl... pass
Success! 3 passed, 0 failed.
Two caveats from the mocking docs: computed attributes are unknown during plan unless you set override_during = plan, and unset computed strings are random 8-character values, so do not assert on an ARN's format from a mock. Use mock_resource defaults when you need a realistic value. Keep one real command = apply test against a sandbox account for a nightly job. For the wider toolchain (TFLint, Checkov), see How to Test Terraform.
Step 7: Generate the README with terraform-docs
Hand-written input tables go stale within a month. terraform-docs v0.24.0 generates them from your descriptions and types. Install it per the installation page, add markers to the README, and inject:
brew install terraform-docs # or: go install github.com/terraform-docs/terraform-docs@v0.24.0
cat > README.md <<'EOF'
# terraform-aws-log-bucket
Private S3 bucket for logs with versioning and lifecycle rules.
<!-- BEGIN_TF_DOCS -->
<!-- END_TF_DOCS -->
EOF
terraform-docs markdown table --output-file README.md --output-mode inject .
Inject mode (the default per the insert-output docs) rewrites only the region between the markers, so your prose survives. Run it in a pre-commit hook or CI and fail the build if the README changes, so docs and code cannot drift.
Step 8: Version with Git tags or a registry
Release with semantic version tags. The registry requires x.y.z tags, with an optional v prefix, per the publishing requirements.
git add .
git commit -m "feat: initial log bucket module"
git tag -a v1.0.0 -m "v1.0.0"
git push origin main --tags
Callers then pin either a Git ref or a registry version:
# Git source: exact tag, double slash for a subdirectory if the repo holds several modules
module "logs" {
source = "git::https://github.com/acme/terraform-aws-log-bucket.git?ref=v1.0.0"
# ...
}
# Private registry in HCP Terraform: version constraints work here, not on Git sources
module "logs" {
source = "app.terraform.io/acme/log-bucket/aws"
version = "~> 1.0"
# ...
}
Semver for modules means: removing or renaming an input or output, tightening a validation, or changing a resource address without a moved block is a major version. Adding an optional input or a new output is a minor. After changing a source or version, run terraform init -upgrade so the new code is downloaded.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Module not installed after changing source |
New source not downloaded | Run terraform init (or terraform init -upgrade for new versions) |
Unsupported argument when calling the module |
Input name typo, or input added in a newer tag than the one pinned | Check variables.tf at the pinned ref; bump ref or version |
Invalid value for variable |
A validation block failed |
Read the error_message; it names the rule and values |
| Validation referencing another variable errors on older versions | Cross-variable validation needs Terraform 1.9+ | Raise required_version to >= 1.9.0 or upgrade the CLI |
version argument ignored or rejected on a Git source |
version only applies to registry sources |
Use ?ref=vX.Y.Z in the Git URL instead |
Variables not allowed in module source |
Terraform older than 1.15, or variable lacks const = true |
Upgrade to 1.15+ and declare const = true, or use a literal |
| Plan wants to destroy and recreate after a module refactor | Resource address changed | Add a moved block from the old address to the new |
Module cannot be used with for_each or count |
Module contains its own provider block |
Remove it; configure providers in the root and pass aliases via providers |
| Test asserts on ARN format fail with mocks | Mock providers generate random strings for computed values | Set mock_resource defaults, or assert in an apply test against a sandbox |
| README tables out of date | terraform-docs not run in CI | Add terraform-docs ... --output-mode inject to pre-commit and fail on diff |
FAQ
Should a Terraform module include a provider block?
No. A reusable module should declare required_providers with a version range and let the root module configure the provider. Provider blocks inside modules block for_each and count on the module and make region and credentials impossible to change from outside.
How do I version a Terraform module stored in Git?
Push semantic version tags such as v1.2.0 and reference them with ?ref=v1.2.0 in the source URL. Git sources do not support the version argument, so you get exact pins only. If you want ~> constraints, publish the module to a public or private registry.
What is the difference between optional() and a default value?
default applies to a whole variable. optional(TYPE, DEFAULT) applies to one attribute inside an object type, so callers can pass { id = "x" } and get the other attributes filled in. A non-null optional default is also used when the caller explicitly passes null for that attribute.
Can I test a Terraform module without AWS credentials?
Yes. Since Terraform 1.7, a mock_provider "aws" {} block in a .tftest.hcl file replaces the real provider with generated fake values, so terraform test runs in plain CI. Keep at least one real apply test in a sandbox account, because mocks do not catch API-side validation errors.
What I check first on any module someone hands me is variables.tf: whether inputs have types, descriptions and validation, and whether the module configures its own provider. Those two things predict how painful it will be to reuse. In production I pin every root to a tag, run terraform test with mocks on every module PR, and keep one nightly apply in a throwaway account. The official references are the module development docs, the variable block reference, the tests docs and terraform-docs. For how these modules fit into dev, staging and prod, see the Terraform project structure post.
Related: How to Test Terraform: terraform test, TFLint and Checkov, How to Import Existing Resources into Terraform and OpenTofu and How to Build an AWS VPC With Terraform.
Spot an error or something out of date? Email me and I will fix it and log it on the corrections page.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Build an AWS VPC With Terraform: Public and Private Subnets (2026)
Build a 3-AZ AWS VPC in Terraform with terraform-aws-modules/vpc 6.7.3 and AWS provider 6.70: public and private subnets, NAT per AZ vs single vs regional, a free S3 gateway endpoint, flow logs, outputs, an SSM connectivity test and fixes for common errors.
16 minOct 20, 2026How to Import Existing Resources into Terraform and OpenTofu (2026)
Step-by-step guide to importing existing cloud resources into Terraform 1.16 or OpenTofu 1.13: import blocks with id or identity, -generate-config-out, terraform query for bulk discovery, for_each imports, cleaning generated HCL, proving a no-op plan, and fixing common import errors.
13 minNov 10, 2026How to Set Up Actions Runner Controller (ARC) on Kubernetes (2026)
Step-by-step ARC 0.15.1 setup for GitHub Actions self-hosted runners on Kubernetes: GitHub App auth, pinned Helm charts, runner scale sets, runner groups, Docker-in-Docker vs kubernetes container mode, scale to zero, metrics, and fixes for jobs stuck in queued.
13 min