TerraformPilot

DevOps

Terraform Modules: How to Write, Use, and Publish Reusable Infrastructure

Write reusable Terraform modules with proper structure, inputs, outputs, and versioning. Covers local modules, registry modules, module composition

LLuca Berton1 min read

What Is a Terraform Module?

#

A module is a directory containing .tf files. That's it. Every Terraform project is already a module (the "root module"). When you call one module from another, it becomes a "child module."

project/
├── main.tf          ← Root module
├── variables.tf
├── outputs.tf
└── modules/
    └── vpc/         ← Child module
        ├── main.tf
        ├── variables.tf
        └── outputs.tf

Modules let you write infrastructure once and reuse it across projects, environments, and teams.

Using a Module

#

Local Module

#
module "vpc" {
  source = "./modules/vpc"
 
  vpc_cidr     = "10.0.0.0/16"
  environment  = "production"
  project_name = "myapp"
}
 
# Use module outputs
resource "aws_instance" "web" {
  subnet_id = module.vpc.public_subnet_ids[0]
}

Registry Module

#
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"
 
  name = "my-vpc"
  cidr = "10.0.0.0/16"
 
  azs             = ["us-east-1a", "us-east-1b"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24"]
 
  enable_nat_gateway = true
}

Git Module

#
module "vpc" {
  source = "git::https://gitlab.com/myorg/terraform-modules.git//vpc?ref=v1.2.0"
 
  vpc_cidr = "10.0.0.0/16"
}

Writing a Module: Complete Example

#

Module Structure

#
modules/vpc/
├── main.tf          # Resources
├── variables.tf     # Inputs
├── outputs.tf       # Outputs
├── versions.tf      # Provider requirements
└── README.md        # Documentation

variables.tf — Module Inputs

#
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, prod)"
  type        = string
 
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Must be dev, staging, or prod."
  }
}
 
variable "public_subnet_count" {
  description = "Number of public subnets"
  type        = number
  default     = 2
}
 
variable "private_subnet_count" {
  description = "Number of private subnets"
  type        = number
  default     = 2
}
 
variable "enable_nat_gateway" {
  description = "Enable NAT Gateway for private subnets"
  type        = bool
  default     = true
}
 
variable "tags" {
  description = "Additional tags for all resources"
  type        = map(string)
  default     = {}
}

main.tf — Resources

#
data "aws_availability_zones" "available" {
  state = "available"
}
 
locals {
  azs = slice(data.aws_availability_zones.available.names, 0, max(var.public_subnet_count, var.private_subnet_count))
  common_tags = merge(var.tags, {
    Environment = var.environment
    ManagedBy   = "terraform"
  })
}
 
resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true
 
  tags = merge(local.common_tags, {
    Name = "${var.environment}-vpc"
  })
}
 
resource "aws_subnet" "public" {
  count                   = var.public_subnet_count
  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(var.vpc_cidr, 8, count.index)
  availability_zone       = local.azs[count.index % length(local.azs)]
  map_public_ip_on_launch = true
 
  tags = merge(local.common_tags, {
    Name = "${var.environment}-public-${local.azs[count.index % length(local.azs)]}"
    Tier = "public"
  })
}
 
resource "aws_subnet" "private" {
  count             = var.private_subnet_count
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 8, count.index + 100)
  availability_zone = local.azs[count.index % length(local.azs)]
 
  tags = merge(local.common_tags, {
    Name = "${var.environment}-private-${local.azs[count.index % length(local.azs)]}"
    Tier = "private"
  })
}
 
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
 
  tags = merge(local.common_tags, {
    Name = "${var.environment}-igw"
  })
}

outputs.tf — Module Outputs

#
output "vpc_id" {
  description = "ID of the VPC"
  value       = aws_vpc.main.id
}
 
output "vpc_cidr" {
  description = "CIDR block of the VPC"
  value       = aws_vpc.main.cidr_block
}
 
output "public_subnet_ids" {
  description = "List of public subnet IDs"
  value       = aws_subnet.public[*].id
}
 
output "private_subnet_ids" {
  description = "List of private subnet IDs"
  value       = aws_subnet.private[*].id
}

versions.tf — Provider Requirements

#
terraform {
  required_version = ">= 1.5"
 
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.0"
    }
  }
}

Module Composition: Building from Smaller Modules

#
# Root module composes multiple child modules
module "vpc" {
  source      = "./modules/vpc"
  vpc_cidr    = "10.0.0.0/16"
  environment = "prod"
}
 
module "database" {
  source    = "./modules/rds"
  vpc_id    = module.vpc.vpc_id
  subnet_ids = module.vpc.private_subnet_ids
  engine    = "postgresql"
}
 
module "app" {
  source        = "./modules/ecs-service"
  vpc_id        = module.vpc.vpc_id
  subnet_ids    = module.vpc.private_subnet_ids
  db_endpoint   = module.database.endpoint
  desired_count = 3
}

Each module handles one concern. The root module wires them together.

Best Practices

#

1. Keep Modules Small and Focused

#

One module = one logical component. A VPC module, a database module, a service module — not an "everything" module.

2. Always Define Input Validation

#
variable "instance_type" {
  type = string
  validation {
    condition     = can(regex("^t[23]\\.", var.instance_type))
    error_message = "Only t2 and t3 instance types allowed."
  }
}

3. Version Pin Registry Modules

#
# Good
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"
}
 
# Bad — no version pin
module "vpc" {
  source = "terraform-aws-modules/vpc/aws"
}

4. Use Consistent File Structure

#

Every module should have:

  • main.tf — resources
  • variables.tf — all inputs
  • outputs.tf — all outputs
  • versions.tf — provider requirements
  • README.md — usage examples

5. Don't Hardcode Provider Configuration

#

Modules should inherit the provider from the calling module:

# Bad — provider config in module
provider "aws" {
  region = "us-east-1"
}
 
# Good — module inherits provider from caller
# (no provider block in the module)

6. Use Description on Everything

#
variable "vpc_cidr" {
  description = "CIDR block for the VPC (e.g., 10.0.0.0/16)"
  type        = string
}
 
output "vpc_id" {
  description = "ID of the created VPC"
  value       = aws_vpc.main.id
}

7. Tag Everything via a Common Variable

#
variable "tags" {
  description = "Common tags for all resources"
  type        = map(string)
  default     = {}
}
 
# In resources:
tags = merge(var.tags, { Name = "..." })

Common Errors

#

"Module not installed"

#
Error: Module not installed

Run terraform init to download modules.

"Unsupported attribute" on module output

#

The module doesn't expose that output. Check outputs.tf in the module.

Cycle between modules

#

Modules can't have circular dependencies. Restructure so data flows one direction.

Hands-On Courses

#

Learn by doing with interactive courses on CopyPasteLearn:

Conclusion

#

A Terraform module is just a directory with .tf files. Keep modules small (one component each), validate inputs, version-pin registry sources, expose useful outputs, and document everything with descriptions. Use module composition to build complex infrastructure from simple, tested building blocks.

#Terraform#Modules#Best Practices#Infrastructure as Code#DevOps

Share this article