Hub article: Terraform Modules: Complete Guide →
Hub article covering the full picture of Terraform modules — design, distribution, and operations
Modules need to deliver reusability while drawing boundaries that hold up under change. This article focuses on stable design guidelines aligned with the official docs, organized so they double as exam prep.
In particular, minimizing inputs and outputs, handling providers correctly, pinning versions with SemVer, and choosing between count and for_each have the biggest impact on reproducibility and maintainability.
Keep each module focused on a single responsibility and keep its inputs (variables) and outputs minimal. A small API surface with few side effects makes it easier to swap implementations and to absorb future change.
As a rule, configure providers in the root module and only declare required_providers in child modules. This preserves consumers' freedom to choose authentication, region, and so on.
Make external coupling explicit. Keep things like external resource IDs and tagging strategy at the input boundary, and avoid resolving names inside the module (i.e. don't overuse data sources).
Module boundaries and provider inheritance, at a glance
Skeleton of a minimal module
# modules/network/
# main.tf
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
}
# variables.tf
variable "cidr_block" {
type = string
}
# outputs.tf
output "vpc_id" {
value = aws_vpc.this.id
}
# Usage from the root module (providers are configured in the root)
module "network" {
source = "./modules/network"
cidr_block = "10.0.0.0/16"
}Give inputs strict types and allow only meaningful defaults. Vague any types reduce reusability and increase the risk of future breaking changes.
Use validation to catch business-rule violations early, and sensitive = true to keep secrets from leaking. Receive shared things like tags as map(string) and normalize them in locals.
Because list order tends to drift, prefer designs that use map/set with stable keys for data that drives instance branching.
Concrete variables.tf examples (types and validation)
variable "name" {
type = string
validation {
condition = length(var.name) > 0 && length(var.name) <= 63
error_message = "name must be between 1 and 63 characters."
}
}
variable "tags" {
type = map(string)
description = "Shared tags"
default = {}
}
variable "ports" {
type = set(number)
validation {
condition = alltrue([for p in var.ports : p > 0 && p < 65536])
error_message = "ports must be between 1 and 65535."
}
}
variable "db_password" {
type = string
sensitive = true
description = "Database password (injected securely from outside)"
}Limit outputs to the identifiers and connection info consumers actually need; don't leak internal resource structure. Prefer stable values like ARNs and IDs so your module survives future implementation changes.
Mark sensitive outputs to protect confidential data, and use depends_on between modules only when you need explicit ordering. When references exist, the implicit dependency they create takes precedence; depends_on is a last resort.
On the exam, you need to clearly articulate the role of outputs and the meaning of depends_on on a module call.
Examples of outputs and module-level depends_on
# modules/network/outputs.tf
output "vpc_id" {
value = aws_vpc.this.id
}
output "private_subnet_ids" {
value = aws_subnet.private[*].id
}
# In the root module: when you genuinely need explicit ordering
module "network" {
source = "./modules/network"
cidr_block = "10.0.0.0/16"
}
module "app" {
source = "./modules/app"
vpc_id = module.network.vpc_id
# The vpc_id reference usually makes the implicit dependency enough
}
# The rare case where there is no reference but you still need ordering
module "audit" {
source = "./modules/audit"
depends_on = [module.network]
}Set required_version in the terraform block to force the whole team onto the same Terraform version range. In required_providers, spell out each provider's source and version constraint.
Child modules should not configure providers; they should only declare requirements via required_providers, letting the root module control behavior via inheritance or explicit aliases.
Version the module itself with VCS tags and follow SemVer: breaking changes bump the major version, backward-compatible features bump the minor, and bug fixes bump the patch.
terraform, provider, and how to pass providers to a module
# Root module
terraform {
required_version = ">= 1.3.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0" # pin to 5.x (excludes a future 6.x)
}
}
}
provider "aws" {
region = "us-east-1"
}
provider "aws" {
alias = "us_west_2"
region = "us-west-2"
}
module "compute" {
source = "./modules/compute"
providers = {
aws = aws.us_west_2
}
}
# Child module (modules/compute)
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
# As a rule, do not define a provider "aws" block here (inherit it from the root)Split directories under modules/ by capability, and put minimal usage examples in examples/. The README should clearly list inputs, outputs, dependencies, and supported version ranges.
In CI, use terraform fmt -recursive and terraform validate to enforce style and syntax consistency, and watch plan diffs. Publish modules via tags and call out breaking changes in the CHANGELOG.
Examples are the fastest path to real-world testing. Keep examples/ as a minimal config that actually runs plan/apply, and make sure readers can override the provider setup.
Recommended layout and quality checks
# Example repository layout
.
├── modules/
│ ├── network/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ └── compute/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── examples/
│ └── simple/
│ ├── main.tf
│ └── providers.tf
└── README.md
# CI basics (shell)
terraform fmt -recursive
terraform validate
terraform -chdir=examples/simple init
terraform -chdir=examples/simple planWhen you're adding/removing instances or key stability matters, prefer for_each over count to avoid index drift. Repeated nested blocks should be generated via dynamic blocks.
Over-relying on data sources for external lookups increases apply-order and environment dependencies, which hurts maintainability. As a rule, take required IDs and config via inputs and normalize them in locals.
Common anti-patterns: accepting any for everything, leaking internal implementation through outputs, and locking in providers by configuring them in child modules. They strip downstream consumers of flexibility and invite breaking changes.
| Aspect | count | for_each | dynamic block |
|---|---|---|---|
| Main use case | Simple replication of identical resources (count-based) | Keyed instances (map/set-based) | Dynamic generation of nested blocks |
| Addressing | res.name[count.index] | res.name["key"] | Only inside the parent resource's config (no address) |
| Change stability | Order changes tend to trigger recreation | Stable keys → minimal diffs | Does not affect the resource address |
| Deletion behavior | Tends to remove from the highest index down | Destroys only the matching key | Removes only the matching block |
| Input data | number | set(string)/map(T) etc. | Any collection via a for_each expression |
Address stability: for_each vs. count
# count (fragile when the order shifts)
variable "names" { type = list(string) }
resource "aws_iam_user" "u" {
count = length(var.names)
name = var.names[count.index]
}
# Reordering names can force many replacements
# for_each (robust as long as the keys are stable)
variable "users" { type = set(string) }
resource "aws_iam_user" "u2" {
for_each = var.users
name = each.value
}
# As long as each key (the user name) stays the same, the diff stays minimalPro
Question 1
You are designing a VPC module to be reused across multiple accounts and regions. Which approach best maximizes reusability and maintainability?
Correct answer: A
A matches the official recommendation. Configure providers in the root and have child modules only declare requirements via required_providers. Strict input types with validation stabilize the boundary, and VCS tags + SemVer make releases predictable. The other options undermine reusability and predictability.
Is it OK to define a provider block inside a child module?
Generally avoid it. Child modules should only declare dependencies via required_providers, while actual provider configuration (auth, region, endpoint, etc.) belongs in the root module and is inherited. When you need multiple configurations, use aliases and pass them explicitly via the providers argument on the module call.
When should you use depends_on on a module call?
Only in the special case where you need to control creation order but there is no implicit dependency via references. Normally, referring to another module's outputs as an input establishes the implicit dependency and depends_on is unnecessary.
How should I design collections to minimize breaking changes?
Prefer for_each with map/set so that the key identifying each resource instance is stable. Relying on count + list tends to trigger massive replacements when ordering shifts. Also expose only stable IDs/ARNs in outputs and avoid leaking internal structure.
Practice with certification-focused question sets
Try free questionsNicheeLab Editorial Team
NicheeLab editorial team focused on data engineering and cloud certification learning. Content is structured around practical study needs and official exam domains.
HCL Syntax: Terraform's Configuration Language (2026)
HCL2 fundamentals for Terraform — blocks, attributes, expres...
Terraform Authoring & Operations Pro: Complete Guide (2026)
Tactics for the Terraform Pro exam — module authoring, works...
Terraform Providers: Plugin Management Fundamentals (2026)
Provider mechanics — required_providers, versions, mirrors, ...
Terraform Resource Blocks: Declarative Infra Units (2026)
Resource block fundamentals — addresses, references, common ...
Terraform Data Sources: Read-Only External Data (2026)
Data source basics — declaration, refresh behavior, dependen...