Skip to content

Terraform Modules — Reusable Infrastructure Code Guide

DodaTech Updated 2026-06-24 6 min read

In this tutorial, you'll learn about Terraform Modules. 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 are used to create reusable, composable, and versioned infrastructure components across multiple projects and environments.

What You'll Learn

Why It Matters

Copy-pasting Terraform configuration across environments and projects leads to drift, inconsistencies, and maintenance nightmares. Terraform modules encapsulate infrastructure logic into reusable building blocks with defined inputs, outputs, and versioning. DodaTech uses 40+ reusable Terraform modules across AWS, GCP, and Azure, provisioning infrastructure consistently and reducing environment creation time from days to minutes.

Real-World Use

DodaZIP's cloud infrastructure is built from reusable modules — vpc, ecs-cluster, rds-postgres, redis, alb, and dns-zone. A new microservice environment is provisioned by composing these modules with environment-specific variables, all defined in a single root module.

flowchart TD
    A[Root Module] --> B[Module: vpc]
    A --> C[Module: ecs-cluster]
    A --> D[Module: rds-postgres]
    A --> E[Module: redis]
    A --> F[Module: alb]
    B --> G[AWS VPC]
    C --> H[ECS Cluster + Auto Scaling]
    D --> I[RDS PostgreSQL]
    E --> J[ElastiCache Redis]
    F --> K[Application Load Balancer]
    style A fill:#844FBA,color:#fff
â„šī¸ Info

Prerequisites: Understanding of Terraform HCL syntax, state management, and cloud provider basics.

Module Structure

terraform-modules/
  modules/
    vpc/
      main.tf          # Core resources
      variables.tf     # Input variables
      outputs.tf       # Output values
      versions.tf      # Provider & version constraints
      README.md        # Documentation
    rds-postgres/
      main.tf
      variables.tf
      outputs.tf
    ecs-service/
      main.tf
      variables.tf
      outputs.tf
  examples/
    complete-vpc/
      main.tf
      terraform.tfvars

Writing a Module

# modules/vpc/variables.tf
variable "environment" {
  description = "Environment name for resource tagging"
  type        = string
  validation {
    condition     = contains(["dev", "staging", "production"], var.environment)
    error_message = "Environment must be dev, staging, or production."
  }
}

variable "vpc_cidr" {
  description = "CIDR block for the VPC"
  type        = string
  default     = "10.0.0.0/16"
}

variable "public_subnet_cidrs" {
  description = "CIDR blocks for public subnets"
  type        = list(string)
}

variable "private_subnet_cidrs" {
  description = "CIDR blocks for private subnets"
  type        = list(string)
}

variable "availability_zones" {
  description = "List of availability zones"
  type        = list(string)
}

variable "tags" {
  description = "Additional resource tags"
  type        = map(string)
  default     = {}
}
# modules/vpc/main.tf
resource "aws_vpc" "this" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = merge({
    Name        = "dodatech-${var.environment}-vpc"
    Environment = var.environment
    ManagedBy   = "terraform"
  }, var.tags)
}

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({
    Name        = "dodatech-${var.environment}-public-${count.index + 1}"
    Environment = var.environment
    Type        = "public"
  }, var.tags)
}

resource "aws_subnet" "private" {
  count = length(var.private_subnet_cidrs)

  vpc_id                  = aws_vpc.this.id
  cidr_block              = var.private_subnet_cidrs[count.index]
  availability_zone       = var.availability_zones[count.index]
  map_public_ip_on_launch = false

  tags = merge({
    Name        = "dodatech-${var.environment}-private-${count.index + 1}"
    Environment = var.environment
    Type        = "private"
  }, var.tags)
}

resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id

  tags = merge({
    Name        = "dodatech-${var.environment}-igw"
    Environment = var.environment
  }, var.tags)
}

resource "aws_eip" "nat" {
  count  = length(var.public_subnet_cidrs)
  domain = "vpc"

  tags = merge({
    Name        = "dodatech-${var.environment}-nat-${count.index + 1}"
    Environment = var.environment
  }, var.tags)
}

resource "aws_nat_gateway" "this" {
  count = length(var.public_subnet_cidrs)

  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[count.index].id

  tags = merge({
    Name        = "dodatech-${var.environment}-nat-${count.index + 1}"
    Environment = var.environment
  }, var.tags)
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.this.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.this.id
  }

  tags = merge({
    Name        = "dodatech-${var.environment}-public-rt"
    Environment = var.environment
  }, var.tags)
}

resource "aws_route_table_association" "public" {
  count          = length(var.public_subnet_cidrs)
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table" "private" {
  count  = length(var.private_subnet_cidrs)
  vpc_id = aws_vpc.this.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.this[count.index].id
  }

  tags = merge({
    Name        = "dodatech-${var.environment}-private-rt-${count.index + 1}"
    Environment = var.environment
  }, var.tags)
}

