Modules: Composition, Not Copy-Paste

This chapter covers writing and consuming Terraform modules, the unit of reuse for infrastructure.

Why Modules

After a few projects, you'll notice patterns. Every service needs: a VPC, a subnet per AZ, a security group, an RDS instance, an ALB. Copy-pasting a 500-line config between projects is a bad time. A module is what you paste once and call many times.

A module is a directory with .tf files. Inside, you define variables (inputs), resources, and outputs (exposed values). The caller passes variables and consumes outputs.

The Structure of a Module

Conventional layout:

modules/
  vpc/
    main.tf          # resources
    variables.tf     # inputs
    outputs.tf       # exposed values
    versions.tf      # required_providers
    README.md        # docs

A trivial module:

modules/notes-bucket/variables.tf:

variable "environment" {
  type        = string
  description = "Environment name (dev/staging/prod)."
}

variable "retention_days" {
  type        = number
  default     = 30
  description = "Number of days before objects expire."
}

modules/notes-bucket/main.tf:

resource "random_id" "suffix" {
  byte_length = 4
}

resource "aws_s3_bucket" "this" {
  bucket = "notes-${var.environment}-${random_id.suffix.hex}"

  tags = {
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_s3_bucket_lifecycle_configuration" "this" {
  bucket = aws_s3_bucket.this.id

  rule {
    id     = "retention"
    status = "Enabled"

    expiration {
      days = var.retention_days
    }
  }
}

modules/notes-bucket/outputs.tf:

output "bucket_name" {
  value = aws_s3_bucket.this.bucket
}

output "bucket_arn" {
  value = aws_s3_bucket.this.arn
}

Consuming a Module

Call it with a module block:

module "dev_notes" {
  source      = "./modules/notes-bucket"
  environment = "dev"
}

module "prod_notes" {
  source         = "./modules/notes-bucket"
  environment    = "prod"
  retention_days = 365
}

output "dev_bucket_name" {
  value = module.dev_notes.bucket_name
}
  • source tells Terraform where to find the module code.
  • Other arguments are the module's input variables.
  • Reference outputs as module.<name>.<output>.

Every module call creates independent resources. dev_notes and prod_notes are two distinct S3 buckets.

Module Sources

source can point to a few places.

Local Path

source = "./modules/notes-bucket"
source = "../shared/modules/vpc"

Use for modules inside the same repo. Simplest. No versioning; the code in the path is the code you get.

Terraform Registry

source  = "terraform-aws-modules/vpc/aws"
version = "5.5.0"

Public registry at registry.terraform.io. terraform-aws-modules/vpc/aws is namespace/name/provider. Pin the version.

Some of the most useful community modules:

terraform-aws-modules/vpc/aws              VPC, subnets, NAT, route tables
terraform-aws-modules/rds/aws              RDS instance with sane defaults
terraform-aws-modules/eks/aws              EKS cluster with node groups
terraform-aws-modules/security-group/aws   Security group with common patterns

Read the docs. Community modules can have hundreds of inputs.

Git

source = "git::https://github.com/your-org/terraform-modules.git//modules/vpc?ref=v1.2.0"

Private or custom modules. ref pins to a tag, branch, or commit.

Terraform Private Registry

source  = "app.terraform.io/your-org/vpc/aws"
version = "~> 1.0"

For HCP Terraform / Terraform Enterprise. Same semantics as public registry, private access.

S3, HTTP, Archive

Terraform supports S3, HTTP, and tarball sources. Rare in practice.

Versioning

Always pin the version for non-local sources:

version = "5.5.0"           # exact
version = "~> 5.5"          # 5.5.x, not 5.6+
version = ">= 5.0, < 6.0"   # range

Without a pin, you get "the latest version", which can change under you. That's a debugging nightmare when a module author ships a breaking change.

Treat module versions like dependency versions in any language: pin, upgrade deliberately, review the changelog.

Writing Good Modules

A module should have a clear purpose, sensible defaults, and no surprises.

One Job

"A VPC module" makes sense. "An entire application platform" module doesn't. Smaller modules compose better.

Sensible Defaults

Every required variable is a friction point. Default what you reasonably can, require only what the caller must know.

variable "enable_flow_logs" {
  type        = bool
  default     = false
  description = "Enable VPC flow logs."
}

Good: sensible off-by-default. Opt in to complexity.

Limited Outputs

Only expose outputs consumers need. Every output is part of the module's API; changing it later is a breaking change.

Document the Inputs

description on every variable. README with examples. Terraform can auto-generate docs (terraform-docs); use it.

Don't Wrap for No Reason

A module that wraps a single resource and exposes every argument is worse than using the resource directly. Modules should add value: composition, defaults, sensible names, encapsulation.

Module Composition

Modules can call other modules. A "platform" module might compose a VPC module, a database module, and a Kubernetes module:

# modules/platform/main.tf
module "vpc" {
  source = "terraform-aws-modules/vpc/aws"
  # ...
}

module "db" {
  source = "./modules/db"
  vpc_id = module.vpc.vpc_id
}

module "k8s" {
  source     = "./modules/eks"
  vpc_id     = module.vpc.vpc_id
  subnet_ids = module.vpc.private_subnets
}

Composition is where modules shine. Build up from small pieces.

for_each on Modules

You can use for_each on module calls, same as resources:

module "env_bucket" {
  for_each = toset(["dev", "staging", "prod"])

  source      = "./modules/notes-bucket"
  environment = each.key
}

Creates three instances of the module. Reference outputs with module.env_bucket["dev"].bucket_name.

Module Testing

Two approaches, covered in Chapter 10:

  • terraform test (built into Terraform 1.6+): write test cases in HCL that validate outputs and apply behavior.
  • Terratest (Go library): real integration tests that deploy, assert, and destroy.

For reusable modules, test them. Other engineers will trust (or blame) you based on whether your module works as advertised.

The Public Registry

When reaching for a module:

  1. Check the Terraform registry. If a community module exists and is maintained, use it.
  2. Prefer terraform-aws-modules (for AWS) over one-off repos. Maintained, widely used, opinionated in consistent ways.
  3. Read the README. Check the examples directory. Understand what gets created.
  4. Pin a version.

Don't build your own VPC module for one service. The community one is better.

Anti-Patterns

Tiny modules that wrap one resource. module.s3_bucket for a single bucket doesn't add value. Use the resource.

Kitchen-sink modules. "The module that does everything" has fifty inputs and does none of them well. Break it up.

Versioning too aggressively. If you publish module v2.0 weekly, consumers can't keep up. Version on meaningful changes.

Unversioned sources. source = "git::...?ref=main" means "latest main", which can change under you. Pin to a tag or commit.

Shared provider config inside modules. Modules should receive providers from the caller (implicitly or via providers argument). Declaring providers in modules makes them awkward to compose.

Common Pitfalls

Missing version on registry source. Latest-by-default breaks when the module author ships breaking changes.

Module path mistakes. source = "./modules/vpc" is relative to the calling config, not the module's own location.

Expecting module.x.resource.y. You can't reference a module's internal resources. Only its outputs. Plan accordingly.

Modules with implicit dependencies on provider config. A module that assumes region = "us-east-1" breaks in another region. Pass what it needs.

Next Steps

Continue to 07-remote-backends.md to share state safely across a team.