Terraform Modules & Reusability
Modules are the primary unit of reusability in Terraform. Every configuration is a module — the root module calls child modules, which encapsulate groups of resources behind a clean variable/output interface. The Terraform Associate exam tests module sources, the standard file structure, input/output wiring, version constraints, Registry modules, provider passing, and when to create a module vs inline resources.
1. What is a Module?
A module is a container for multiple Terraform resources that are used together. It is the primary mechanism for code reuse, encapsulation, and standardization in Terraform.
Key concept: Every Terraform configuration you write is already a module — the root module. When you call another module with a module block, that becomes a child module.
2. Root Module vs Child Module
| Aspect | Root Module | Child Module |
|---|---|---|
| Definition | Top-level .tf files in working dir | Called via module block |
| Has its own state? | Yes — it owns the state | No — resources appear in root state |
| Can be called? | No (it's the entry point) | Yes, by root or other modules |
| Variables set by | CLI, tfvars, env vars | Caller via module block arguments |
| Outputs consumed by | CLI / terraform output | Caller via module.name.output |
3. Standard Module File Structure
The Terraform community has standardized on this layout:
modules/
vpc/ ← module directory name = logical name
main.tf ← all resources
variables.tf ← all input variable declarations
outputs.tf ← all output value declarations
versions.tf ← terraform {} block with required_providers
README.md ← required for Terraform Registry publishing
compute/
main.tf
variables.tf
outputs.tf
versions.tf
root-config/
main.tf ← calls modules
variables.tf ← root inputs
outputs.tf ← root outputs
terraform.tfvars ← environment-specific values
backend.tf ← backend configuration
Files are not required to have these exact names — Terraform loads all
.tffiles in a directory. These names are conventions that make codebases predictable.
4. Module Sources
The source argument tells Terraform where to find the module. It is required in every module block:
1# 1. Local path (relative to calling module)
2module "vpc" {
3 source = "./modules/vpc"
4}
5
6# 2. Terraform Registry (NAMESPACE/MODULE/PROVIDER)
7module "vpc" {
8 source = "terraform-aws-modules/vpc/aws"
9 version = "5.1.2"
10}
11
12# 3. GitHub (public repo, specific ref)
13module "vpc" {
14 source = "github.com/hashicorp/example//modules/vpc?ref=v2.0.0"
15 # Note: // separates repo from subdirectory
16}
17
18# 4. Generic Git (any Git server)
19module "vpc" {
20 source = "git::https://gitlab.example.com/infra/modules.git//vpc?ref=v1.5.0"
21}
22
23# 5. Git over SSH
24module "vpc" {
25 source = "git::ssh://git@github.com/myorg/terraform-modules.git//vpc"
26}
27
28# 6. S3 bucket (zipped module)
29module "vpc" {
30 source = "s3::https://my-bucket.s3.amazonaws.com/modules/vpc-1.0.0.zip"
31}
32
33# 7. GCS bucket
34module "vpc" {
35 source = "gcs::https://www.googleapis.com/storage/v1/my-bucket/vpc-1.0.0.zip"
36}| Source Type | version arg? | Use Case |
|---|---|---|
| Local path | No (not applicable) | In-repo modules during development |
| Terraform Registry | Yes — required | Public/private reusable modules |
| GitHub / Git | No (use ?ref=) | Private org modules, pinned by tag |
| S3 / GCS | No | Artifact-based distribution |
Exam tip: Only Registry and Git sources support the
versionargument. Local paths do not useversion.
5. The module Block — All Arguments
1module "web_cluster" {
2 # Required
3 source = "terraform-aws-modules/autoscaling/aws"
4 version = "~> 6.0"
5
6 # Input variables (module-specific)
7 name = "web-${var.environment}"
8 min_size = 2
9 max_size = 10
10 desired_capacity = var.instance_count
11 vpc_zone_identifier = module.vpc.private_subnet_ids
12 target_group_arns = [aws_lb_target_group.web.arn]
13
14 # Meta-arguments (work on any module block)
15 count = var.enable_cluster ? 1 : 0 # conditional module
16 depends_on = [module.vpc, aws_iam_role.node] # explicit dependency
17
18 providers = {
19 aws = aws.us_west # override provider for this module
20 }
21}| Argument | Type | Purpose |
|---|---|---|
source | Required | Where to find the module |
version | For Registry/Git | Version constraint |
| Input vars | Module-specific | Set module's variable values |
count | Meta-argument | Create N instances of the module |
for_each | Meta-argument | One instance per map/set element |
depends_on | Meta-argument | Explicit module-level dependency |
providers | Meta-argument | Pass aliased providers to module |
6. Module Inputs and Outputs — The Interface
Modules communicate through a clean variable/output interface:
Inside the module (modules/vpc/variables.tf):
1variable "cidr_block" {
2 description = "CIDR block for the VPC"
3 type = string
4}
5
6variable "environment" {
7 description = "Deployment environment"
8 type = string
9}
10
11variable "enable_dns_hostnames" {
12 description = "Enable DNS hostnames in VPC"
13 type = bool
14 default = true
15}Inside the module (modules/vpc/main.tf):
1resource "aws_vpc" "main" {
2 cidr_block = var.cidr_block
3 enable_dns_hostnames = var.enable_dns_hostnames
4
5 tags = {
6 Name = "${var.environment}-vpc"
7 Environment = var.environment
8 }
9}
10
11resource "aws_internet_gateway" "main" {
12 vpc_id = aws_vpc.main.id
13 tags = { Name = "${var.environment}-igw" }
14}Inside the module (modules/vpc/outputs.tf):
1output "vpc_id" {
2 description = "ID of the VPC"
3 value = aws_vpc.main.id
4}
5
6output "internet_gateway_id" {
7 description = "ID of the Internet Gateway"
8 value = aws_internet_gateway.main.id
9}In the root module (main.tf):
1module "vpc" {
2 source = "./modules/vpc"
3 cidr_block = "10.0.0.0/16"
4 environment = var.environment
5 # enable_dns_hostnames uses its default = true
6}
7
8resource "aws_subnet" "app" {
9 vpc_id = module.vpc.vpc_id # consuming module output
10 cidr_block = "10.0.1.0/24"
11 availability_zone = "us-east-1a"
12}
13
14output "vpc_id" {
15 value = module.vpc.vpc_id # re-exposing module output
16}7. Public Registry Modules
The Terraform Registry hosts thousands of community modules. The terraform-aws-modules organization maintains the most widely used:
1# VPC with public/private subnets, NAT gateways, routing
2module "vpc" {
3 source = "terraform-aws-modules/vpc/aws"
4 version = "~> 5.1"
5
6 name = "prod-vpc"
7 cidr = "10.0.0.0/16"
8 azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
9
10 private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
11 public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
12
13 enable_nat_gateway = true
14 single_nat_gateway = false # one NAT GW per AZ for HA
15
16 tags = { Environment = "prod" }
17}
18
19# EKS cluster
20module "eks" {
21 source = "terraform-aws-modules/eks/aws"
22 version = "~> 19.0"
23
24 cluster_name = "prod-cluster"
25 cluster_version = "1.29"
26 vpc_id = module.vpc.vpc_id
27 subnet_ids = module.vpc.private_subnets
28}Registry module address format: NAMESPACE/MODULE_NAME/PROVIDER
terraform-aws-modules/vpc/aws
└─────────────────── └──── └──
namespace name provider
8. Module Composition Patterns
Flat composition (recommended):
- Root module calls all child modules directly
- Dependencies expressed via output → input wiring
- Easy to understand the full picture from one place
Nested composition (use sparingly):
- A child module calls other child modules
- Increases encapsulation but hides dependencies
- Harder to debug; avoid more than 2 levels deep
9. Using count and for_each with Modules
1# Deploy the same module across multiple environments
2module "app" {
3 for_each = toset(["dev", "staging", "prod"])
4 source = "./modules/app"
5
6 environment = each.key
7 instance_count = each.key == "prod" ? 3 : 1
8 vpc_id = var.vpc_ids[each.key]
9}
10
11# Reference a specific instance
12output "prod_url" {
13 value = module.app["prod"].load_balancer_dns
14}
15
16# Reference all instances
17output "all_urls" {
18 value = { for env, mod in module.app : env => mod.load_balancer_dns }
19}10. Passing Providers to Modules
By default, a child module inherits the default (unaliased) provider from its caller. Use providers to override:
1# Module that needs to deploy into two regions simultaneously
2# modules/replica/versions.tf declares:
3# required_providers { aws = { source = "hashicorp/aws" } }
4# configuration_aliases = [aws.primary, aws.replica] (Terraform >= 0.13)
5
6module "database_replica" {
7 source = "./modules/replica"
8
9 providers = {
10 aws.primary = aws # default provider → primary alias inside module
11 aws.replica = aws.us_west_2 # aliased provider → replica alias inside module
12 }
13}Inside the module (modules/replica/versions.tf):
1terraform {
2 required_providers {
3 aws = {
4 source = "hashicorp/aws"
5 version = "~> 5.0"
6 configuration_aliases = [aws.primary, aws.replica]
7 }
8 }
9}11. After terraform init — Module Download
1# terraform init downloads modules to .terraform/modules/
2.terraform/
3 modules/
4 modules.json ← index of all downloaded modules
5 vpc/ ← local module (symlinked)
6 web_cluster/ ← Registry module (downloaded)
7 terraform-aws-autoscaling-6.5.3/
8 main.tf
9 variables.tf
10 outputs.tf1# Re-download modules (e.g., after changing source/version)
2terraform init -upgrade
3
4# Get just module sources without re-downloading providers
5terraform get
6terraform get -update # re-download even if already present12. Module Best Practices
When to create a module:
- The same group of resources is needed in 3+ places
- A component has a clean interface (defined inputs, defined outputs)
- You need to enforce organizational standards (naming, tagging, security)
When NOT to create a module:
- You only have 1-2 resources — just write them inline
- The abstraction doesn't simplify anything
- It would create a "pass-through" module with no real logic
Design principles:
1# Good: clear interface, hides complexity
2module "secure_bucket" {
3 source = "./modules/secure-s3"
4 bucket_name = "my-data"
5 environment = "prod"
6 kms_key_arn = aws_kms_key.main.arn
7}
8# The module handles: versioning, encryption, public access block,
9# lifecycle rules, access logging — 40+ lines hidden behind 4 inputs
10
11# Bad: module just wraps one resource with no added value
12module "bucket" {
13 source = "./modules/s3-bucket"
14 bucket_name = "my-data"
15}
16# This module just creates aws_s3_bucket — no benefit over writing it directly13. Complete Example — VPC + Compute Module Composition
1# root/main.tf
2
3module "vpc" {
4 source = "./modules/vpc"
5 cidr_block = "10.0.0.0/16"
6 environment = var.environment
7 azs = ["us-east-1a", "us-east-1b"]
8}
9
10module "web" {
11 source = "./modules/compute"
12
13 environment = var.environment
14 vpc_id = module.vpc.vpc_id # output wiring
15 subnet_ids = module.vpc.private_subnet_ids
16 instance_count = var.instance_count
17 instance_type = var.instance_type
18 key_name = var.key_name
19
20 depends_on = [module.vpc] # explicit for hidden dependencies
21}
22
23module "alb" {
24 source = "./modules/load-balancer"
25
26 environment = var.environment
27 vpc_id = module.vpc.vpc_id
28 subnet_ids = module.vpc.public_subnet_ids
29 target_instance_ids = module.web.instance_ids # output wiring
30}
31
32# root/outputs.tf
33output "alb_dns_name" {
34 description = "URL of the load balancer"
35 value = module.alb.dns_name
36}
37
38output "vpc_id" {
39 value = module.vpc.vpc_id
40}14. Quick Reference
| Concept | Key Fact |
|---|---|
| Every config is | A module (the root module) |
| Child module | Called via module block; resources appear in root state |
source | Required in every module block |
version | Required for Registry sources; not used for local paths |
| Registry format | NAMESPACE/MODULE/PROVIDER |
| Module outputs | module.name.output_name |
| Module inputs | Set as arguments in the module block |
terraform get | Download module sources without downloading providers |
terraform init | Downloads both providers and modules |
| Module meta-arguments | count, for_each, depends_on, providers |
| Passing providers | providers = { aws.alias = aws.my_alias } in module block |
| Standard files | main.tf, variables.tf, outputs.tf, versions.tf |
| Flat vs nested | Prefer flat (root calls all children); avoid deep nesting |
| When to modularize | 3+ uses of the same pattern with a clean interface |
Practice Questions11
Q1. What is a Terraform module?
Select one answer before revealing.
Q2. What are valid module source types in Terraform? (Select all that apply — more than one answer may be correct.)
Select one answer before revealing.
Q3. You have created a VPC module and want to use its output in a security group resource. The module is called `network` and has an output named `vpc_id`. How do you reference it?
Select one answer before revealing.
Q4. What is the difference between `count` and `for_each` when creating multiple resources? (Select all that apply — more than one answer may be correct.)
Select one answer before revealing.
Q5. Scenario: You have a Terraform module that creates an EC2 instance and its associated security group. A junior engineer wants to use the module but needs a slightly different security group rule. What is the best approach?
Select one answer before revealing.
Q6. Scenario: You need to deploy identical infrastructure in 5 AWS accounts (dev, qa, staging, preprod, prod). What is the recommended Terraform approach?
Select one answer before revealing.
Q7. Scenario: Your Terraform configuration has a `for_each` over a set of S3 bucket names. A new team wants to remove one bucket. What will Terraform plan show?
Select one answer before revealing.
Q8. Scenario: You need to create 3 identical S3 buckets with names `dev-bucket`, `staging-bucket`, and `prod-bucket`. Which Terraform approach is most idiomatic?
Select one answer before revealing.
Q9. You need to run the same Terraform configuration for both `us-east-1` and `eu-west-1` using the same module. Which approach correctly passes different providers to a child module?
Select one answer before revealing.
Q10. Hands-On: What does the following Terraform code produce, and is there a potential issue? ```hcl variable "instance_count" { type = number default = 2 } resource "aws_instance" "servers" { count = var.instance_count ami = "ami-0c55b159cbfafe1f0" instance_type = "t3.micro" tags = { Name = "server-${count.index}" } } output "server_ips" { value = aws_instance.servers[*].public_ip } ```
Select one answer before revealing.
Q11. Scenario: You manage a shared Terraform module that creates an AWS VPC. A new team asks to add a `flow_logs_enabled` variable to enable VPC Flow Logs. However, flow logs require an IAM role and CloudWatch Log Group. How should you design this in the module?
Select one answer before revealing.