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

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

Key Takeaway

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

Table of Contents

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.

🚀

Level Up Your Terraform Skills

Hands-on courses, books, and resources from Luca Berton

Luca Berton
Written by

Luca Berton

DevOps Engineer, AWS Partner, Terraform expert, and author. Creator of Ansible Pilot, Terraform Pilot, and CopyPasteLearn.