Search…

Terraform modules

In this series (10 parts)
  1. Introduction to Infrastructure as Code
  2. Terraform fundamentals
  3. Terraform state management
  4. Terraform modules
  5. Terraform in CI/CD
  6. Ansible fundamentals
  7. Ansible roles and best practices
  8. Packer for machine images
  9. CloudFormation and CDK
  10. Managing drift and compliance

Copying and pasting Terraform blocks across projects works until it does not. A single change to your VPC layout means updating ten repositories. Modules solve this by turning infrastructure into reusable building blocks with clear interfaces.

Why modules exist

Every Terraform configuration is technically a module. The directory you run terraform apply in is the root module. When you extract resources into a separate directory and call them with a module block, you create a child module.

Modules give you three things:

  1. Reusability. Write a VPC once. Deploy it everywhere.
  2. Encapsulation. Consumers see inputs and outputs, not internal wiring.
  3. Consistency. Teams share the same tested patterns instead of inventing their own.
graph TD
  A[Root Module] --> B[VPC Module]
  A --> C[Database Module]
  A --> D[App Module]
  D --> B
  C --> B

Module structure

A well-organized module follows a predictable file layout. Nothing enforces this convention, but every team you join will expect it.

modules/vpc/
  main.tf          # Resources
  variables.tf     # Input variables
  outputs.tf       # Output values
  versions.tf      # Provider requirements
  README.md        # Usage documentation

Keep modules focused. A module that creates a VPC, subnets, route tables, and an internet gateway is reasonable. A module that also provisions EC2 instances, databases, and DNS records is doing too much.

Building a reusable VPC module

Start with the input variables. These define the module’s public API.

modules/vpc/variables.tf

variable "name" {
  description = "Name prefix for all resources"
  type        = string
}

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

variable "availability_zones" {
  description = "List of AZs for subnet placement"
  type        = list(string)
}

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)
}

modules/vpc/main.tf

resource "aws_vpc" "this" {
  cidr_block           = var.cidr_block
  enable_dns_support   = true
  enable_dns_hostnames = true
  tags = { Name = "${var.name}-vpc" }
}

resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id
  tags   = { Name = "${var.name}-igw" }
}

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 = { Name = "${var.name}-public-${count.index}" }
}

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]
  tags = { Name = "${var.name}-private-${count.index}" }
}

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
  }
}

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

modules/vpc/outputs.tf

output "vpc_id" {
  value = aws_vpc.this.id
}

output "public_subnet_ids" {
  value = aws_subnet.public[*].id
}

output "private_subnet_ids" {
  value = aws_subnet.private[*].id
}

Calling the module

From your root configuration, reference the module with a source argument.

module "network" {
  source = "./modules/vpc"

  name                 = "production"
  cidr_block           = "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"]
}

resource "aws_security_group" "app" {
  name   = "app-sg"
  vpc_id = module.network.vpc_id

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

Access outputs through module.<name>.<output>. This is the only way modules communicate.

Module sources and versioning

The source argument accepts local paths, Terraform Registry references, GitHub URLs, S3 buckets, and generic Git repositories. For remote sources, always pin versions.

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.5.1"

  name = "my-vpc"
  cidr = "10.0.0.0/16"
}

Never use unversioned remote modules in production. A breaking change upstream will break your next apply.

Composition patterns

Real infrastructure composes multiple modules. Outputs from one module feed into inputs of another. Terraform resolves the dependency graph automatically.

module "network" {
  source               = "./modules/vpc"
  name                 = "prod"
  cidr_block           = "10.0.0.0/16"
  availability_zones   = ["us-east-1a", "us-east-1b"]
  public_subnet_cidrs  = ["10.0.1.0/24", "10.0.2.0/24"]
  private_subnet_cidrs = ["10.0.10.0/24", "10.0.11.0/24"]
}

module "database" {
  source     = "./modules/rds"
  vpc_id     = module.network.vpc_id
  subnet_ids = module.network.private_subnet_ids
  engine     = "postgres"
}

module "application" {
  source      = "./modules/ecs"
  vpc_id      = module.network.vpc_id
  subnet_ids  = module.network.public_subnet_ids
  db_endpoint = module.database.endpoint
}

The network builds first. Then the database and application proceed in whatever order their dependencies allow.

Guidelines for good modules

  • Expose only what callers need. Not every resource attribute belongs in outputs.tf.
  • Use sensible defaults. Required variables should be things that genuinely vary between deployments.
  • Validate inputs. Use validation blocks to catch bad values before they hit the provider.
variable "cidr_block" {
  type = string

  validation {
    condition     = can(cidrhost(var.cidr_block, 0))
    error_message = "Must be a valid CIDR block."
  }
}

What comes next

Modules give you reusable components. The final article in this series covers testing and CI/CD pipelines for infrastructure code. You will learn how to validate modules with terraform validate, run integration tests, and set up automated workflows that plan and apply changes safely.

Start typing to search across all content
navigate Enter open Esc close