Modules
Modules package resources behind inputs and outputs so teams stop copy-pasting. Design a good module interface, instantiate it many times with for_each, pin versions, and understand why a module is a namespace, not a security boundary.
🎯 What you'll learn
- Root vs child modules and module sources
- Designing a module: inputs, outputs, safe defaults
- for_each/count over modules and resource addresses
- Versioning, releasing and upgrading modules
📚 Before you start
1What a Module Is
Every directory of .tf files is a module. The directory you run Terraform in is the root module; it can call child modules to reuse groups of resources:
module "vpc" {
source = "./modules/vpc" # local path
name = local.name_prefix
cidr = var.vpc_cidr
azs = local.azs
}
module "eks" {
source = "terraform-aws-modules/eks/aws" # public registry
version = "~> 20.0" # ALWAYS pin registry modules
cluster_name = "${local.name_prefix}-eks"
vpc_id = module.vpc.vpc_id # use the child module's outputs
subnet_ids = module.vpc.private_subnet_ids
}A module's interface is its variables (inputs) and outputs; its resources are an implementation detail.
2Module Sources
| Source | Example | Notes |
|---|---|---|
| local path | ./modules/vpc | same repo, versioned with the caller |
| Terraform Registry | terraform-aws-modules/vpc/aws + version | semantic versions |
| Git | git::https://github.com/acme/tf-modules.git//vpc?ref=v1.4.0 | pin a tag or commit |
| private registry | app.terraform.io/acme/vpc/aws | HCP Terraform / Artifactory |
| S3 / GCS / HTTP archives | s3::https://…/vpc.zip | less common |
Run terraform init (or init -upgrade) after changing sources/versions.
3Designing a Good Module
modules/service/
main.tf # resources
variables.tf # inputs with types, descriptions, validation, sane defaults
outputs.tf # what callers need (ids, arns, urls)
versions.tf # required_providers (no provider block inside reusable modules!)
README.md # usage example (terraform-docs can generate inputs/outputs tables)
examples/basic/ # a runnable example, also used by testsGuidelines:
- One clear responsibility at a useful level of abstraction ("a service with its ALB target group, ECS service, log group and alarms"), not a thin wrapper around a single resource.
- Minimal, typed inputs with secure defaults (encryption on, public access off); escape hatches (
extra_tags,additional_policies) instead of exposing every argument. - Don't configure providers in reusable modules — the caller passes them (use
configuration_aliaseswhen a module needs multiple regions/accounts). - Outputs for everything a caller might wire up.
- Composition over nesting: root modules compose several flat modules; deep module trees are hard to debug.
A 'wrapper of everything' module
module "bucket" {
source = "./modules/s3"
acl = var.acl
versioning = var.versioning
public_access_block = var.pab # 40 pass-through vars…
}It mirrors the resource 1:1, adds nothing, and lets callers make it public.
An opinionated module
module "uploads" {
source = "git::https://github.com/petpal/tf-modules//secure-bucket?ref=v2.3.0"
name = "petpal-prod-uploads"
retention_days = 365
}
# encryption, versioning, public-access block and TLS-only policy
# are always on; no input can turn them off4Repeating Modules
module "service" {
for_each = var.services # one module instance per service
source = "./modules/service"
name = each.key
image = each.value.image
cluster = aws_ecs_cluster.main.id
vpc = module.vpc
}
# addresses: module.service["api"].aws_ecs_service.this5Versioning and Releasing Modules
- Shared modules live in their own repo (or a monorepo folder) with semantic version tags; consumers pin versions and upgrade deliberately.
- Breaking changes (renamed inputs, resources that would be replaced) → major version; include
movedblocks in the module so callers upgrading don't destroy resources. - Test modules before release (
09-testing_and_policy_as_code/).
Under the HoodModules Are Namespaces, Not Boundaries▾
When Terraform loads a configuration, it flattens all modules into one graph: module.vpc.aws_subnet.private[0] is just a resource with a longer address. Module inputs are evaluated in the caller's scope and become variables inside the module; outputs become references the caller can use. Dependencies cross module boundaries at the level of individual values, so a resource in module B can start as soon as the specific output of module A it uses is known — not after all of module A. A module adds no runtime isolation: all its resources share the root module's state file, providers and apply. Real isolation (blast radius, separate locks) comes only from separate root modules / states (03-state_and_remote_backends/ Part 2).
⚠️ Common Pitfalls
- Unpinned registry/Git modules — surprise changes on
init -upgrade. - Provider blocks inside reusable modules.
- God modules with 80 inputs mirroring every resource argument.
- Wrapper modules around one resource with no added value.
- Breaking changes without
movedblocks — callers' resources get replaced. - Deeply nested modules — hard to read plans and debug.
📋 Quick Reference
module "x" { source = "…", version = "~> 1.2", inputs… } → module.x.output_name
sources: local · registry (+version) · git::…?ref=tag · private registry
module layout: main.tf, variables.tf, outputs.tf, versions.tf, README, examples/
no provider blocks in reusable modules · secure defaults · for_each over modules
semver releases + moved blocks for refactors🧠 Key takeaways
- A module is a directory of .tf files with inputs and outputs; every config has a root module.
- Good modules are opinionated (secure defaults that can't be disabled) with a small interface.
- Use for_each with stable keys to repeat modules; addresses include the key.
- Pin versions (registry versions or git tags), release with changelogs, and remember modules are namespaces, not security boundaries.
🎤 Interview Q&A
Q1What makes a good Terraform module?core▾
A focused purpose, a clear interface (typed variables with descriptions and validation, useful outputs), sensible secure defaults, no hard-coded providers/credentials, versioned releases, examples and tests.
- Too small modules? Thin wrappers over one resource add indirection without value.
Q2How do you version and source modules?core▾
Publish to a registry (public or private) or Git tags, and pin versions in source/version. Upgrade via PRs with plan review.
- Why pin? Unpinned modules can change behaviour unexpectedly.
Q3Root modules vs child modules?senior▾
The root module is the directory you run Terraform in (with backend and providers); child modules are reusable components called from it.
- Should child modules configure providers? No; pass providers from the root.
Q4How do you compose modules?core▾
Pass outputs of one module as inputs to another (VPC module outputs subnet IDs to an EKS module), keeping dependencies explicit and avoiding modules reaching into each other's internals.
- Nested modules depth? Keep shallow for readability.
Q5How do you test modules?core▾
terraform validate, tflint, terraform test with plan/apply assertions, Terratest for integration tests in sandbox accounts, and policy checks (Checkov, tfsec).
- What does terraform test do? Runs .tftest.hcl files with assertions against plans or real applies.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.