cloud IaC terraform modules claude-curated

A Terraform module is a directory of .tf files that can be called from another configuration. Modules are how Terraform reuses infrastructure shapes — a VPC pattern, an Lambda + API Gateway combo, a standardised database setup. Composition is how those modules fit together; Terraform deliberately has no inheritance, so the only structural tool is wiring outputs into inputs.

Module types

TypeDescription
Root moduleThe directory you run terraform apply in. Has its own backend, owns state. Never called from elsewhere.
Shared / reusable moduleA directory of resources designed to be called from multiple root modules. Lives in your repo or a separate one.
Registry moduleA versioned, published module — public Terraform Registry, a private TFC registry, or an org-internal one.

The line between “shared module” and “registry module” is publishing: a registry module has a version number and a stable URL.

Shared module pattern

A common layout for organisations with many environments:

infra/
  modules/
    network/
    database/
    api-service/
  envs/
    dev/
      main.tf
    staging/
      main.tf
    prod/
      main.tf

Each env’s root module calls the shared modules with environment-specific variables:

module "network" {
  source = "../../modules/network"
 
  cidr_block = var.cidr_block
  env        = "prod"
}

The shared module owns the resource definitions; the root module owns the wiring (which env, which variable values, which outputs to surface). State lives at the root level — one state file per env. Terragrunt formalises this layout further; see Terragrunt - Organiztion.

When to extract a module: rule of three

A heuristic: don’t extract a module the first time you write a pattern, or even the second time. Extract it the third time. The first two uses won’t reveal which inputs need to be variable; trying to design the abstraction up front produces over-parameterised modules that are harder to use than the duplication they replace.

Inverse signs that it’s already a module:

  • The same five resources appear together with similar wiring.
  • A change to the pattern needs to be applied in three places at once.
  • The variation between uses is captured by a small number of inputs (env name, instance size).

Inverse signs it shouldn’t be a module yet:

  • Each “copy” is actually different in non-trivial ways.
  • The variation is so large the module has 30+ inputs and feels like a configuration language for itself.

Module sources

A module’s source argument decides where Terraform fetches it from:

  • Local pathsource = "./modules/network". Fast, no network. Best for modules in the same repo as the caller.
  • Gitsource = "git::https://github.com/org/repo.git//modules/network?ref=v1.2.0". Pin to a tag. Use for cross-repo reuse without a registry.
  • Registrysource = "org/network/aws" and version = "~> 1.2". Cleanest. Requires publishing.
  • S3 / GCSsource = "s3::https://s3.amazonaws.com/bucket/network.zip". Useful for air-gapped or compliance scenarios.

terraform init downloads modules into .terraform/modules/; subsequent runs use the cache.

Versioning

Modules consumed via Git, registry, or S3 should be pinned. The two common styles:

  • Tag pin?ref=v1.2.0 for Git. Bump deliberately by changing the ref.
  • Version constraintversion = "~> 1.2" for registry. Allows patch updates automatically.

Pinning matters because module changes propagate on the next init. An unpinned module reference means a teammate’s init could pull a breaking change you weren’t expecting — a frequent cause of Terraform State Drift.

For shared modules in a monorepo, local paths skip versioning entirely — the version is implicitly “whatever’s on this branch”.

Inputs and outputs

Modules expose their interface through variable (input) and output blocks:

# modules/network/variables.tf
variable "cidr_block" {
  type        = string
  description = "CIDR for the VPC."
}
 
# modules/network/outputs.tf
output "vpc_id" {
  value = aws_vpc.this.id
}

The caller passes inputs as arguments and reads outputs through module.<name>.<output>:

module "network" {
  source     = "./modules/network"
  cidr_block = "10.0.0.0/16"
}
 
module "database" {
  source = "./modules/database"
  vpc_id = module.network.vpc_id
}

The wiring vpc_id = module.network.vpc_id is composition: the database module doesn’t know about the network module, only about a VPC ID it needs.

Composition vs inheritance

Object-oriented languages often use inheritance for reuse — a child class inherits behaviour from a parent and overrides parts of it. Terraform deliberately omits this. There is no way to write a “base module” that another module extends, no way to override one resource of an imported module.

The substitute is composition:

  • Modules expose narrow, well-typed inputs and outputs.
  • A root module calls multiple shared modules and wires their outputs into each other’s inputs.
  • Variation across environments is variable values, not module overrides.

This forces a discipline that often produces cleaner designs: each module has a single responsibility, and the relationships between modules are explicit in the root module’s wiring rather than hidden in inheritance chains.

When you genuinely need “module A but with one resource swapped”, the right answers are:

  1. Add an input that toggles the resource (enable_x = false).
  2. Split the module so the differing resource lives outside it.
  3. Fork the module if the variation is large enough.

Forcing inheritance via clever Terraform tricks (dynamic blocks driven by huge variable maps) usually ends in regret. See Terraform Import Block for bringing existing resources into a composed module, Terraform Cloud Workspaces for how modules map to remote state, and CI CD for Terraform for pipeline shape.

See also

References