Skip to content
>_higan
This post has a translation:Read in Tiếng Việt

Terraform #06 — Modules: Design, Versioning & Composition

Build reusable modules the right way: clean input/output interfaces, versioning, registry usage, and composition patterns.

Goal: Design reusable modules the way the industry actually does it — clear input/output interfaces, versioning, composition. Modules are where you can really tell apart someone who's thought carefully about their Terraform from someone who hasn't.


6.1. What is a module?

A module is a directory containing .tf files. Every Terraform directory is a module.

  • Root module: the directory where you run terraform apply.
  • Child module: a module called by another module (via a module block).
        ┌─────────────────────────────────────┐
        │          ROOT MODULE                 │
        │   (where you run terraform apply)    │
        │                                      │
        │   module "network" { ... } ──────────┼──► modules/network/
        │   module "database" { ... } ─────────┼──► modules/database/
        │   module "app" { ... } ──────────────┼──► registry / git
        └─────────────────────────────────────┘

Why use modules?

BenefitWhat it means in practice
ReuseWrite once, use for dev/staging/prod and across teams
EncapsulationHide complex internals behind a simple interface
StandardizationEvery VPC or DB is created the same way
TestabilityTest a module in isolation
OwnershipEach team owns its modules

6.2. A well-structured module

modules/vpc/
├── README.md          # docs: description, usage example (terraform-docs can generate this)
├── main.tf            # primary resources
├── variables.tf       # INPUT — the interface
├── outputs.tf         # OUTPUT — the interface
├── versions.tf        # required_version + required_providers
└── examples/          # usage examples (for testing and docs)
    └── basic/
        └── main.tf

A module's interface = its variables (input) + outputs (output). Module users only need to understand the interface — they shouldn't need to read main.tf. Design the interface carefully.


6.3. Calling a module

module "vpc" {
  source = "./modules/vpc"
 
  # Pass input variables
  vpc_cidr    = "10.0.0.0/16"
  az_count    = 3
  environment = "prod"
}
 
# Use the module's output
resource "aws_instance" "web" {
  subnet_id = module.vpc.public_subnet_ids[0]
}
 
output "vpc_id" {
  value = module.vpc.vpc_id   # access child output: module.<name>.<output>
}

Module meta-arguments

Modules also accept count, for_each, depends_on, and providers:

# Multiple instances of a module
module "team_vpc" {
  for_each    = toset(["alpha", "beta"])
  source      = "./modules/vpc"
  environment = each.key
}
 
# Pass a provider alias into a module
module "us_resources" {
  source    = "./modules/app"
  providers = { aws = aws.us_east }
}

6.4. Module sources

# 1. Local path (most common when starting out)
source = "./modules/vpc"
source = "../shared/vpc"
 
# 2. Terraform Registry (public or private)
source  = "terraform-aws-modules/vpc/aws"
version = "5.8.1"
 
# 3. Git
source = "git::https://github.com/org/repo.git//modules/vpc?ref=v1.2.0"
source = "git::git@github.com:org/repo.git//modules/vpc?ref=v1.2.0"
 
# 4. GitHub shorthand
source = "github.com/org/repo//modules/vpc?ref=v1.2.0"
 
# 5. S3 / GCS / HTTP archive
source = "s3::https://bucket.s3.amazonaws.com/vpc.zip"

⚠️ Always pin the version with ?ref= (git) or version = (registry). Unpinned modules can change under you and break production. Use tags/semver, never main or master.


6.5. Module versioning (Semantic Versioning)

Modules should follow SemVer: MAJOR.MINOR.PATCH

Change typeBumpExample
Breaking (remove/rename input, change behavior)MAJOR1.x → 2.0.0
New feature, backwards compatibleMINOR1.1 → 1.2.0
Bug fix, no interface changePATCH1.1.0 → 1.1.1

Pinning in code:

version = "~> 5.8"     # >= 5.8.0, < 6.0.0 (safe, gets minor/patch updates)
version = "= 5.8.1"    # locked to exact version
version = ">= 5.8, < 5.10"

6.6. Module design principles

Do:

  1. One module, one clear purpose (VPC, DB, ECS service...). No "swiss army knife" modules that do everything.
  2. Reasonable defaults on inputs — easy to use out of the box, but still overridable.
  3. Every variable has a description and type. Add validation when it makes sense.
  4. Export everything the caller might need (id, arn, endpoint...).
  5. Include examples/ to illustrate usage and enable testing.
  6. Write a README (use terraform-docs to auto-generate the inputs/outputs table).
  7. A common tags variable that gets merged onto every resource.

Don't:

  1. Configure providers or backends inside child modules — leave that to the root module.
  2. Deeply nest modules (module → module → module → module). Hard to debug.
  3. Create a variable for everything (50+ variables → nobody wants to call your module).
  4. Hidden side effects (a module that silently creates things unrelated to its stated purpose).
  5. Undocumented implicit ordering between modules.

Module layering (as it works in practice)

Resource modules (low level)  →  wrap one group of resources (e.g. one VPC + subnets)
        ▲
Infrastructure modules        →  compose multiple resource modules (e.g. full network stack)
        ▲
Root module (per environment) →  compose infra modules + environment-specific config

6.7. Provider configuration in modules

Key rule: Child modules should not have a provider block. They only declare required_providers in versions.tf. Providers are configured in the root module and passed down (implicitly or via providers = {}).

# modules/vpc/versions.tf — CORRECT
terraform {
  required_providers {
    aws = {
      source                = "hashicorp/aws"
      version               = ">= 5.0"
      configuration_aliases = [aws.secondary]  # if the module needs an alias
    }
  }
}
# NO "provider" block here!

Why? If a module configures its own provider, you can't use for_each/count with it, and it becomes very hard to reuse.


6.8. Public registry — standing on the shoulders of giants

registry.terraform.io has thousands of high-quality modules. The terraform-aws-modules collection is the most well-known:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.8"
 
  name = "my-vpc"
  cidr = "10.0.0.0/16"
  azs             = ["us-east-1a", "us-east-1b", "us-east-1c"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
  enable_nat_gateway = true
}

In practice: use community modules for common things (VPC, EKS, RDS), write your own for business-specific logic. Don't reinvent the wheel — but do read the source of any third-party module before you trust it.


6.9. Hands-on Labs

In hands-on/ you'll find a complete module (modules/app-config) and a root module that calls it. Follow LAB.md:

  1. Lab 1 — Read through the module structure + call it from the root module.
  2. Lab 2 — Call the module multiple times using for_each (multi-env in one apply).
  3. Lab 3 — Change the module's interface and see the effect.
  4. Lab 4 — (Optional) Use a module from the public registry.

✅ Module 06 completion criteria

  • Can explain the difference between root and child modules.
  • Can design a module with clear variable/output interfaces.
  • Understand why child modules shouldn't have a provider block.
  • Know how to pin module versions correctly (git ref / registry version).
  • Can call a module using for_each.
  • Know when to use a community module vs writing your own.

➡️ Next: Module 07 — Multi-Environment

Share: