Reference · Terraform

Module Design

Modules as functions: the standard structure, the API contract, and refactoring without destroying anything.

The mental model

A module is a directory of .tf files — nothing more. You are always inside one: the directory you run Terraform in is the root module. Calling a module is like calling a function: variables are the parameters, outputs are the return values, and the resources inside are implementation details. The call site gives it a name, and that name becomes a state address namespace: module.vpc.aws_subnet.private[0].

Standard module structure

my-module/
├── main.tf         # resources — the implementation
├── variables.tf    # the API in (every variable: description)
├── outputs.tf      # the API out (every output: description)
├── README.md       # purpose + usage; marks a module as "public" to humans
├── LICENSE
├── examples/       # runnable usage examples
└── modules/        # nested modules (with README = external-use; without = internal)
    └── …

Source: Standard Module Structure

Calling a module

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"  # registry source
  version = "~> 5.8"                          # ALWAYS pin — registry sources only
  cidr    = var.vpc_cidr                       # variables = arguments
}

# git source pins with ?ref= (tag/commit) instead of version:
# source = "git::https://github.com/acme/tf-vpc.git?ref=v2.3.0"

resource "aws_instance" "app" {
  subnet_id = module.vpc.private_subnet_ids[0]  # outputs = return values
}

The provider rule

Reusable modules declare what they need (required_providers) but never contain provider blocks. Provider configuration belongs to the root module; it flows into modules automatically (or explicitly via providers = {…} for multi-region). A legacy provider block inside a module blocks count/for_each on the module call and orphans resources when the module is removed.

Refactoring: the moved block

State maps addresses → real objects. Renaming a resource or pulling it into a module changes its address — and Terraform reads that as "destroy the old, create the new."

moved {
  from = aws_s3_bucket.logs
  to   = module.storage.aws_s3_bucket.logs
}

Source: Refactoring (moved blocks)

When NOT to write a module