resource "aws_route_table_association" "private" {
  count          = length(var.private_subnet_cidrs)
  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private[count.index].id
}
# modules/vpc/outputs.tf
output "vpc_id" {
  description = "The ID of the VPC"
  value       = aws_vpc.this.id
}

output "public_subnet_ids" {
  description = "IDs of the public subnets"
  value       = aws_subnet.public[*].id
}

output "private_subnet_ids" {
  description = "IDs of the private subnets"
  value       = aws_subnet.private[*].id
}

output "nat_gateway_ips" {
  description = "Elastic IPs of the NAT gateways"
  value       = aws_eip.nat[*].public_ip
}

Using Modules

# root/main.tf
module "vpc" {
  source = "git::https://github.com/dodatech/terraform-modules.git//modules/vpc?ref=v1.4.0"

  environment         = "production"
  vpc_cidr           = "10.0.0.0/16"
  availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]

  public_subnet_cidrs  = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  private_subnet_cidrs = ["10.0.10.0/24", "10.0.11.0/24", "10.0.12.0/24"]

  tags = {
    Project    = "dodazip"
    CostCenter = "platform-engineering"
  }
}

module "rds" {
  source = "git::https://github.com/dodatech/terraform-modules.git//modules/rds-postgres?ref=v2.1.0"

  environment      = "production"
  vpc_id          = module.vpc.vpc_id
  subnet_ids      = module.vpc.private_subnet_ids
  instance_class  = "db.r6g.large"
  allocated_storage = 200
  engine_version  = "16.3"
  database_name   = "dodazip"
  master_username = "dodazip_admin"

  backup_retention_period = 30
  deletion_protection     = true
  multi_az               = true
}

# Use outputs
output "vpc_id" {
  value = module.vpc.vpc_id
}

output "database_endpoint" {
  value = module.rds.endpoint
}

Module Versioning with Terraform Registry

terraform {
  required_version = ">= 1.7.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

# Public module from registry
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.8.1"

  name = "dodatech-production"
  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"
    ManagedBy   = "terraform"
  }
}

Module Composition Pattern

# modules/ecs-service/variables.tf
variable "service_name" {
  type = string
}

variable "environment" {
  type = string
}

variable "vpc_id" {
  type = string
}

variable "subnet_ids" {
  type = list(string)
}

variable "container_image" {
  type = string
}

variable "container_port" {
  type    = number
  default = 8080
}

variable "desired_count" {
  type    = number
  default = 2
}
# root/compose-service.tf
module "user_service" {
  source = "./modules/ecs-service"

  service_name   = "user-service"
  environment    = "production"
  vpc_id         = module.vpc.vpc_id
  subnet_ids     = module.vpc.private_subnet_ids
  container_image = "registry.dodatech.com/user-service:latest"
  container_port  = 8080
  desired_count   = 3
}

module "auth_service" {
  source = "./modules/ecs-service"

  service_name   = "auth-service"
  environment    = "production"
  vpc_id         = module.vpc.vpc_id
  subnet_ids     = module.vpc.private_subnet_ids
  container_image = "registry.dodatech.com/auth-service:latest"
  container_port  = 8081
  desired_count   = 2
}

Common Configuration Mistakes

  1. Not pinning module versions: Using source = "./modules/vpc" without a version tag means any change to the module affects all consuming projects. Pin to Git tags or registry versions with ref=v1.0.0.

  2. Missing variable validation: Without validation blocks in variables.tf, incorrect values (e.g., invalid instance types) only fail during apply. Validate early to fail fast.

  3. Hardcoding provider regions in modules: Modules should accept region as a variable, not hardcode it. This allows the same module to deploy to any region.

  4. Ignoring output preconditions: Module consumers rely on correct outputs. Add precondition blocks to validate that outputs have expected values before returning.

  5. Not splitting state for large modules: A single massive root module slows down every plan and apply. Break infrastructure into separate state files per concern (networking, databases, compute).

Practice Questions

  1. What is a Terraform module and why use it? Answer: A module is a directory of Terraform configuration files defining related infrastructure. Modules promote reusability, consistency, and versioning across projects.

  2. How do you reference a module from a Git Repository? Answer: Use source = "git::https://github.com/org/repo.git//path?ref=v1.0.0". The //path selects a subdirectory, ?ref pins a version tag.

  3. What is the difference between root modules and child modules? Answer: The root module is the working directory where <a href="/devops/terraform/">terraform</a> apply runs. Child modules are referenced via module blocks from the root or other modules.

  4. How do you share outputs between modules? Answer: Use module.module_name.output_name to reference a child module's output from another module in the same root configuration.

Challenge

Create a Terraform module for a complete microservice deployment on AWS ECS Fargate: VPC with public/private subnets, ALB with path-based routing, ECS cluster with auto-scaling, RDS PostgreSQL with read replicas, ElastiCache Redis, and Route53 DNS. Write input variables with validation, output all resource identifiers, pin provider versions, and create an example root module that provisions two Microservices.

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro