Reusable IaC modules are like internal libraries: if the “API” is clean, teams move fast. If it’s messy, everyone forks it, patches it, and you end up with five “almost the same” VPCs and twelve flavors of S3 buckets.
This guide shows how to design modules that stay reusable for years—by getting three things right:
- Naming (what you call things and how consistent it is)
- Inputs/Outputs (your module’s public API)
- Versioning (how you change without breaking everyone)
I’ll use Terraform-style examples because it’s the most common module model, but the principles apply to any IaC.
The mindset: a module is a product, not a folder
The moment a second team uses your module, it becomes a product with:
- an interface (inputs/outputs)
- compatibility guarantees (versioning)
- documentation and examples
- backward-compatibility pressure
So design it like you design a service API.
1) Naming: the boring thing that decides whether your module scales
Rule 1: Name modules by capability, not by environment
Bad:
vpc-prodeks-stagings3-rajesh
Good:
network-vpccompute-eksstorage-s3-bucket
Because env is configuration, not a module identity.
Rule 2: Use a predictable module taxonomy
Pick one convention and stick to it:
Option A (simple):
vpcekss3-bucketiam-role
Option B (scales better in big orgs):
network-vpcnetwork-subnetscompute-ekssecurity-iam-rolestorage-s3-bucketobservability-log-bucket
This helps people “guess” modules without searching.
Rule 3: Standardize resource naming inside the module
Your module will create real cloud resources. Make their names:
- consistent
- unique enough
- searchable
- aligned with tagging
A common pattern:
name = "${var.project}-${var.env}-${var.component}"
Example:
cloudopsnow-prod-paymentscloudopsnow-dev-analytics
Don’t hardcode prod anywhere. Pass it.
Rule 4: Decide how the module handles naming vs tagging
Best practice for reuse:
- Module accepts a
name(orname_prefix) - Module accepts a
tagsmap - Module adds its own standard tags (like
managed_by,module,component) and merges them
That way every team can add their own tags without fighting the module.
2) Inputs & Outputs: design the module API like a real interface
If naming is structure, inputs/outputs are the contract.
Step-by-step: design the interface before writing resources
Step 1 — Write the “module contract” in plain English
Before any code, write:
- What does this module create?
- What does it NOT create?
- What must callers provide?
- What do callers need back?
Example for an S3 bucket module:
Creates: bucket, encryption, public access block, lifecycle rules (optional)
Does not create: IAM policies for apps (that’s a different concern)
Requires: name, environment, tags
Returns: bucket name, bucket ARN
This prevents “god modules.”
Step 2 — Keep inputs minimal and “opinionated by default”
Reusable modules win when they are:
- safe by default
- customizable when needed
- not 60 variables deep
The golden pattern: “80% defaults + 20% escape hatches”
Give sane defaults for common cases, but allow overrides for advanced teams.
Step 3 — Use strong types (so callers can’t accidentally break you)
Avoid type = any unless you have a good reason.
Prefer:
string,number,boollist(string),map(string)object({ ... })
Example: define a clean inputs file (variables.tf)
variable "name" {
description = "Base name for resources (e.g., cloudopsnow-prod-payments)"
type = string
validation {
condition = length(var.name) >= 3 && length(var.name) <= 63
error_message = "name must be 3-63 chars."
}
}
variable "tags" {
description = "Additional tags to apply to all resources"
type = map(string)
default = {}
}
variable "enable_versioning" {
description = "Enable object versioning"
type = bool
default = true
}
variable "lifecycle_rules" {
description = "Optional lifecycle rules"
type = list(object({
id = string
enabled = bool
prefix = optional(string)
expiration_days = optional(number)
}))
default = []
}
Notice:
- defaults are safe
- types are strict
- validation prevents garbage inputs
Step 4 — Use a small number of “grouped” inputs for advanced config
Instead of 30 booleans, do:
variable "logging" {
description = "Access logging config"
type = object({
enabled = bool
target_bucket = optional(string)
target_prefix = optional(string)
})
default = {
enabled = false
}
}
This keeps the API clean and future-proof.
Step 5 — Outputs: return only what callers truly need
A good output set is small and stable.
Example: outputs for an S3 module (outputs.tf)
output "bucket_name" {
description = "Bucket name"
value = aws_s3_bucket.this.bucket
}
output "bucket_arn" {
description = "Bucket ARN"
value = aws_s3_bucket.this.arn
}
Output anti-patterns
- Outputting every internal resource ID “just in case”
- Outputting sensitive values without marking them sensitive
- Outputting nested internal details that make refactors impossible
Rule: outputs should feel like a clean “SDK surface.”
Step 6 — Create “escape hatches” carefully
Sometimes you need advanced flexibility (custom policies, extra rules, special settings).
Good escape hatch patterns:
extra_tags(merged)extra_policy_json(validated if possible)additional_security_group_idsaddonsas a map/object
Bad escape hatches:
resource_overrides = anyraw_config = string- “paste your whole JSON here and hope it works”
Escape hatches should be structured, not unbounded.
3) Versioning: how you ship changes without breaking everyone
If you do versioning right, other teams will trust your module. If you don’t, they’ll pin forever or fork.
The simplest rule: use SemVer (MAJOR.MINOR.PATCH)
- PATCH: bugfixes, internal refactors, no interface changes
- MINOR: backward-compatible features (new optional variables, new outputs)
- MAJOR: breaking changes (rename input, change default behavior, resource recreation)
You’re basically telling your users: “What risk level is this upgrade?”
What counts as a breaking change (engineers underestimate this)
Breaking changes include:
- renaming variables or outputs
- changing variable types (string → object)
- changing defaults that alter behavior (e.g., encryption off → on can affect policies)
- changing resource naming that forces replacement
- changing tags in a way that affects policies/billing
- switching resource strategy (e.g., one NAT → per-AZ NAT) and causing recreation
If it can force replacement or surprise behavior, it’s breaking.
A practical module release workflow (that actually works)
Step 1 — Pin module versions in callers
Callers should always pin:
module "app_bucket" {
source = "git::ssh://your-repo/modules/storage-s3-bucket?ref=v1.4.2"
name = "cloudopsnow-prod-app"
tags = { team = "platform", env = "prod" }
}
No pin = surprise upgrades.
Step 2 — Make every release include 3 things
- Version tag (v1.4.2)
- Changelog (what changed, why, and upgrade notes)
- Compatibility notes (especially for MAJOR)
Example changelog entry style
- Added: optional
loggingblock (backward compatible) - Fixed: lifecycle rules not applied when empty
- Changed: (BREAKING) renamed
bucket→bucket_name
Clear, short, honest.
Step 3 — Maintain upgrade safety
When you do breaking changes:
- bump MAJOR
- provide a simple migration checklist
- avoid “silent” behavior shifts
Engineers upgrade when it’s predictable.
A complete “good module” skeleton (copy this)
modules/
storage-s3-bucket/
main.tf
variables.tf
outputs.tf
versions.tf
README.md
examples/
basic/
main.tf
with_logging/
main.tf
test/
(optional)
versions.tf (provider + terraform constraints)
terraform {
required_version = ">= 1.3.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
This prevents “works on my machine” provider chaos.
The 7 most common module mistakes (and how to avoid them)
1) The “god module”
It creates VPC + EKS + RDS + everything.
Fix: split by domain capability:
network-vpccompute-eksdata-rds
Compose modules at the root.
2) Too many knobs
50+ variables that nobody understands.
Fix: opinionated defaults + a few structured advanced blocks.
3) Weak typing
Everything is any.
Fix: typed objects, validations, and predictable schemas.
4) Outputs tied to internal structure
Callers depend on your internal resource graph.
Fix: output stable values only (ARNs, IDs, names).
5) Hidden breaking changes
Defaults change and everyone gets surprised.
Fix: SemVer discipline and changelog.
6) Resource naming not stable
Renames cause replacements.
Fix: stable naming rules; treat name changes as breaking.
7) No examples
People copy/paste from random usage.
Fix: ship examples/basic and examples/advanced always.
Real-world example: designing a reusable VPC module interface
Goal: build a VPC module that works for small and large teams without becoming complicated.
Inputs (good set)
namecidr_blockaz_count(default 2)enable_nat(default true)nat_mode(singleorper_az, defaultsingle)tags
Outputs (good set)
vpc_idprivate_subnet_idspublic_subnet_idsroute_table_ids(only if truly needed)nat_gateway_ids(only if callers must reference)
That’s reusable. That scales. That stays readable.
The final checklist: “Is my module truly reusable?”
Before you publish a module, check:
Naming
- Module name is capability-based (not env-based)
- Resource naming is stable and consistent
- Tags are merged (caller tags + module standard tags)
Inputs/Outputs
- Inputs are minimal and typed
- Defaults are safe
- Validation exists for key inputs
- Outputs are small, stable, and necessary
Versioning
- Semantic versioning used consistently
- Callers can pin versions
- Changelog exists
- Breaking changes are MAJOR + documented