Terraform Modules and Reusable Infrastructure â Complete Guide with Best Practices
In this tutorial, you'll learn about Terraform Modules and Reusable Infrastructure. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Terraform modules are self-contained packages of Terraform configuration that encapsulate related infrastructure resources, enabling teams to reuse, compose, and version infrastructure across multiple environments and projects.
What You'll Learn
Why It Matters
Copy-pasting Terraform code across projects leads to configuration drift, security inconsistencies, and maintenance nightmares. When a security group rule needs updating, you either miss 10 of 50 copy-pasted instances or spend days auditing. Modules solve this by defining infrastructure once and referencing it with different inputs. A single module update propagates to all consumers with a version bump.
Real-World Use
DodaTech maintains a library of 30+ Terraform modules for AWS infrastructure (VPC, EKS, RDS, S3, IAM) shared across teams. The Durga Antivirus Pro backend uses the eks-cluster module with environment = "production", referencing version 4.2.0 from a private registry. Upgrading Kubernetes versions is a single version change in the module source.
flowchart TD
A["Root Module: production"] --> B["Module: vpc v2.1.0"]
A --> C["Module: eks v4.2.0"]
A --> D["Module: rds v3.0.0"]
A --> E["Module: s3 v1.5.0"]
B --> F["AWS: VPC, Subnets, Routes"]
C --> G["AWS: EKS Cluster, Node Groups"]
D --> H["AWS: RDS Instance, Subnet Group"]
E --> I["AWS: S3 Bucket, Policies"]
J["Terraform Registry"] --> B
J --> C
J --> D
J --> E
K["module.vpc.outputs.vpc_id"] --> L["module.eks.inputs.vpc_id"]
K --> M["module.rds.inputs.subnet_ids"]
style A fill:#7B42BC,color:#fff
style B fill:#326CE5,color:#fff
style C fill:#326CE5,color:#fff
style D fill:#326CE5,color:#fff
Prerequisites: Basic Terraform knowledge (resources, variables, state), AWS or cloud provider account, and Terraform 1.5+ installed.
Module Structure
A standard module follows the recommended directory structure with well-defined inputs and outputs.
terraform-aws-vpc/
README.md # Documentation with examples
LICENSE # License file (MIT, Apache 2.0)
main.tf # Primary resources
variables.tf # Input variable declarations
outputs.tf # Output value declarations
versions.tf # Terraform and provider version constraints
examples/
basic/ # Simple usage example
main.tf
variables.tf
outputs.tf
multi-az/ # Advanced usage example
main.tf
tests/
basic_test.go # Terratest integration tests
# variables.tf
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
validation {
condition = can(cidrhost(var.vpc_cidr, 0))
error_message = "Must be a valid CIDR block."
}
}
variable "environment" {
description = "Environment name (dev, staging, production)"
type = string
validation {
condition = contains(["dev", "staging", "production"], var.environment)
error_message = "Environment must be dev, staging, or production."
}
}
variable "enable_nat_gateway" {
description = "Whether to deploy a NAT Gateway"
type = bool
default = true
}
variable "tags" {
description = "Resource tags"
type = map(string)
default = {}
}
Expected behavior: Variables enforce type constraints and validation rules. The vpc_cidr validation ensures the input is a valid CIDR notation. The environment variable restricts inputs to a controlled set of allowed values, preventing typos like "produktion".
# main.tf
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = merge(var.tags, {
Name = "${var.environment}-vpc"
Environment = var.environment
})
}
resource "aws_subnet" "public" {
count = length(var.public_subnet_cidrs)
vpc_id = aws_vpc.this.id
cidr_block = var.public_subnet_cidrs[count.index]
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = merge(var.tags, {
Name = "${var.environment}-public-${count.index + 1}"
Environment = var.environment
Tier = "public"
})
}
resource "aws_eip" "nat" {
count = var.enable_nat_gateway ? 1 : 0
domain = "vpc"
}
resource "aws_nat_gateway" "this" {
count = var.enable_nat_gateway ? 1 : 0
allocation_id = aws_eip.nat[0].id
subnet_id = aws_subnet.public[0].id
tags = merge(var.tags, {
Name = "${var.environment}-nat"
Environment = var.environment
})
}
| Output Name | Description | Value |
|---|---|---|
| vpc_id | The VPC ID | aws_vpc.this.id |
| public_subnet_ids | Public subnet IDs | aws_subnet.public[*].id |
| private_subnet_ids | Private subnet IDs | aws_subnet.private[*].id |
| nat_gateway_ips | NAT Gateway EIPs | aws_eip.nat[*].public_ip |
Using Modules with Version Constraints
# production/main.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "production-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
enable_dns_hostnames = true
tags = {
Environment = "production"
Terraform = "true"
}
}
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "~> 19.0"
cluster_name = "production-eks"
cluster_version = "1.28"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
node_groups = {
main = {
desired_capacity = 3
max_capacity = 20
min_capacity = 2
instance_types = ["t3.medium"]
}
}
tags = {
Environment = "production"
}
}
Expected behavior: Terraform fetches module versions 5.x for VPC and 19.x for EKS from the Terraform Registry. Module outputs from module.vpc feed into module.eks. The ~> constraint allows patch version updates (5.0.1, 5.0.2) but blocks major or minor version upgrades (5.1.0, 6.0.0).
Remote State with Terraform Cloud
Store state remotely to enable team collaboration and state locking.
# production/versions.tf
terraform {
backend "s3" {
bucket = "dodatech-terraform-state"
key = "production/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-state-lock"
}
}
# Initialize with remote state
terraform init
# Expected output:
# Initializing the backend...
# Successfully configured the backend "s3"!
# Terraform has been successfully initialized!
# Plan with remote state
terraform plan -out=tfplan
# Apply
terraform apply tfplan
Module Testing with Terratest
// tests/vpc_test.go
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestVPCCreation(t *testing.T) {
terraformOptions := &terraform.Options{
TerraformDir: "../examples/basic",
Vars: map[string]interface{}{
"vpc_cidr": "10.0.0.0/16",
"environment": "test",
},
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
vpcID := terraform.Output(t, terraformOptions, "vpc_id")
assert.NotEmpty(t, vpcID, "VPC ID should not be empty")
}
Common Errors
Hard-coding values inside modules instead of using variables: A module with hard-coded values is not reusable. Every configurable value -- CIDR blocks, instance types, tags, feature flags -- must be an input variable with a sensible default.
Not pinning module versions: Using
source = "git::https://...//modules/vpc?ref=main"without a version tag causes every<a href="/devops/terraform/">Terraform</a> initto potentially pull different code. Always pin to a specific Git tag, release version, or semver constraint.Circular module dependencies: Module A references output from Module B, and Module B references output from Module A. Terraform cannot resolve this graph. Extract the common dependency into a third module that both A and B consume.
Exposing sensitive data in module outputs: Module outputs are stored in state in plaintext. If a module outputs
db_master_password, that password is visible in the state file. Usesensitive = truein output declarations and avoid outputting secrets altogether.Module sprawl without a registry Strategy: Without a module registry (public, private, or Git-based), teams lose visibility into available modules and create duplicates. Establish a registry Strategy early, whether using Terraform Cloud's private registry, a simple Git Repository convention, or a dedicated module registry tool.
Practice Questions
What is the difference between a root module and a child module? Answer: A root module is the working directory where
<a href="/devops/terraform/">Terraform</a> applyis executed. It has a backend configuration. A child module is called from a root module using amoduleblock. Child modules cannot have their own backend configuration.How does the
sourceargument work in a module block? Answer: Thesourcetells Terraform where to find the module's configuration. Supported sources include: local paths (./modules/vpc), Terraform Registry (<a href="/devops/terraform/">Terraform</a>-aws-modules/vpc/aws), GitHub (github.com/org/repo//path), Git HTTPS, S3, and HTTP URLs.Why should modules have version constraints? Answer: Version constraints prevent accidental upgrades that could introduce breaking changes. A
~> 5.0constraint allows patch releases (5.0.1, 5.0.2) but blocks 5.1.0 (may include breaking changes) and 6.0.0 (definitely breaking).What is the purpose of
countandfor_eachin modules? Answer:countandfor_eachallow a single module block to create multiple instances of the module's resources.countworks with a numeric index.for_eachworks with a map or set, providing more meaningful keys. Both enable dynamic infrastructure creation based on variable input.
Challenge
Refactor a monolithic Terraform configuration for a web application (VPC, EKS, RDS, S3) into reusable modules. Create three modules: networking (VPC, subnets, NAT), compute (EKS cluster, node groups), and storage (RDS, S3 buckets). Each module should have typed variables with validation, comprehensive outputs, and sensible defaults. Create root modules for dev, staging, and production environments that consume these modules with different inputs. Add Terratest tests for the networking module. Publish the modules to a private Git Repository with semantic versioning tags.
Mini Project
Build a complete reusable infrastructure module library: write a <a href="/devops/terraform/">Terraform</a>-aws-ecs-service module that takes a container definition, target group ARN, and security group IDs as inputs and creates an ECS service with Fargate launch type, auto-scaling, and service discovery. Write a <a href="/devops/terraform/">Terraform</a>-aws-rds-cluster module for Aurora with read replicas, automated backups, and enhanced monitoring. Create root modules for three environments consuming these modules. Write Terratest integration tests for both modules. Set up a CI/CD pipeline that runs <a href="/devops/terraform/">Terraform</a> fmt, <a href="/devops/terraform/">Terraform</a> validate, and tflint on every Pull Request, and publishes new module versions on merge to main.
Related Resources
| Resource | Description |
|---|---|
| Terraform Basics | Core Terraform concepts |
| Infrastructure as Code | IaC best practices |
| GitOps Workflow | Managing IaC with GitOps |
| CI/CD Pipelines | Automating Terraform in CI/CD |
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro