Terraform modules turn infrastructure code from scripts into libraries. Here’s how to design them well.

Module Structure

modulvepsc//mvovRaaueEirtrAnipsD.auiMtbtoEflsn.e.smst.d.fttff#####RIOPDenurosptocoupvuutuimrtdecveneavrtsraailrtaueibeqolsuneisrements

Basic Module

variables.tf

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
variable "name" {
  description = "Name prefix for resources"
  type        = string
}

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

variable "azs" {
  description = "Availability zones"
  type        = list(string)
}

variable "private_subnets" {
  description = "Private subnet CIDR blocks"
  type        = list(string)
  default     = []
}

variable "public_subnets" {
  description = "Public subnet CIDR blocks"
  type        = list(string)
  default     = []
}

variable "tags" {
  description = "Tags to apply to resources"
  type        = map(string)
  default     = {}
}

main.tf

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
resource "aws_vpc" "this" {
  cidr_block           = var.cidr_block
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = merge(var.tags, {
    Name = var.name
  })
}

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

  vpc_id            = aws_vpc.this.id
  cidr_block        = var.private_subnets[count.index]
  availability_zone = var.azs[count.index % length(var.azs)]

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

resource "aws_subnet" "public" {
  count = length(var.public_subnets)

  vpc_id                  = aws_vpc.this.id
  cidr_block              = var.public_subnets[count.index]
  availability_zone       = var.azs[count.index % length(var.azs)]
  map_public_ip_on_launch = true

  tags = merge(var.tags, {
    Name = "${var.name}-public-${count.index + 1}"
    Type = "public"
  })
}

outputs.tf

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
output "vpc_id" {
  description = "VPC ID"
  value       = aws_vpc.this.id
}

output "private_subnet_ids" {
  description = "Private subnet IDs"
  value       = aws_subnet.private[*].id
}

output "public_subnet_ids" {
  description = "Public subnet IDs"
  value       = aws_subnet.public[*].id
}

output "vpc_cidr_block" {
  description = "VPC CIDR block"
  value       = aws_vpc.this.cidr_block
}

versions.tf

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
terraform {
  required_version = ">= 1.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 4.0"
    }
  }
}

Using Modules

Local Module

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
module "vpc" {
  source = "./modules/vpc"

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

  tags = {
    Environment = "production"
    Project     = "myapp"
  }
}

# Use outputs
resource "aws_instance" "app" {
  subnet_id = module.vpc.private_subnet_ids[0]
  # ...
}

Registry Module

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.0.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
  single_nat_gateway = true
}

Git Module

1
2
3
4
module "vpc" {
  source = "git::https://github.com/org/terraform-modules.git//vpc?ref=v1.2.0"
  # ...
}

Variable Validation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
variable "environment" {
  description = "Environment name"
  type        = string

  validation {
    condition     = contains(["dev", "staging", "production"], var.environment)
    error_message = "Environment must be dev, staging, or production."
  }
}

variable "instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t3.micro"

  validation {
    condition     = can(regex("^t3\\.", var.instance_type))
    error_message = "Instance type must be t3 family."
  }
}

