Copying Terraform resources between environments feels fast until a security rule, tag, or naming convention changes. Reusable modules replace that drift with one reviewed implementation and a small, intentional interface. A good module describes an architectural capability—such as a private application network—not a thin wrapper around one AWS resource.

This guide shows how to structure, consume, validate, version, and refactor Terraform modules for AWS without creating a deeply nested system that nobody can safely change.

Root modules and child modules

The Terraform files in the directory where you run terraform plan form the root module. A root module configures providers, chooses environment-specific values, and calls child modules. Child modules define reusable infrastructure and expose inputs and outputs.

HashiCorp recommends using modules to raise the abstraction level and keeping the module tree relatively flat. The official module development guide specifically cautions against excessive modules and thin wrappers that add complexity without creating a meaningful architectural concept.

Choose a clear module boundary

A useful AWS module should own resources that normally change together. Examples include:

  • An application network containing a VPC, public and private subnets, route tables, and required endpoints.
  • A load-balanced service containing a target group, listener rules, task definition, and service autoscaling.
  • An encrypted data bucket containing lifecycle rules, public-access blocking, logging, and an access policy.

A module named aws_s3_bucket_wrapper that only renames the arguments of one resource is usually not worthwhile. Start from how application teams describe the capability, then define the minimum interface they need.

Use the standard file structure

modules/
└── application-network/
    ├── README.md
    ├── main.tf
    ├── variables.tf
    ├── outputs.tf
    ├── versions.tf
    ├── locals.tf
    ├── tests/
    │   └── basic.tftest.hcl
    └── examples/
        └── complete/
            └── main.tf

Only the root module is technically required, but predictable filenames help tools and reviewers navigate the code. HashiCorp's standard module structure also recommends a README, examples, and nested modules only when they represent optional or complex subcomponents.

Declare provider requirements, not credentials

A reusable child module declares which providers it requires in versions.tf:

terraform {
  required_version = ">= 1.9, < 2.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.0"
    }
  }
}

Configure the AWS provider in the root module, where credentials, region, default tags, and aliases belong:

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      Environment = var.environment
      ManagedBy   = "Terraform"
    }
  }
}

Provider configurations are global to the overall configuration and cannot be defined by reusable child modules. Each child module still declares its provider source and compatible versions, as described in HashiCorp's provider guidance.

Design typed, validated inputs

Prefer a small interface with strong types and validation:

variable "name" {
  description = "Stable name prefix for network resources."
  type        = string

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{2,30}$", var.name))
    error_message = "name must be 3-31 lowercase letters, digits, or hyphens."
  }
}

variable "vpc_cidr" {
  description = "IPv4 CIDR block for the VPC."
  type        = string

  validation {
    condition     = can(cidrnetmask(var.vpc_cidr))
    error_message = "vpc_cidr must be a valid IPv4 CIDR block."
  }
}

variable "availability_zones" {
  type = list(string)

  validation {
    condition     = length(var.availability_zones) >= 2
    error_message = "Provide at least two availability zones."
  }
}

Do not expose every underlying AWS argument. Provide safe defaults for optional behavior and expose only decisions that consumers genuinely need to make.

Build resources from inputs and locals

locals {
  common_tags = {
    Component = "application-network"
    Name      = var.name
  }
}

resource "aws_vpc" "this" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true
  tags                 = local.common_tags
}

resource "aws_subnet" "private" {
  for_each = toset(var.availability_zones)

  vpc_id            = aws_vpc.this.id
  availability_zone = each.value
  cidr_block        = cidrsubnet(var.vpc_cidr, 4, index(var.availability_zones, each.value))

  tags = merge(local.common_tags, { Tier = "private" })
}

Stable for_each keys are safer than numeric indexes when collections may be reordered. Changing a list position used by count can cause Terraform to reinterpret resource addresses and propose unnecessary replacement.

Expose outputs as a small API

output "vpc_id" {
  description = "ID of the application VPC."
  value       = aws_vpc.this.id
}

output "private_subnet_ids" {
  description = "Private subnet IDs keyed by availability zone."
  value = {
    for zone, subnet in aws_subnet.private : zone => subnet.id
  }
}

Outputs should help other modules compose infrastructure without exposing entire resource objects. Map outputs with stable semantic keys make dependencies easier to read and reduce coupling to internal implementation details.

Call the module from an environment

module "network" {
  source = "git::https://github.com/example/terraform-aws-network.git?ref=v2.3.1"

  name               = "payments-prod"
  vpc_cidr           = "10.40.0.0/16"
  availability_zones = ["us-east-1a", "us-east-1b"]
}

module "service" {
  source = "git::https://github.com/example/terraform-aws-service.git?ref=v1.8.0"

  vpc_id     = module.network.vpc_id
  subnet_ids = values(module.network.private_subnet_ids)
}

Pin remote modules to an immutable release tag or registry version. Tracking a moving main branch can change production plans without a deliberate version update.

Test modules before consumers discover mistakes

Run formatting and static validation on every change:

terraform fmt -check -recursive
terraform init -backend=false
terraform validate
terraform test

Add policy or security scanning that matches your organization, and execute a real plan against a disposable account when a module's behavior cannot be proven statically. Examples should be small but complete enough for a user to run.

Refactor without destroying resources

Renaming a resource changes its Terraform address. Record that change with a moved block:

moved {
  from = aws_vpc.main
  to   = aws_vpc.this
}

Terraform then understands that the existing object has a new address instead of planning a delete and recreate. Removing old moved blocks can be a breaking change for consumers skipping versions, so document the supported upgrade path.

Module review checklist

  • The module represents an architectural capability, not a renamed resource.
  • Inputs are typed, documented, validated, and minimal.
  • Providers and credentials are configured by the root module.
  • Outputs form a stable interface for composition.
  • Remote consumers pin an immutable version.
  • Formatting, validation, tests, and example plans run in CI.
  • Breaking changes use semantic versions and migration notes.
  • Resource renames include moved blocks.

Reusable Terraform modules are most effective when their interfaces are smaller than their implementations. Keep composition flat, centralize secure defaults, version deliberately, and make safe upgrades part of the module design rather than an afterthought.