How to Use Terraform Modules
How to Use Terraform Modules Terraform modules are one of the most powerful and essential features of HashiCorp’s infrastructure-as-code (IaC) tool. They allow you to encapsulate, reuse, and share Terraform configurations across multiple projects and environments—making your infrastructure more maintainable, scalable, and consistent. Whether you're managing a single AWS VPC or orchestrating a glob
How to Use Terraform Modules
Terraform modules are one of the most powerful and essential features of HashiCorps infrastructure-as-code (IaC) tool. They allow you to encapsulate, reuse, and share Terraform configurations across multiple projects and environmentsmaking your infrastructure more maintainable, scalable, and consistent. Whether you're managing a single AWS VPC or orchestrating a global multi-cloud deployment, Terraform modules help you avoid duplication, reduce errors, and accelerate deployment cycles. This comprehensive guide walks you through everything you need to know to effectively use Terraform modules, from basic syntax to enterprise-grade best practices. By the end, youll be equipped to build modular, production-ready infrastructure with confidence.
Step-by-Step Guide
Understanding Terraform Modules
A Terraform module is a container for multiple resources that are used together. Think of it as a function in programming: you define inputs (arguments), process them with a set of resources, and return outputs. Modules promote reusability and abstractioninstead of writing the same VPC, security group, or database configuration in ten different files, you write it once in a module and call it wherever needed.
Modules can be sourced from local directories, remote repositories (like GitHub), or the Terraform Registry. The Terraform Registry hosts thousands of community and official modules for cloud providers like AWS, Azure, Google Cloud, and more. Using these modules saves time and leverages community-tested patterns.
Creating Your First Module
To create a module, start by organizing your Terraform code into a dedicated directory. For example, create a folder named modules/vpc in your project root.
Inside modules/vpc, create three files:
main.tfContains the resource definitionsvariables.tfDeclares input variablesoutputs.tfDefines what values the module returns
Heres a minimal VPC module example:
modules/vpc/variables.tf
variable "cidr_block" {
description = "The CIDR block for the VPC"
type = string
}
variable "availability_zones" {
description = "List of availability zones"
type = list(string)
}
variable "create_internet_gateway" {
description = "Whether to create an internet gateway"
type = bool
default = true
}
modules/vpc/main.tf
resource "aws_vpc" "main" {
cidr_block = var.cidr_block
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "main-vpc"
}
}
resource "aws_internet_gateway" "igw" {
count = var.create_internet_gateway ? 1 : 0
vpc_id = aws_vpc.main.id
tags = {
Name = "main-igw"
}
}
modules/vpc/outputs.tf
output "vpc_id" {
value = aws_vpc.main.id
}
output "internet_gateway_id" {
value = length(aws_internet_gateway.igw) > 0 ? aws_internet_gateway.igw[0].id : null
}
This module accepts a CIDR block, a list of availability zones (though not yet used), and a boolean flag to optionally create an internet gateway. It outputs the VPC ID and, if created, the internet gateway ID.
Calling the Module from a Root Configuration
Now, in your root Terraform directory (e.g., environments/prod), create a main.tf file that references the module:
environments/prod/main.tf
provider "aws" {
region = "us-east-1"
}
module "vpc" {
source = "../modules/vpc"
cidr_block = "10.0.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
create_internet_gateway = true
}
output "vpc_id" {
value = module.vpc.vpc_id
}
When you run terraform init, Terraform automatically downloads and initializes the local module. Then, terraform plan and terraform apply will create the VPC and optional internet gateway as defined in the module.
Using Remote Modules
Instead of maintaining modules locally, you can reference modules hosted remotely. The Terraform Registry is the most common source. For example, to use the official AWS VPC module:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "3.18.0"
name = "my-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
enable_nat_gateway = true
enable_vpn_gateway = false
}
Using remote modules from the Terraform Registry ensures you benefit from versioning, community testing, and automatic updates. Always specify a version to avoid unexpected changes in your infrastructure.
Module Versioning and Source Control
Modules should be versioned using Git tags or semantic versioning (e.g., v1.0.0, v2.1.3). When using a Git repository as a source, you can specify the version like this:
module "vpc" {
source = "git::https://github.com/yourcompany/terraform-modules.git//modules/vpc?ref=v1.2.0"
}
The //modules/vpc syntax tells Terraform to use the subdirectory within the repo. The ?ref=v1.2.0 ensures youre pinned to a specific commit or tag. This prevents breaking changes from being pulled in automatically.
Always treat modules like software libraries: version them, document them, and test them independently before integrating into production environments.
Organizing Module Structure
As your infrastructure grows, youll need a scalable module structure. A recommended approach is:
- modules/ Root directory for all reusable modules
- environments/ Separate directories for dev, staging, prod
- modules/networking/ VPC, subnets, route tables
- modules/compute/ EC2, ECS, Lambda
- modules/database/ RDS, DynamoDB, ElastiCache
- modules/security/ IAM roles, security groups, KMS
This separation ensures that each module has a single responsibility and can be independently tested, versioned, and reused.
Testing Modules
Modules should be tested just like application code. Use tools like Terratest (Go-based) or terraform-compliance (Python-based) to write automated tests that verify module behavior.
For example, with Terratest, you can write a test that:
- Applies the module
- Checks if the VPC was created with the correct CIDR
- Verifies that the internet gateway exists when enabled
- Destroys the infrastructure after testing
Testing ensures that changes to a module dont break dependent configurations. Integrate these tests into your CI/CD pipeline to enforce quality.
Managing Module Dependencies
Modules can depend on other modules. For example, a modules/ecs-cluster might depend on a modules/networking module to get subnet IDs. This is handled naturally through output references:
modules/ecs-cluster/main.tf
resource "aws_ecs_cluster" "main" {
name = "my-ecs-cluster"
}
resource "aws_ecs_service" "app" {
name = "app-service"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.app.arn
desired_count = 2
network_configuration {
subnets = var.subnet_ids
security_groups = var.security_group_ids
assign_public_ip = false
}
}
modules/ecs-cluster/variables.tf
variable "subnet_ids" {
description = "List of subnet IDs from the networking module"
type = list(string)
}
variable "security_group_ids" {
description = "List of security group IDs from the networking module"
type = list(string)
}
In the root configuration:
module "networking" {
source = "../modules/networking"
... inputs
}
module "ecs_cluster" {
source = "../modules/ecs-cluster"
subnet_ids = module.networking.private_subnets
security_group_ids = module.networking.ecs_security_group_ids
}
This chaining of modules allows you to build complex infrastructure hierarchies without duplication.
Best Practices
1. Use Version Control for All Modules
Never store modules in unversioned local paths if theyre shared across teams or environments. Always use Git repositories with semantic versioning. This enables traceability, rollbacks, and collaboration. Use tags like v1.0.0 or v2.1.3 to indicate stable releases.
2. Define Clear Inputs and Outputs
Every module should have well-documented input variables and outputs. Use descriptive names and include description fields in your variables.tf and outputs.tf files. This makes modules self-documenting and easier to use by others.
Example:
variable "instance_type" {
description = "The EC2 instance type (e.g., t3.micro, m5.large). Must be compatible with the AMI."
type = string
validation {
condition = contains(["t3.micro", "t3.small", "m5.large"], var.instance_type)
error_message = "Invalid instance type. Must be one of: t3.micro, t3.small, m5.large."
}
}
Use validation blocks to enforce constraints at plan time, reducing runtime errors.
3. Avoid Hardcoding Values
Never hardcode region names, AMI IDs, or subnet ranges inside modules. Always pass them as variables. This makes modules portable across environments and cloud regions.
4. Use Default Values Wisely
Provide sensible defaults for optional variables, but avoid overloading them. For example, defaulting enable_monitoring to true is helpful, but defaulting instance_type to t3.micro may lead to performance issues in production. Use defaults to simplify common cases, not to mask poor design.
5. Keep Modules Small and Focused
Follow the Single Responsibility Principle: each module should do one thing well. A module for EC2 instances should not also manage load balancers or DNS records. Split complex configurations into smaller modules and compose them in the root configuration.
6. Document Your Modules
Include a README.md in every module directory. Document:
- What the module does
- Required and optional inputs
- Outputs provided
- Example usage
- Known limitations
- Dependencies
Good documentation reduces onboarding time and prevents misuse.
7. Use Local Modules for Development, Remote for Production
During development, use local paths (source = "../modules/vpc") for faster iteration. Once stable, switch to remote sources (Terraform Registry or Git tags) to ensure consistency across environments and teams.
8. Avoid Circular Dependencies
Modules should not reference each other in a loop. For example, Module A uses Module B, and Module B uses Module A. This creates a circular dependency that Terraform cannot resolve. Restructure your architecture to break the cycleoften by introducing a third module that both depend on.
9. Use Terraform Workspaces or Environments for State Isolation
Each environment (dev, staging, prod) should have its own state file. Use Terraform workspaces or separate directories to isolate state. Never share state across environments. Modules should be environment-agnostic; the environment-specific configuration belongs in the root.
10. Audit and Update Modules Regularly
Modules evolve. Regularly check for new versions of remote modules you use. Use tools like tfupdate or terragrunt to automate version updates. Always test updated modules in a non-production environment before deploying.
Tools and Resources
Terraform Registry
The Terraform Registry is the official source for verified, community-maintained modules. It includes modules for AWS, Azure, GCP, Kubernetes, and more. Filter by provider, popularity, and version. Always prefer modules with high download counts, recent updates, and clear documentation.
Terratest
Terratest is a Go library that helps you write automated tests for infrastructure code. It integrates with Terraform and supports testing across multiple cloud providers. Use it to validate that modules create the correct resources, apply security policies, and behave as expected under different inputs.
tfsec
tfsec is a static analysis tool that scans Terraform code for security misconfigurations. It can be used to validate modules against best practices such as open security groups or unencrypted S3 buckets. Integrate tfsec into your CI pipeline to catch issues before deployment.
checkov
Checkov by Bridgecrew is another popular IaC scanning tool. It supports Terraform, CloudFormation, and Kubernetes manifests. Checkov has hundreds of built-in policies and allows custom policy creation. Its excellent for enforcing compliance across modules.
Terraform Lint
terraform-lint (or terraform validate) checks your code for syntax errors and structural issues. Always run terraform validate before committing modules. Use terraform fmt to ensure consistent formatting across your team.
Atlantis
Atlantis is a CI/CD tool that automates Terraform workflows in GitHub, GitLab, or Bitbucket. It automatically runs plan on pull requests and allows team members to approve and apply changes with comments. Ideal for teams using modules across multiple repositories.
HashiCorp Learn
HashiCorp Learn offers free, hands-on tutorials on modules, providers, and advanced Terraform patterns. Its an excellent resource for beginners and experienced users alike.
GitHub Repositories
Explore popular Terraform module repositories:
- terraform-aws-modules Comprehensive AWS modules
- terraform-google-modules Official Google Cloud modules
- terraform-azure-modules Azure infrastructure modules
These repositories are maintained by HashiCorp and the community and serve as excellent examples of well-structured, production-ready modules.
Visual Studio Code Extensions
Install the official Terraform extension for VS Code. It provides syntax highlighting, auto-completion, linting, and module navigation. It also helps detect invalid variable references and missing dependencies in real time.
Real Examples
Example 1: Multi-Tier Web Application
Imagine you need to deploy a web application with a public-facing load balancer, private web servers, and a private RDS database. You can structure this using three modules:
modules/networkingCreates VPC, public/private subnets, route tablesmodules/webCreates Auto Scaling Group, ALB, security groupsmodules/databaseCreates RDS instance, parameter group, snapshot
Root configuration (environments/staging/main.tf):
provider "aws" {
region = "us-west-2"
}
module "networking" {
source = "../modules/networking"
name = "staging-vpc"
cidr = "10.10.0.0/16"
public_subnets = ["10.10.1.0/24", "10.10.2.0/24"]
private_subnets = ["10.10.11.0/24", "10.10.12.0/24"]
}
module "web" {
source = "../modules/web"
vpc_id = module.networking.vpc_id
public_subnets = module.networking.public_subnets
private_subnets = module.networking.private_subnets
instance_type = "t3.small"
desired_capacity = 2
min_capacity = 1
max_capacity = 5
}
module "database" {
source = "../modules/database"
vpc_id = module.networking.vpc_id
private_subnets = module.networking.private_subnets
db_instance_class = "db.t3.micro"
allocated_storage = 20
engine_version = "13.5"
username = "app_user"
password = "secure-password-123"
}
This structure allows you to:
- Reuse the same
networkingmodule for multiple applications - Swap out the
webmodule for a different compute platform (e.g., ECS) - Test the database module independently with different engine versions
Example 2: Kubernetes Cluster with EKS
Deploying a managed Kubernetes cluster on AWS using EKS requires multiple components: VPC, IAM roles, node groups, and cluster configuration. The official terraform-aws-modules/eks/aws module abstracts all of this complexity.
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "19.14.0"
cluster_name = "my-prod-cluster"
cluster_version = "1.27"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
eks_managed_node_groups = {
ng1 = {
desired_capacity = 3
max_capacity = 5
min_capacity = 2
instance_types = ["t3.medium"]
disk_size = 50
}
}
write_kubeconfig = false
}
This single module call creates:
- An EKS control plane
- Node groups with Auto Scaling
- Required IAM roles and policies
- Network policies for worker nodes
Without this module, youd need to write over 200 lines of Terraform code manually. The module reduces complexity and ensures compliance with AWS best practices.
Example 3: Secure Bastion Host Module
Many organizations require a bastion host for SSH access to private instances. Heres a secure, reusable module:
modules/bastion/variables.tf
variable "vpc_id" {
description = "VPC ID where the bastion will be deployed"
type = string
}
variable "subnet_id" {
description = "Public subnet ID for the bastion"
type = string
}
variable "allowed_cidr_blocks" {
description = "List of CIDR blocks allowed to SSH into the bastion"
type = list(string)
default = ["0.0.0.0/0"]
}
variable "key_name" {
description = "EC2 key pair name for SSH access"
type = string
}
modules/bastion/main.tf
resource "aws_security_group" "bastion" {
name = "bastion-sg"
description = "Allow SSH from specific IPs"
vpc_id = var.vpc_id
ingress {
description = "SSH"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = var.allowed_cidr_blocks
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "bastion-security-group"
}
}
resource "aws_instance" "bastion" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
key_name = var.key_name
subnet_id = var.subnet_id
security_groups = [aws_security_group.bastion.id]
associate_public_ip_address = true
tags = {
Name = "bastion-host"
}
}
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"]
Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
}
This module can be called from any environment:
module "bastion" {
source = "../modules/bastion"
vpc_id = module.networking.vpc_id
subnet_id = module.networking.public_subnets[0]
allowed_cidr_blocks = ["203.0.113.0/24", "198.51.100.0/24"]
key_name = "prod-keypair"
}
By abstracting this into a module, you ensure every bastion host follows the same security standardsno more manual configuration drift.
FAQs
What is the difference between a Terraform module and a provider?
A provider is a plugin that Terraform uses to interact with an APIlike AWS, Azure, or Google Cloud. It defines the resources and data sources available. A module is a reusable collection of Terraform configurations that use providers to create infrastructure. Think of providers as the tools, and modules as the blueprints built with those tools.
Can I use modules across different cloud providers?
Yes, but you need to design them carefully. A module can include resources from multiple providers (e.g., AWS and Cloudflare), but its often better to keep them separate. For example, create a modules/aws-vpc and a modules/cloudflare-dns module, then compose them in your root configuration. Mixing providers in a single module can reduce reusability and increase complexity.
How do I update a module without breaking my infrastructure?
Always use versioned modules (e.g., source = "git::https://...?ref=v1.2.0"). When a new version is released, test it in a staging environment first. Run terraform plan to see what changes will be made. If the plan shows destructive changes (e.g., replacing resources), evaluate whether the update is safe. Use tools like tfupdate to automate version bumps and CI/CD pipelines to validate changes before production.
Can I use modules with Terraform Cloud or Terraform Enterprise?
Yes. Terraform Cloud and Enterprise support both local and remote modules. You can store modules in private Git repositories and reference them via HTTPS or SSH. Terraform Cloud also provides a private module registry for enterprise teams to share internal modules securely.
Do modules affect Terraform state?
Modules do not create separate state files. All resources created by a module are tracked in the root state file. However, the state reflects the modules structure. For example, a resource inside a module will appear in state as module.vpc.aws_vpc.main. This helps you trace resource ownership but means you cannot isolate state per module.
How do I test a module without applying it to real infrastructure?
Use Terratest or other IaC testing frameworks to simulate deployment. You can also use terraform plan to preview changes without applying them. For local modules, run terraform init and terraform plan in the module directory itself to validate syntax and variable usage.
What happens if a module Im using is deleted from the registry?
If youre using a versioned remote module (e.g., source = "terraform-aws-modules/vpc/aws v3.18.0"), Terraform caches the module locally. Even if the module is removed from the registry, your existing state will continue to work. However, future terraform init runs on new machines may fail. To avoid this, always mirror critical modules in your own private Git repository.
Can modules contain data sources?
Yes. Modules can use data sources to fetch information from the cloud provider (e.g., data "aws_ami", data "aws_subnet"). This is common in modules that need to reference existing resources, such as finding a VPC by tag or retrieving an existing IAM role.
How do I handle secrets in modules?
Never hardcode secrets (passwords, API keys) in modules. Pass them as input variables using Terraforms sensitive flag:
variable "db_password" {
description = "Database password"
type = string
sensitive = true
}
Use external secret managers like AWS Secrets Manager, HashiCorp Vault, or Azure Key Vault to retrieve secrets at runtime, and pass the ARN or name as a variable to the module.
Conclusion
Terraform modules are not just a conveniencethey are a necessity for any organization serious about infrastructure scalability, consistency, and maintainability. By abstracting repetitive configurations into reusable components, you reduce human error, accelerate deployment cycles, and enforce best practices across teams and environments. This guide has walked you through creating, using, testing, and securing modulesfrom basic syntax to enterprise-grade patterns.
Remember: the goal of modules is not to write less code, but to write better, more reliable, and more maintainable infrastructure. Treat your modules like production software: version them, document them, test them, and update them responsibly. Use the Terraform Registry, leverage community modules where appropriate, and build your own when needed.
As you scale your infrastructure, modular design will become your most powerful ally. Start smallrefactor one repetitive resource into a module today. Tomorrow, youll be building entire cloud architectures with confidence, clarity, and control.