variable "cidr_block" {
  description = "CIDR block"
  type        = string

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

Complex Types

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
variable "services" {
  description = "Service configurations"
  type = list(object({
    name          = string
    port          = number
    health_path   = optional(string, "/health")
    replicas      = optional(number, 1)
    environment   = optional(map(string), {})
  }))
}

# Usage
services = [
  {
    name     = "api"
    port     = 8080
    replicas = 3
    environment = {
      LOG_LEVEL = "info"
    }
  },
  {
    name = "worker"
    port = 9090
  }
]

Conditional Resources

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
variable "create_nat_gateway" {
  description = "Create NAT gateway"
  type        = bool
  default     = true
}

resource "aws_nat_gateway" "this" {
  count = var.create_nat_gateway ? 1 : 0

  allocation_id = aws_eip.nat[0].id
  subnet_id     = aws_subnet.public[0].id
}

resource "aws_eip" "nat" {
  count = var.create_nat_gateway ? 1 : 0

  domain = "vpc"
}

Dynamic Blocks

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
variable "ingress_rules" {
  description = "Ingress rules"
  type = list(object({
    port        = number
    protocol    = string
    cidr_blocks = list(string)
  }))
  default = []
}

resource "aws_security_group" "this" {
  name        = var.name
  description = var.description
  vpc_id      = var.vpc_id

  dynamic "ingress" {
    for_each = var.ingress_rules
    content {
      from_port   = ingress.value.port
      to_port     = ingress.value.port
      protocol    = ingress.value.protocol
      cidr_blocks = ingress.value.cidr_blocks
    }
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

Module Composition

Root Module

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# main.tf
module "vpc" {
  source = "./modules/vpc"
  # ...
}

module "security_groups" {
  source = "./modules/security-groups"

  vpc_id = module.vpc.vpc_id
  # ...
}

module "ecs_cluster" {
  source = "./modules/ecs-cluster"

  vpc_id             = module.vpc.vpc_id
  private_subnet_ids = module.vpc.private_subnet_ids
  security_group_ids = [module.security_groups.app_sg_id]
  # ...
}

Shared Data

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# data.tf - Shared data sources
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}

locals {
  account_id = data.aws_caller_identity.current.account_id
  region     = data.aws_region.current.name
  
  common_tags = {
    Environment = var.environment
    Project     = var.project
    ManagedBy   = "terraform"
  }
}

For_each vs Count

Count (Index-based)

1
2
3
4
5
# Problematic: Adding/removing items shifts indices
resource "aws_subnet" "private" {
  count      = length(var.private_subnets)
  cidr_block = var.private_subnets[count.index]
}

For_each (Key-based)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Better: Keys are stable
variable "subnets" {
  type = map(object({
    cidr_block = string
    az         = string
  }))
}

resource "aws_subnet" "private" {
  for_each = var.subnets

  cidr_block        = each.value.cidr_block
  availability_zone = each.value.az

  tags = {
    Name = each.key
  }
}

# Usage
subnets = {
  "private-1a" = { cidr_block = "10.0.1.0/24", az = "us-east-1a" }
  "private-1b" = { cidr_block = "10.0.2.0/24", az = "us-east-1b" }
}

Sensitive Values

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
variable "database_password" {
  description = "Database password"
  type        = string
  sensitive   = true
}

output "connection_string" {
  description = "Database connection string"
  value       = "postgres://user:${var.database_password}@${aws_db_instance.this.endpoint}/db"
  sensitive   = true
}

Module Testing

Terraform Test (1.6+)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# tests/vpc_test.tftest.hcl
run "vpc_creates_successfully" {
  command = plan

  variables {
    name       = "test-vpc"
    cidr_block = "10.0.0.0/16"
    azs        = ["us-east-1a"]
  }

  assert {
    condition     = aws_vpc.this.cidr_block == "10.0.0.0/16"
    error_message = "VPC CIDR block incorrect"
  }
}

Run tests:

1
terraform test

Documentation

README.md

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# VPC Module

Creates a VPC with public and private subnets.

## Usage

```hcl
module "vpc" {
  source = "./modules/vpc"

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

Inputs

NameDescriptionTypeDefaultRequired
nameName prefixstring-yes
cidr_blockVPC CIDRstring“10.0.0.0/16”no

Outputs

NameDescription
vpc_idVPC ID
private_subnet_idsPrivate subnet IDs
G`te`en`rebrraaasfthoermd-odcoscsaumtaormkadtoiwcnal.ly>:README.md

Best Practices

  1. Pin versions: Always specify provider and module versions
  2. Use for_each over count: Stable keys prevent recreation
  3. Validate inputs: Catch errors early with validation blocks
  4. Output everything useful: Make modules composable
  5. Document thoroughly: Include examples and descriptions
  6. Keep modules focused: Single responsibility principle
  7. Use locals: Name complex expressions
  8. Sensitive handling: Mark secrets as sensitive

Good modules are like good functions: clear inputs, predictable outputs, and a single purpose.