Skip to content
Infrastructure as Code iac modules 5 min read

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:

ArtifactPurpose
README.mdOverview, usage snippet, input/output reference
description on every variable/outputInline self-documentation and registry rendering
examples/ directoryRunnable 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 provider blocks or hardcoded regions/credentials — declare required_providers and inherit configuration from the caller.
  • Ship a README.md and runnable examples/, and keep input/output tables current with terraform-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 validate and terraform plan) so the module’s public contract is continuously tested.
  • Keep variable types explicit and constrained (use object(...) and validation blocks) to catch misuse at plan time instead of apply time.
Last updated June 14, 2026
Was this helpful?