Module Design
A well-designed Terraform module behaves like a small, well-documented API: it exposes a focused set of inputs, produces predictable outputs, and hides the messy implementation details behind a clean surface. Poorly designed modules become a liability — they accumulate flags, leak provider assumptions, and force every consumer to understand internals before they can use them. This page covers the design principles that keep modules reusable across teams, environments, and even cloud accounts, and that hold up whether you run Terraform 1.5+ or OpenTofu.
Single responsibility
A module should do one thing well. Resist the urge to build a “platform” module that provisions a VPC, an EKS cluster, an RDS instance, and an S3 bucket all at once. Such mega-modules are hard to test, slow to plan, and impossible to reuse partially. Instead, build small composable units — a vpc module, a cluster module, a database module — and wire them together in a root configuration.
A focused module is easier to reason about: its inputs map clearly to one resource group, and its blast radius during apply is contained.
# modules/s3-bucket/main.tf — one concern: a managed, encrypted bucket
resource "aws_s3_bucket" "this" {
bucket = var.name
tags = var.tags
}
resource "aws_s3_bucket_versioning" "this" {
bucket = aws_s3_bucket.this.id
versioning_configuration {
status = var.versioning_enabled ? "Enabled" : "Suspended"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
bucket = aws_s3_bucket.this.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = var.kms_key_arn
}
}
}
Sensible defaults and minimal required inputs
Every required variable is a tax on the caller. Make the common case effortless by providing safe defaults, and reserve required inputs for values the module genuinely cannot guess — names, identifiers, and references to other resources.
# modules/s3-bucket/variables.tf
variable "name" {
description = "Globally unique bucket name."
type = string
}
variable "versioning_enabled" {
description = "Whether object versioning is enabled."
type = bool
default = true
}
variable "kms_key_arn" {
description = "KMS key ARN for SSE. Defaults to the AWS-managed S3 key."
type = string
default = "aws/s3"
}
variable "tags" {
description = "Tags applied to all resources."
type = map(string)
default = {}
}
With these defaults, the smallest valid call is a single line:
module "logs" {
source = "./modules/s3-bucket"
name = "acme-app-logs-prod"
}
Choose secure defaults, not merely convenient ones. Encryption on, public access blocked, and versioning enabled should be the path of least resistance — make insecurity the value the caller has to opt into explicitly.
No hardcoded provider configuration
A reusable module must never declare a configured provider block of its own. Hardcoding a region, profile, or credentials inside a module locks every consumer into your choices and prevents the module from being used across accounts or regions. Modules should only declare which providers they require, and inherit the configured provider from the calling module.
# modules/s3-bucket/versions.tf — declare requirements, not configuration
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
When a module needs to support multiple instances of a provider (for example, a primary and a replica region), expose this through configuration_aliases and let the root pass providers in explicitly:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
configuration_aliases = [aws.primary, aws.replica]
}
}
}
# root: caller supplies the configured providers
module "replicated_bucket" {
source = "./modules/s3-bucket"
name = "acme-shared-assets"
providers = {
aws.primary = aws.us_east_1
aws.replica = aws.eu_west_1
}
}
Documentation and examples
A module is only reusable if people can adopt it without reading every .tf file. Treat documentation as part of the deliverable:
| Artifact | Purpose |
|---|---|
README.md | Overview, usage snippet, input/output reference |
description on every variable/output | Inline self-documentation and registry rendering |
examples/ directory | Runnable root configurations showing real usage |
Generated docs (terraform-docs) | Keep the README input/output tables in sync |
Tools like terraform-docs automate the tedious parts:
terraform-docs markdown table --output-file README.md --output-mode inject .
Output:
README.md updated successfully
Keep at least one example per major use case under examples/. These double as integration tests — your CI can run terraform validate and terraform plan against them on every commit.
Versioned releases
Pin and publish modules with semantic version tags so that consumers upgrade deliberately rather than being broken by an unexpected main-branch change. Tag releases as vMAJOR.MINOR.PATCH and document breaking changes in a changelog.
module "logs" {
source = "app.terraform.io/acme/s3-bucket/aws"
version = "~> 2.1" # any 2.x >= 2.1, but never 3.0
name = "acme-app-logs-prod"
}
For Git-sourced modules, pin to an immutable tag rather than a branch:
module "logs" {
source = "git::https://github.com/acme/tf-modules.git//s3-bucket?ref=v2.1.3"
name = "acme-app-logs-prod"
}
A clean terraform init then resolves the exact version:
Output:
Initializing modules...
Downloading registry.terraform.io/acme/s3-bucket/aws 2.1.3 for logs...
- logs in .terraform/modules/logs
Terraform has been successfully initialized!
Reserve major version bumps for changes that alter the input/output contract or destroy resources. Treat the variables and outputs of a published module as a public API — renaming a variable is a breaking change even if the resources are identical.
Best Practices
- Give each module a single, well-named responsibility and compose larger systems from small modules rather than building monoliths.
- Minimize required inputs; provide secure, sensible defaults so the common usage is a few lines, and document every variable and output.
- Never embed configured
providerblocks or hardcoded regions/credentials — declarerequired_providersand inherit configuration from the caller. - Ship a
README.mdand runnableexamples/, and keep input/output tables current withterraform-docs. - Release modules under immutable semantic version tags and pin consumers with
version = "~> x.y"or?ref=vX.Y.Z. - Validate examples in CI (
terraform validateandterraform plan) so the module’s public contract is continuously tested. - Keep variable types explicit and constrained (use
object(...)andvalidationblocks) to catch misuse at plan time instead of apply time.