Terraform Modules Explained
Learn what Terraform modules are, when to use them, and how to construct them.
A Terraform module is a set of Terraform configuration files (.tf
or .tf.tf.json
) within a single directory. Even your simplest Terraform configuration, run from one directory, is a "root module."
Modules act as reusable building blocks. They encapsulate a collection of resources, data sources, variables, and outputs, treating them as a single logical unit. Think of them as programming functions: they take inputs, provision infrastructure, and produce outputs.
Why Use Terraform Modules?
Adopting Terraform modules offers significant benefits:
- Organization and Readability: Break down large, complex infrastructure into smaller, manageable, logical units. This improves code readability and makes infrastructure definition easier to understand.
- Reusability and Efficiency: Write infrastructure code once and reuse it across projects, teams, or environments. This eliminates redundant code, saves development time, and speeds up deployments.
- Consistency and Standardization: Ensure common infrastructure components deploy uniformly across your organization. Modules enforce best practices, naming conventions, and security policies, reducing configuration drift and errors.
- Abstraction of Complexity: Hide intricate resource provisioning details from higher-level configurations. Module users only need to know inputs and expected outputs, simplifying the experience and reducing cognitive load.
- Version Control and Collaboration: Modules can be independently versioned and managed. Teams can collaborate on specific infrastructure components without affecting others. Module updates can be rolled out systematically.
When to Use Modules?
Deciding when to create a module involves identifying patterns and common infrastructure requirements. Use modules when you:
- Repeat the same resources: If you copy/paste Terraform code for similar components (e.g., multiple VPCs, specific EC2 instances, or database clusters).
- Need to enforce standards: To ensure infrastructure components always deploy with specific configurations, tags, or security settings across projects or teams.
- Want to abstract complexity: When a complex set of resources forms a logical unit (e.g., an application stack including network, compute, and database).
- Are building a shared library: For organizations providing pre-approved, tested infrastructure building blocks to development teams.
Keeping Your Code DRY with Modules
The Don't Repeat Yourself (DRY) principle is a cornerstone of good software engineering, and it applies powerfully to Infrastructure as Code. Terraform modules are your primary tool for achieving DRY code in your infrastructure definitions.
Without modules, provisioning similar infrastructure components (like multiple web servers, databases, or network segments) often leads to copying and pasting large blocks of Terraform code. This creates several problems:
- Increased Maintenance Overhead: If you need to change a common configuration (e.g., update an instance type or add a new tag), you'd have to find and modify every copied instance of that code.
- Higher Risk of Errors: Manual repetition increases the chance of inconsistencies, typos, or missed updates across different infrastructure deployments.
- Reduced Readability: Large, repetitive configuration files become difficult to navigate and understand.
Modules solve this by allowing you to encapsulate a reusable piece of infrastructure. Instead of repeating the resource definitions, you simply call the module multiple times, each time providing potentially different input variables to customize its deployment.
Terraform Module Best Practices
To truly harness the power of Terraform modules, adhere to these best practices:
- Modularize Thoughtfully: Break down infrastructure into logical layers (network, compute, database) or features. Each module should have a clear, single responsibility. Avoid creating overly complex or monolithic modules.
- Encapsulation and Abstraction: Design modules to be as self-contained as possible. Only expose necessary inputs and outputs, hiding the internal implementation details. This makes modules easier to use and less prone to external breakage.
- Avoid Hardcoding: Parameterize all configurable values using input variables. This makes your modules reusable across various environments and configurations without needing code changes within the module itself.
- Semantic Versioning: Version your modules using semantic versioning (e.g.,
1.0.0
,1.1.0
,2.0.0
). This provides clear communication about changes, indicates backward compatibility, and helps manage upgrades for module consumers. Always pin module versions in your configurations. - Comprehensive Documentation: A well-documented module (with a detailed
README.md
) is a usable module. Explain its purpose, all inputs (with types, descriptions, and defaults), outputs, and provide clear usage examples. - Leverage the Terraform Registry (or your internal private registry): Before creating a new module, check if a suitable one already exists in the public Terraform Registry or your organization's private registry. Reusing existing, tested modules saves time and effort.
- Consistent Naming Conventions: Adopt clear and consistent naming for variables, outputs, resources, and file names within your modules. Consistency improves readability and maintainability.
- Automate Testing: Implement automated tests for your modules using tools like
terraform validate
,terraform fmt
,terraform plan
checks, and more advanced testing frameworks (e.g., Terratest). Testing ensures modules behave as expected and prevents regressions. - Minimize Dependencies: While modules can call other modules, try to minimize complex, multi-level module dependencies. Deep dependency trees can make debugging and understanding the overall infrastructure flow challenging.
Child and Root Modules
Module relationships are hierarchical:
- Root Module: This module invokes another module. It provides input variable values to the child module and consumes its outputs. Your root Terraform configuration, the one you run
terraform apply
on, is always a root module. - Child Module: This module is called or instantiated by a root module. It defines resources and exposes functionality through inputs and outputs.
When a parent module calls a child module, the child module creates its own isolated scope for resources and variables. This isolation prevents naming collisions and ensures changes within a child module don't inadvertently affect resources in the parent or other child modules, promoting stable, predictable infrastructure.
Example of Calling a Child Module:
Imagine you have a child module named ec2-instance
located in a directory ./modules/ec2-instance
that creates an EC2 instance and takes instance_name
and instance_type
as inputs, and outputs the public_ip
.
Your parent module (e.g., main.tf
in your root directory) would call it like this:
Terraform
# In your root module's main.tf
# Define a variable for the instance type in the root module
variable "app_instance_type" {
description = "The instance type for the application server"
type = string
default = "t2.medium"
}
# Call the 'ec2-instance' child module
module "app_server" {
source = "./modules/ec2-instance" # Path to your local child module
# Pass values to the child module's input variables
instance_name = "my-web-app-server"
instance_type = var.app_instance_type # Using a variable from the parent module
# You can also pass other arguments specific to the child module
# key_name = "my-ssh-key"
}
# Access an output from the child module
output "web_app_public_ip" {
description = "Public IP address of the web application server"
value = module.app_server.instance_public_ip # Referencing the child module's output
}
In this example, module "app_server"
is the block that calls the child module. The source
argument specifies the path to the child module's directory, and the arguments inside the block (instance_name
, instance_type
) correspond to the input variables defined within the ec2-instance
child module. The output
block then demonstrates how to access values exposed by the child module.
+-------------------------------------------------+
| ROOT MODULE |
| |
| main.tf (calling configuration) |
| |
| +-------------------------------------------+ |
| | module "my_server" { | |
| | source = "./modules/ec2" | |
| | Input 1: instance_name = "web-server" <--- ---+
| | Input 2: instance_type = "t3.small" <--- ---+
| | } | |
| | | |
| +-------------------------------------------+ |
| ^ | |
| | (Output from module) | |
| +-------------------------------------------+ |
| | output "server_ip" { | |
| | value = module.my_server.public_ip | |
| | } | |
+-------------------------------------------------+
|
| Calls / Instantiates
v
+-------------------------------------------------+
| CHILD MODULE |
| (e.g., ./modules/ec2) |
| |
| +---------------------+ +------------------+ |
| | VARIABLES (.tf) | | OUTPUTS (.tf) | |
| |---------------------| |------------------| |
| | variable "instance_name" <-------| output "public_ip" |
| | variable "instance_type" <-------| |
| +---------------------+ +------------------+ |
| ^ | Actual Infrastructure |
| | | Definitions |
| | | |
| | | +--------------------+ |
| +----------| | aws_instance | |
| | | - name | |
| | | - type | |
| | | - public_ip |--+ Output source
| | +--------------------+ |
| | |
+-------------------------------------------------+
Inputs and Outputs
Module reusability heavily relies on accepting inputs and producing outputs:
Outputs: Defined using output
blocks within the module, these expose specific values about the created resources. These outputs can be used by the calling (parent) module, other modules, or external systems for further configuration or information.Terraform
# In outputs.tf within your module
output "instance_public_ip" {
description = "The public IP address of the EC2 instance"
value = aws_instance.example.public_ip
}
Inputs (Variables): Defined using variable
blocks within the module, these parameterize the module's behavior. Instead of hardcoding values like region, instance types, or CIDR blocks, define them as variables. When calling the module, you provide values for these variables, making the module flexible.Terraform
# In variables.tf within your module
variable "instance_type" {
description = "The EC2 instance type"
type = string
default = "t3.micro"
}
How is a Module Constructed?
A Terraform module is a directory containing one or more .tf
(Terraform configuration) or .tf.json
files. While content varies, a well-structured module typically includes:
main.tf
: The primary file containing core resource definitions and data sources.variables.tf
: Declares all expected input variables, ideally with descriptions and default values.outputs.tf
: Defines output values the module exposes to its calling module.versions.tf
: Specifies required Terraform versions and provider constraints, ensuring compatibility.README.md
: Crucial documentation. This file should explain the module's purpose, usage, inputs, outputs, and provide examples.examples/
(optional directory): A common practice to include working examples of how to use the module, invaluable for users.
By embracing Terraform modules and these principles, you'll build robust, maintainable, and scalable IaC deployments, transforming infrastructure management into an efficient, automated process.
When and Why to Use Module Registries
Module registries are centralized repositories for sharing and discovering Terraform modules, essential for collaborative environments and large-scale IaC adoption.
When to use a module registry: Consider a registry when multiple teams need to share and reuse common infrastructure patterns, as they provide a single source of truth for approved modules. Registries simplify finding and integrating pre-built modules from official providers, the community, or internal teams. They are also vital for version management, allowing modules to be versioned (e.g., 1.0.0
, 1.1.0
). This enables users to pin specific versions, control updates, and prevent breaking changes for consistent deployments. Furthermore, many registries integrate with CI/CD pipelines, simplifying module testing, publishing, and consumption for automated workflows. Finally, for organizations requiring strict control over deployed infrastructure components, internal registries host validated and compliant modules, supporting compliance and governance efforts.
Why use a module registry: Registries offer simplified consumption; users can easily reference modules by name and version, eliminating the need to manage direct Git URLs or local paths. This leads to improved governance through centralized control over module versions and access, ensuring teams use approved and secure configurations. They also provide enhanced discovery and documentation, often with built-in documentation, examples, and search capabilities, making modules easier to understand and utilize. As your organization grows, a registry provides scalability, managing module needs and preventing unmanaged code sprawl. Public registries (like the Terraform Registry) offer high availability and act as a reliable source for widely used modules, while internal registries ensure your custom modules are always accessible, enhancing reliability.
You can utilize the public Terraform Registry for widely used modules, or establish a private registry (e.g., using Scalr, Terraform Cloud, Terraform, or Gitlab for your organization's specific and proprietary modules.
Tutorial - Create a S3 Module
Step 1: Create Your Module Directory and Files
Let's create a simple module that provisions an AWS S3 bucket.
First, create a directory for your module. We'll call it modules/s3_bucket
.
my-terraform-project/
├── main.tf
├── variables.tf
├── outputs.tf
└── modules/
└── s3_bucket/
├── main.tf
├── variables.tf
└── outputs.tf
Step 2: Define the Module's Variables (modules/s3_bucket/variables.tf
)
This file declares the inputs your module expects.
# modules/s3_bucket/variables.tf
variable "bucket_name" {
description = "The name of the S3 bucket."
type = string
}
variable "acl" {
description = "The ACL to apply to the bucket. (e.g., 'private', 'public-read')"
type = string
default = "private"
}
variable "tags" {
description = "A map of tags to assign to the bucket."
type = map(string)
default = {}
}
Step 3: Define the Module's Resources (modules/s3_bucket/main.tf
)
This file contains the actual resource definitions.
# modules/s3_bucket/main.tf
resource "aws_s3_bucket" "this" {
bucket = var.bucket_name
acl = var.acl
tags = var.tags
# Optional: Enable versioning
versioning {
enabled = true
}
# Optional: Enable server-side encryption
server_side_encryption_configuration {
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
}
Step 4: Define the Module's Outputs (modules/s3_bucket/outputs.tf
)
This file declares values that the module will expose to the parent configuration.
# modules/s3_bucket/outputs.tf
output "bucket_id" {
description = "The ID of the S3 bucket."
value = aws_s3_bucket.this.id
}
output "bucket_arn" {
description = "The ARN of the S3 bucket."
value = aws_s3_bucket.this.arn
}
output "bucket_domain_name" {
description = "The domain name of the S3 bucket."
value = aws_s3_bucket.this.bucket_domain_name
}
Step 5: Use Your Module in a Root Configuration
Now, in your main Terraform configuration (the "root" module), you can call and use your s3_bucket
module.
First, configure your AWS provider in your root main.tf
.
# my-terraform-project/main.tf
provider "aws" {
region = "us-east-1" # Or your desired AWS region
}
# Call the s3_bucket module
module "my_first_bucket" {
source = "./modules/s3_bucket" # Path to your local module directory
bucket_name = "my-unique-application-bucket-12345" # Replace with a globally unique name
acl = "private"
tags = {
Environment = "Development"
Project = "TerraformModuleDemo"
}
}
module "my_second_bucket" {
source = "./modules/s3_bucket"
bucket_name = "another-unique-app-bucket-67890" # Replace with a globally unique name
acl = "public-read" # Example of different ACL
tags = {
Environment = "Staging"
Owner = "TeamA"
}
}
output "first_bucket_arn" {
description = "ARN of the first S3 bucket."
value = module.my_first_bucket.bucket_arn
}
output "second_bucket_domain" {
description = "Domain name of the second S3 bucket."
value = module.my_second_bucket.bucket_domain_name
}
Step 6: Initialize and Apply
Navigate to your my-terraform-project
directory in your terminal and run the following commands:
Initialize Terraform: This downloads the necessary providers and processes the modules.
terraform init
Plan the changes: See what Terraform will do.
terraform plan
Apply the configuration: Create the resources.
terraform apply
Type yes
when prompted to confirm.
After a successful terraform apply
, you will see the output values for your buckets.
Example of the Most Popular Modules
Here are 5 highly-regarded and frequently used modules, with examples demonstrating how they abstract complex infrastructure provisioning:
1. AWS VPC Module (terraform-aws-modules/vpc/aws
)
This is arguably one of the most foundational and widely used Terraform modules. It simplifies the creation of a Virtual Private Cloud (VPC) with all its associated components like subnets (public, private, database), NAT gateways, internet gateways, route tables, and security groups. It dramatically reduces the boilerplate code for networking setup on AWS.
Example Usage:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.0.0" # Always pin module versions!
name = "my-application-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
public_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
private_subnets = ["10.0.10.0/24", "10.0.11.0/24", "10.0.12.0/24"]
enable_nat_gateway = true
single_nat_gateway = true
tags = {
Environment = "Dev"
Project = "MyApp"
}
}
2. AWS EKS Module (terraform-aws-modules/eks/aws
)
For deploying Amazon Elastic Kubernetes Service (EKS) clusters, this module is a go-to. It handles the provisioning of the EKS control plane, worker nodes (managed node groups or Fargate), and all necessary supporting resources like IAM roles, security groups, and networking components.
Example Usage:
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "20.0.0"
cluster_name = "my-app-eks-cluster"
cluster_version = "1.28"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
control_plane_subnet_ids = module.vpc.private_subnets # Often the same as subnet_ids
enable_irsa = true
eks_managed_node_groups = {
default = {
instance_types = ["t3.medium"]
min_size = 1
max_size = 3
desired_size = 2
}
}
}
3. Azure AKS Module (Azure/aks/azurerm
)
Similar to the EKS module for AWS, this official Azure module simplifies the deployment of Azure Kubernetes Service (AKS) clusters. It provisions the AKS cluster, node pools, virtual network, subnets, and other necessary Azure resources to get a Kubernetes environment running.
Example Usage:
module "aks" {
source = "Azure/aks/azurerm"
version = "8.1.0"
resource_group_name = azurerm_resource_group.example.name
location = azurerm_resource_group.example.location
cluster_name = "my-app-aks-cluster"
kubernetes_version = "1.28.3"
orchestrator_version = "1.28.3" # Redundant with kubernetes_version in newer versions, but good for clarity
agents_pool_name = "default"
agents_count = 2
agents_size = "Standard_DS2_v2"
network_plugin = "azure"
vnet_subnet_id = azurerm_subnet.aks_subnet.id
}
4. Google Cloud VPC Network Module (GoogleCloudPlatform/vpc-network/google
)
This official Google Cloud module provides a robust way to create and manage VPC networks, subnets, firewall rules, and VPC peering configurations on GCP. It's a fundamental building block for any GCP infrastructure.
Example Usage:
module "vpc_network" {
source = "GoogleCloudPlatform/vpc-network/google"
version = "6.1.0"
project_id = "your-gcp-project-id"
network_name = "my-app-vpc-network"
subnets = [
{
subnet_name = "app-subnet"
subnet_ip = "10.10.0.0/20"
subnet_region = "us-central1"
},
{
subnet_name = "db-subnet"
subnet_ip = "10.10.16.0/20"
subnet_region = "us-central1"
description = "Subnet for database instances"
}
]
# Example of creating firewall rules
firewall_rules = [
{
name = "allow-http-ingress"
description = "Allow HTTP ingress"
direction = "INGRESS"
ranges = ["0.0.0.0/0"]
allow = [{
protocol = "tcp"
ports = ["80", "443"]
}]
}
]
}
5. AWS S3 Bucket Module (terraform-aws-modules/s3-bucket/aws
)
While seemingly simple, configuring an S3 bucket with all its common features (versioning, logging, lifecycle rules, public access blocks, CORS, bucket policies, and notifications) can become verbose. This module streamlines S3 bucket creation, ensuring best practices for security and management.
Example Usage:
module "s3_bucket" {
source = "terraform-aws-modules/s3-bucket/aws"
version = "3.10.0"
bucket = "my-unique-app-data-bucket-12345"
acl = "private"
control_object_ownership = true
object_ownership = "BucketOwnerPreferred"
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
versioning = {
enabled = true
}
lifecycle_rule = [
{
id = "log"
enabled = true
prefix = "log/"
noncurrent_version_transition = [{
days = 30
storage_class = "STANDARD_IA"
}]
noncurrent_version_expiration = {
days = 90
}
}
]
}