.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].
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) └── …
./modules/cluster).Source: Standard Module Structure
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 }
count/for_each/depends_on (TF ≥0.13).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.
moved blockState 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 }
count/for_each to existing resources.terraform state mv for shared codebases.Source: Refactoring (moved blocks)