Table of Contents
- 1. Module Fundamentals
- 2. Using Terraform Modules
- 3. Module Development and Design
- 4. Module Publishing and Versioning
- 5. Reference Tables
1. Module Fundamentals
1.1 Terraform Module Definition
A Terraform module is simply a directory containing Terraform files. That’s it. This minimalist definition has an important implication: the configuration in which you run Terraform commands is itself a module — the root module.
The module concept is at the heart of the DRY (Don’t Repeat Yourself) principle applied to Infrastructure as Code. Rather than rewriting the same resources in every project, you encapsulate repetitive patterns into reusable modules.
1.2 Root module vs Child modules
flowchart TD
RM["🟦 Root Module\n(working directory)"]
CM1["🟩 Child Module\nVPC"]
CM2["🟩 Child Module\nweb-front-end"]
CM3["🟧 Child Module\nDatabase"]
GCM["🟨 Grand-child Module\nIAM Security"]
RM -->|"module 'vpc'"| CM1
RM -->|"module 'web_front_end'"| CM2
RM -->|"module 'database'"| CM3
CM2 -->|"module 'iam_security'"| GCM
| Characteristic | Root Module | Child Module |
|---|---|---|
| Definition | Target directory of Terraform commands | Directory invoked by a parent |
| Commands | terraform init, plan, apply | No direct commands |
| Parent | None | Root module or another child module |
| Can invoke | Child modules | Other child modules |
| Receives information from | User input variables / .tfvars | Its parent module via module block arguments |
1.3 Common module types
There are three main categories of modules:
| Type | Description | Example |
|---|---|---|
| Resource modules | Deploy a set of resources following a common infrastructure pattern | AWS VPC module, EKS module, web front-end module |
| Data-only modules | Use data sources to query and transform information | Approved AMI catalog module |
| Function modules | Apply a data transformation on inputs, without resources | CIDR calculation, tag transformation |
Resource modules are the most common. They allow you to:
- Encapsulate best practices in a reusable abstraction
- Simplify the interface for the module consumer
- Standardize deployments across an organization
1.4 Terraform module structure
flowchart LR
subgraph MODULE["📁 modules/web-front-end/"]
V["variables.tf\n(inputs)"]
M["main.tf\n(resources)"]
O["outputs.tf\n(outputs)"]
T["versions.tf\n(required_providers)"]
R["README.md"]
end
subgraph ROOT["📁 root module/"]
RV["variables.tf"]
RM2["main-ec2.tf\nmodule 'web_front_end'"]
RO["outputs.tf"]
RP["providers.tf"]
RTF["terraform.tf"]
RTFV["terraform.tfvars"]
end
RM2 -->|"source = ./modules/web-front-end"| MODULE
RV -->|"inputs"| RM2
MODULE -->|"module.web_front_end.output_name"| RO
2. Using Terraform Modules
2.1 The module block syntax
The module block is the only new syntactic element for using modules. It takes a label (name) and the mandatory source argument.
module "vpc" {
# source is the ONLY REQUIRED argument
# its value must be a literal (not a variable, not an expression)
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
# Optional meta-arguments
count = 2
for_each = toset(["prod", "staging"])
depends_on = [aws_iam_role.example]
providers = {
aws = aws.us-east-1
}
# Arguments = input variables of the child module
name = "my-vpc"
cidr = "10.0.0.0/16"
}
Important: The value of
sourcemust be a literal value. You cannot usevar.module_sourceor a Terraform expression.
2.2 Module inputs and outputs
Modules behave like functions: they have well-defined inputs (input variables), well-defined outputs (output values), and their internal scope is isolated.
Module scoping
flowchart LR
subgraph PARENT["Root Module (parent)"]
PV["var.vpc_cidr\nvar.prefix\nlocal.tags\ndata.aws_az"]
end
subgraph CHILD["Child Module"]
CI["variable 'vpc_cidr'\nvariable 'prefix'"]
CR["internal resources\ninternal locals\ninternal data sources"]
CO["output 'vpc_id'\noutput 'subnet_ids'"]
end
PV -->|"module block arguments\n(only way to send info)"| CI
CO -->|"module.child_name.vpc_id\n(only way to retrieve info)"| PARENT
CR -.->|"NOT accessible\nfrom parent"| PARENT
PARENT -.->|"NOT accessible\nfrom child\n(except providers)"| CR
Defining input variables in a child module
# modules/web-front-end/variables.tf
variable "app_port" {
type = number
description = "Port the application listens on"
default = 80
}
variable "autoscale_group_min_max" {
type = object({
min = number
max = number
})
description = "The minimum and maximum size for the autoscale group."
}
variable "autoscale_group_size" {
type = number
description = "Default size of autoscale group."
}
variable "environment" {
type = string
description = "(Required) Environment of all resources"
}
variable "instance_type" {
type = string
description = "Instance type for Autoscale group"
default = "t3.micro"
}
variable "launch_template_ami" {
type = string
description = "AMI ID to use for the launch template"
}
variable "prefix" {
type = string
description = "(Required) Prefix to use for all resources in this module."
}
variable "public_subnet_ids" {
type = list(string)
description = "List of public subnet IDs for the autoscale group and NLB."
}
variable "user_data_contents" {
type = string
description = "User data script contents for the launch template."
}
variable "vpc_id" {
type = string
description = "VPC ID where resources will be deployed."
}
Defining outputs in a child module
# modules/web-front-end/outputs.tf
output "autoscaling_group_name" {
description = "The name of the autoscaling group"
value = aws_autoscaling_group.front_end.name
}
output "lb_public_dns" {
description = "The public DNS name of the load balancer"
value = aws_lb.front_end.dns_name
}
Referencing a module output from the parent
# In the root module
resource "aws_autoscaling_policy" "scale_up" {
autoscaling_group_name = module.web_front_end.autoscaling_group_name
}
output "public_nlb_dns" {
value = module.web_front_end.lb_public_dns
}
The syntax is: module.<module_name>.<output_name>
2.3 Module providers
Providers are passed from the root module to child modules. There are two mechanisms:
Implicit inheritance
By default, a child module inherits the default (non-aliased) provider instance from the root module. No additional configuration is needed.
Explicit inheritance with the providers argument
# Root module - providers.tf
provider "aws" {
region = "us-west-2"
}
provider "aws" {
alias = "dr"
region = "us-east-1"
}
provider "aws" {
alias = "security"
region = "eu-west-1"
}
# Root module - main.tf
module "network_dr" {
source = "./modules/network"
providers = {
aws = aws.dr # The child module will use the "dr" instance
}
}
module "security_baseline" {
source = "./modules/security"
providers = {
aws = aws.security
aws.logging = aws.dr
}
}
Multiple aliases in a child module
To use multiple aliases in a child module, declare them in the required_providers block:
# modules/multi-region/versions.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 6.0"
configuration_aliases = [aws.logging]
}
}
}
2.4 Module sources
flowchart TD
S["Module Source"]
S --> LOCAL["📁 Local\n./modules/web-front-end\n../shared/modules/vpc"]
S --> REG["🌐 Terraform Registry\nhashicorp/consul/aws\nregistry.example.com/org/module/aws"]
S --> GH["🐙 GitHub\ngithub.com/org/repo\ngithub.com/org/repo//subdir"]
S --> GIT["🔗 Generic Git\ngit::https://example.com/repo.git\ngit::ssh://...?ref=v1.0.0"]
S --> BB["Bitbucket\nbitbucket.org/org/repo"]
S --> URL["🗜️ Archive URL\nhttps://example.com/module.zip\nhttps://example.com/module.tar.gz"]
S --> S3["☁️ S3 Bucket\ns3::https://bucket.region.amazonaws.com/module.zip"]
S --> GCS["☁️ GCS Bucket\ngcs::https://storage.googleapis.com/bucket/module.zip"]
style REG fill:#4CAF50,color:#fff
style LOCAL fill:#2196F3,color:#fff
| Source | Syntax | Version constraint |
|---|---|---|
| Local | ./modules/name or ../shared | ❌ Not supported |
| Public Registry | org/module/provider | ✅ version = "~> 5.0" |
| Private Registry | hostname/org/module/provider | ✅ version = "~> 5.0" |
| GitHub | github.com/org/repo | ❌ Use ?ref=v1.0.0 |
| Git | git::https://...?ref=tag | ❌ Use ?ref= |
| Archive URL | https://example.com/module.zip | ❌ Not supported |
| S3 | s3::https://... | ❌ Not supported |
Tip: In practice, the vast majority of teams use either the local filesystem (organization’s private modules) or the Terraform Registry (public modules or enterprise private registries).
2.5 Adding a module from the Terraform Registry
sequenceDiagram
participant Dev as Developer
participant TF as Terraform CLI
participant REG as Terraform Registry
participant AWS as AWS Provider
Dev->>TF: terraform init
TF->>REG: Resolve source "terraform-aws-modules/vpc/aws"
REG-->>TF: Return module archive v5.1.4
TF->>TF: Extract module into .terraform/modules/
TF->>AWS: Initialize hashicorp/aws provider
Dev->>TF: terraform plan
TF->>TF: Evaluate module inputs
TF->>AWS: Create resource plan
AWS-->>TF: Plan generated
TF-->>Dev: Display plan
Concrete example: Replace manual VPC resources with the official AWS VPC module.
Before (manual resources in main-network.tf):
# main-network.tf - BEFORE (without module)
resource "aws_vpc" "main" {
cidr_block = var.vpc_address_range
enable_dns_hostnames = true
tags = {
Name = "${var.prefix}-vpc"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
}
resource "aws_subnet" "public_subnets" {
count = length(var.vpc_public_subnet_ranges)
vpc_id = aws_vpc.main.id
cidr_block = var.vpc_public_subnet_ranges[count.index]
availability_zone = data.aws_availability_zones.available.names[count.index % length(data.aws_availability_zones.available.names)]
map_public_ip_on_launch = true
tags = {
Name = "${var.prefix}-public-subnet-${count.index + 1}"
}
}
resource "aws_route_table" "default" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
}
resource "aws_route_table_association" "public_subnets" {
count = length(var.vpc_public_subnet_ranges)
subnet_id = aws_subnet.public_subnets[count.index].id
route_table_id = aws_route_table.default.id
}
After (with the Terraform Registry module):
# main-network.tf - AFTER (with module)
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "${var.prefix}-vpc"
cidr = var.vpc_address_range
azs = data.aws_availability_zones.available.names
public_subnets = var.vpc_public_subnet_ranges
enable_dns_hostnames = true
map_public_ip_on_launch = true
}
# Reference module outputs
# module.vpc.vpc_id
# module.vpc.public_subnets
3. Module Development and Design
3.1 When to write a module?
HashiCorp has formalized a decision tree to guide this thinking:
flowchart TD
Q1{"Is it a recurring\narchitectural pattern\n(e.g. VPC + subnets + IGW)?"}
Q2{"Does the service require\na lot of configuration\n(e.g. AKS, EKS)?"}
Q3{"Is it a custom set\nof multiple\nresource types?"}
YES["✅ Write a module\n(resource module)"]
NO["❌ No module needed\nPut the resource\ndirectly in the root"]
Q1 -->|Yes| YES
Q1 -->|No| Q2
Q2 -->|Yes| YES
Q2 -->|No| Q3
Q3 -->|Yes| YES
Q3 -->|No| NO
Golden rule: A module should provide abstraction or describe an architectural concept. It should not be simply a wrapper around a single resource type.
3.2 Planning a new module
Before writing a single line of code, answer three questions:
- What resources are part of the module?
- What inputs are expected from the parent module?
- What outputs will be returned?
3.3 Design principles
Module composition
flowchart TB
subgraph ROOT["Root Module (orchestrator)"]
RM["main-ec2.tf\nmain-network.tf\noutputs.tf"]
end
subgraph MVPC["Module vpc"]
VPC["aws_vpc\naws_subnet\naws_internet_gateway\naws_route_table"]
end
subgraph MWF["Module web-front-end"]
WF["aws_security_group\naws_launch_template\naws_autoscaling_group\naws_lb"]
end
subgraph MDB["Module database (future)"]
DB["aws_db_instance\naws_db_subnet_group"]
end
ROOT -->|"module 'vpc'\nvpc_cidr, azs"| MVPC
ROOT -->|"module 'web_front_end'\nvpc_id ← module.vpc.vpc_id\nsubnets ← module.vpc.public_subnets"| MWF
ROOT -->|"module 'database'\nvpc_id ← module.vpc.vpc_id"| MDB
style ROOT fill:#1565C0,color:#fff
style MVPC fill:#2E7D32,color:#fff
style MWF fill:#6A1B9A,color:#fff
style MDB fill:#4E342E,color:#fff
Module composition: the root module is the composer that orchestrates modules and circulates information between them.
Dependency inversion
The module should not have dependencies on resources outside of it. The information it needs is injected via its input variables.
# ❌ BAD: tight coupling — the module directly accesses the parent's VPC
resource "aws_autoscaling_group" "front_end" {
vpc_zone_identifier = aws_subnet.public_subnets[*].id # parent reference!
}
# ✅ GOOD: dependency injection via input variable
variable "public_subnet_ids" {
type = list(string)
description = "List of public subnet IDs for the autoscale group and NLB."
}
resource "aws_autoscaling_group" "front_end" {
vpc_zone_identifier = var.public_subnet_ids # injected dependency
}
Optional creation (conditional resources)
variable "create_nlb" {
type = bool
description = "Whether to create a Network Load Balancer"
default = true
}
resource "aws_lb" "front_end" {
count = var.create_nlb ? 1 : 0
# ...
}
3.4 Recommended file structure
modules/
└── web-front-end/ ← module directory
├── main.tf ← main resources
├── variables.tf ← input variables
├── outputs.tf ← output values
├── versions.tf ← required_providers (or terraform.tf)
├── README.md ← documentation (terraform-docs)
├── examples/ ← usage examples (recommended)
│ ├── basic/
│ └── complete/
└── tests/ ← Terraform tests (recommended)
Recommended tool:
terraform-docsautomatically generates markdown documentation for variables and outputs.
3.5 Creating the web-front-end module
versions.tf — module required providers
# modules/web-front-end/versions.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 6.14.0"
}
}
}
variables.tf — module inputs
# modules/web-front-end/variables.tf
variable "app_port" {
type = number
description = "Port the application listens on"
default = 80
}
variable "autoscale_group_min_max" {
type = object({
min = number
max = number
})
description = "The minimum and maximum size for the autoscale group."
}
variable "autoscale_group_size" {
type = number
description = "Default size of autoscale group."
}
variable "environment" {
type = string
description = "(Required) Environment of all resources"
}
variable "instance_type" {
type = string
description = "Instance type for Autoscale group"
default = "t3.micro"
}
variable "launch_template_ami" {
type = string
description = "AMI ID to use for the launch template"
}
variable "prefix" {
type = string
description = "(Required) Prefix to use for all resources in this module."
}
variable "public_subnet_ids" {
type = list(string)
description = "List of public subnet IDs for the autoscale group and NLB."
}
variable "user_data_contents" {
type = string
description = "User data script contents for the launch template."
}
variable "vpc_id" {
type = string
description = "VPC ID where resources will be deployed."
}
main.tf — module resources
# modules/web-front-end/main.tf
# Security Groups
resource "aws_security_group" "nlb_sg" {
name_prefix = "${var.prefix}-nlb-sg-"
vpc_id = var.vpc_id
ingress {
description = "Allow HTTP traffic from anywhere"
from_port = var.app_port
to_port = var.app_port
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
description = "Allow all outbound traffic"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.prefix}-${var.environment}-nlb-sg"
Environment = var.environment
}
}
resource "aws_security_group" "ec2_sg" {
name_prefix = "${var.prefix}-ec2-sg-"
vpc_id = var.vpc_id
ingress {
description = "Allow HTTP traffic from NLB"
from_port = var.app_port
to_port = var.app_port
protocol = "tcp"
security_groups = [aws_security_group.nlb_sg.id]
}
egress {
description = "Allow all outbound traffic"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.prefix}-${var.environment}-ec2-sg"
Environment = var.environment
}
}
# Launch Template & Autoscaling Group
resource "aws_launch_template" "front_end" {
name_prefix = "${var.prefix}-web-"
image_id = var.launch_template_ami
instance_type = var.instance_type
user_data = var.user_data_contents
vpc_security_group_ids = [aws_security_group.ec2_sg.id]
tag_specifications {
resource_type = "instance"
tags = {
Name = "${var.prefix}-${var.environment}-ec2-instance"
Environment = var.environment
}
}
}
resource "aws_autoscaling_group" "front_end" {
vpc_zone_identifier = var.public_subnet_ids
desired_capacity = var.autoscale_group_size
max_size = var.autoscale_group_min_max.max
min_size = var.autoscale_group_min_max.min
launch_template {
id = aws_launch_template.front_end.id
version = "$Latest"
}
health_check_type = "ELB"
tag {
key = "Name"
value = "${var.prefix}-${var.environment}-asg"
propagate_at_launch = false
}
tag {
key = "Environment"
value = var.environment
propagate_at_launch = true
}
}
# Network Load Balancer
resource "aws_lb" "front_end" {
name = "${var.prefix}-${var.environment}-nlb"
internal = false
load_balancer_type = "network"
subnets = var.public_subnet_ids
tags = {
Name = "${var.prefix}-${var.environment}-nlb"
Environment = var.environment
}
}
resource "aws_lb_target_group" "front_end" {
name = "${var.prefix}-${var.environment}-tg"
port = var.app_port
protocol = "TCP"
vpc_id = var.vpc_id
}
resource "aws_autoscaling_attachment" "front_end" {
autoscaling_group_name = aws_autoscaling_group.front_end.id
lb_target_group_arn = aws_lb_target_group.front_end.arn
}
outputs.tf — module outputs
# modules/web-front-end/outputs.tf
output "autoscaling_group_name" {
description = "The name of the autoscaling group"
value = aws_autoscaling_group.front_end.name
}
output "lb_public_dns" {
description = "The public DNS name of the load balancer"
value = aws_lb.front_end.dns_name
}
3.6 Refactoring the root module
Once the module is created, the root module is refactored to invoke it. The root module becomes an orchestrator that composes modules together.
# main-ec2.tf (root module)
# Data source for the AMI — stays in the root module (dependency inversion)
data "aws_ssm_parameter" "amzn2_linux" {
name = "/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2"
}
# Invoke the web-front-end module
module "web_front_end" {
source = "./modules/web-front-end"
# Variables directly from the root module
app_port = var.app_port
autoscale_group_size = var.autoscale_group_size
autoscale_group_min_max = var.autoscale_group_min_max
environment = var.environment
instance_type = var.instance_type
prefix = var.prefix
# Dependency injection: values from other modules
public_subnet_ids = module.vpc.public_subnets # from the VPC module
vpc_id = module.vpc.vpc_id # from the VPC module
# Value from a local data source
launch_template_ami = data.aws_ssm_parameter.amzn2_linux.value
# user_data rendered in root module (dependency inversion)
user_data_contents = base64encode(templatefile("./templates/startup_script.tpl", {
environment = var.environment
}))
}
# Autoscaling Policies — stay in the root module (out of scope of the module)
resource "aws_autoscaling_policy" "scale_up" {
name = "${var.prefix}-${var.environment}-scale-up"
scaling_adjustment = 1
adjustment_type = "ChangeInCapacity"
cooldown = 300
autoscaling_group_name = module.web_front_end.autoscaling_group_name
policy_type = "SimpleScaling"
}
resource "aws_autoscaling_policy" "scale_down" {
name = "${var.prefix}-${var.environment}-scale-down"
scaling_adjustment = -1
adjustment_type = "ChangeInCapacity"
cooldown = 300
autoscaling_group_name = module.web_front_end.autoscaling_group_name
policy_type = "SimpleScaling"
}
# outputs.tf (root module)
output "public_nlb_dns" {
description = "Public DNS of the Network Load Balancer"
value = module.web_front_end.lb_public_dns
}
After adding a new local module, you must rerun
terraform initso Terraform resolves the new modules before runningterraform plan.
4. Module Publishing and Versioning
4.1 Semantic versioning
Modules follow the Semantic Versioning (semver) format: MAJOR.MINOR.PATCH
flowchart LR
V["Version\ne.g. 2.3.1"]
V --> MAJ["MAJOR: 2\n\nBreaking changes\nForces code update"]
V --> MIN["MINOR: 3\n\nNew optional features\nBackward compatible"]
V --> PAT["PATCH: 1\n\nBug fixes\nNo new features\nBackward compatible"]
style MAJ fill:#C62828,color:#fff
style MIN fill:#1565C0,color:#fff
style PAT fill:#2E7D32,color:#fff
4.2 Version constraints
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
# Exact version
version = "5.1.4"
# Comparison operators
version = ">= 5.0.0"
version = "< 6.0.0"
version = ">= 5.0.0, < 6.0.0"
# Pessimistic constraint operator (~>) — MOST COMMON
# Only allows the rightmost digit to increment
version = "~> 5.1" # equivalent to >= 5.1, < 6.0
version = "~> 5.1.4" # equivalent to >= 5.1.4, < 5.2.0
# NOT operator (rare)
version = "!= 5.0.3"
}
Recommendation: Always pin a module version with a constraint. This ensures reproducible deployments and avoids surprises during automatic updates.
4.3 Publishing a module to the Terraform Registry
sequenceDiagram
participant Dev as Developer
participant GH as GitHub
participant REG as Terraform Registry
Dev->>GH: 1. Create public repo\nnamed: terraform-<PROVIDER>-<NAME>
Dev->>GH: 2. Push module files\n(main.tf, variables.tf, outputs.tf, README.md)
Dev->>GH: 3. Create a release tag\n(v1.0.0 — semver format)
Dev->>REG: 4. Sign in with GitHub account
Dev->>REG: 5. Publish > choose the repository
REG->>GH: Scan release tags
REG-->>Dev: Module published and available
Note over REG: The Registry uses release tags\nto identify published versions
Mandatory naming convention for the public Registry:
terraform-<PROVIDER>-<MODULE_NAME>
Examples:
terraform-aws-web_front_end
terraform-aws-vpc
terraform-google-kubernetes-engine
terraform-azurerm-network
After publishing, the module is accessible with the short source:
module "web_front_end" {
source = "globomantics/web_front_end/aws"
version = "~> 1.0"
# ...
}
4.4 Refactoring and module updates
The moved block lets you refactor a resource address without destroying and recreating state:
# Scenario 1: rename a resource within a module
moved {
from = aws_security_group.nlb
to = aws_security_group.nlb_sg
}
# Scenario 2: move a resource from root module to a child module
moved {
from = aws_lb.front_end
to = module.web_front_end.aws_lb.front_end
}
# Scenario 3: rename a module
moved {
from = module.old_web_frontend
to = module.web_front_end
}
4.5 Updating a module (feature addition)
Example: add support for custom tags on instances (minor update → version 1.1.0).
# modules/web-front-end/variables.tf — new optional variable
variable "instance_tags" {
type = map(string)
description = "Custom tags to apply to EC2 instances"
default = {} # optional = minor update, not breaking
}
# modules/web-front-end/main.tf — use with merge()
resource "aws_launch_template" "front_end" {
# ...
tag_specifications {
resource_type = "instance"
tags = merge(
{
Name = "${var.prefix}-${var.environment}-ec2-instance"
Environment = var.environment
},
var.instance_tags # custom tags override on same key
)
}
}
The consumer can now use the new feature optionally:
module "web_front_end" {
source = "globomantics/web_front_end/aws"
version = "~> 1.1" # update to minor version
# ...
# Optional — not required for existing configurations
instance_tags = {
Team = "platform-team"
Project = "storefront-v2"
}
}
5. Reference Tables
5.1 Module sources
| Source type | Format | Supports version | Use case |
|---|---|---|---|
| Local filesystem | ./modules/name | ❌ | Project-private modules |
| Terraform Public Registry | org/module/provider | ✅ | Community modules |
| Terraform Private Registry | hostname/org/module/provider | ✅ | Enterprise modules |
| GitHub | github.com/org/repo | ❌ (?ref=) | Sharing without registry |
| Generic Git | git::https://...?ref= | ❌ (?ref=) | Private Git repos |
| Bitbucket | bitbucket.org/org/repo | ❌ | Bitbucket cloud/server |
| HTTP archive | https://url/module.zip | ❌ | Simple distribution |
| S3 bucket | s3::https://... | ❌ | Internal AWS storage |
| GCS bucket | gcs::https://... | ❌ | Internal GCP storage |
5.2 Module block meta-arguments
| Meta-argument | Type | Description |
|---|---|---|
source | string (required) | Module origin — literal value only |
version | string | Version constraint — only for Terraform Registry |
count | number | Create N instances of the module |
for_each | map / set | Create one module instance per element |
depends_on | list(ref) | Explicit dependencies |
providers | map | Map parent → child provider instances |
5.3 Change categories by semver
| Type | Semver | Examples | Code update required? |
|---|---|---|---|
| Bug fix / Patch | PATCH (x.x.N) | Fix for an unexpected error | ❌ No |
| Minor update | MINOR (x.N.0) | New optional variable, new output | ❌ No |
| Major update | MAJOR (N.0.0) | Required variable renamed/removed, resource replaced, minimum provider version incremented | ✅ Yes |
5.4 Best practices
| Practice | Description |
|---|---|
| Pin versions | Always use version = "~> x.y" for Registry modules |
| README.md required | Document inputs, outputs, and usage examples |
| Use terraform-docs | Generate documentation automatically |
| Dependency inversion | Module receives dependencies via variables, not by accessing them directly |
| Module composition | Combine multiple modules in the root module rather than putting everything in one |
| One module = one concept | Don’t create catch-all modules; each module should have a clear role |
| Optional variables with default | Optional features = variables with default value → minor update |
moved for refactoring | Use the moved block to change resource addresses without recreating state |
| Changelog | Maintain a CHANGELOG.md in the module repo |
examples/ directory | Provide concrete usage examples |
tests/ directory | Include Terraform tests to validate the module |
| Local source = relative value | Prefer ./modules/name over an absolute path for portability |
terraform init after adding | Always rerun terraform init after adding/modifying a module source |
Search Terms
terraform · infrastructure · ci/cd · devops · child · outputs · providers · block · defining · design · inheritance · inputs · publishing · refactoring · registry · resources · root · sources · versioning