/Terraform Modules & Reusability
Concept
Medium

Terraform Modules & Reusability

9 min read·modulesroot-modulechild-modulemodule-sourcesmodule-outputsregistry-modulesmodule-compositionprovider-passingterraform-associate

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.

Rendering diagram…

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

AspectRoot ModuleChild Module
DefinitionTop-level .tf files in working dirCalled via module block
Has its own state?Yes — it owns the stateNo — resources appear in root state
Can be called?No (it's the entry point)Yes, by root or other modules
Variables set byCLI, tfvars, env varsCaller via module block arguments
Outputs consumed byCLI / terraform outputCaller 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 .tf files 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:

hcl
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 Typeversion arg?Use Case
Local pathNo (not applicable)In-repo modules during development
Terraform RegistryYes — requiredPublic/private reusable modules
GitHub / GitNo (use ?ref=)Private org modules, pinned by tag
S3 / GCSNoArtifact-based distribution

Exam tip: Only Registry and Git sources support the version argument. Local paths do not use version.


5. The module Block — All Arguments

hcl
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}
ArgumentTypePurpose
sourceRequiredWhere to find the module
versionFor Registry/GitVersion constraint
Input varsModule-specificSet module's variable values
countMeta-argumentCreate N instances of the module
for_eachMeta-argumentOne instance per map/set element
depends_onMeta-argumentExplicit module-level dependency
providersMeta-argumentPass aliased providers to module

6. Module Inputs and Outputs — The Interface

Modules communicate through a clean variable/output interface:

Rendering diagram…

Inside the module (modules/vpc/variables.tf):

hcl
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):

hcl
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):

hcl
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):

hcl
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:

hcl
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

Rendering diagram…

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

hcl
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:

hcl
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):

hcl
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

bash
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.tf
bash
1# 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 present

12. 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:

hcl
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 directly

13. Complete Example — VPC + Compute Module Composition

hcl
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

ConceptKey Fact
Every config isA module (the root module)
Child moduleCalled via module block; resources appear in root state
sourceRequired in every module block
versionRequired for Registry sources; not used for local paths
Registry formatNAMESPACE/MODULE/PROVIDER
Module outputsmodule.name.output_name
Module inputsSet as arguments in the module block
terraform getDownload module sources without downloading providers
terraform initDownloads both providers and modules
Module meta-argumentscount, for_each, depends_on, providers
Passing providersproviders = { aws.alias = aws.my_alias } in module block
Standard filesmain.tf, variables.tf, outputs.tf, versions.tf
Flat vs nestedPrefer flat (root calls all children); avoid deep nesting
When to modularize3+ uses of the same pattern with a clean interface

Practice Questions11

easy

Q1. What is a Terraform module?


Select one answer before revealing.

medium

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.

medium

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.

hard

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.

hard

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.

hard

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.

hard

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.

hard

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.

hard

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.

medium

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.

hard

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.