Creating Modules

Learn how to create and use custom Terraform modules in your environments.


Overview

Terraform modules are reusable packages of Terraform configurations. This guide shows you how to create custom modules and use them in your environments.

Module structure

Follow this standard structure for new modules:

modules/
└── your-module/
    ├── main.tf           # Resource definitions
    ├── variables.tf      # Input variables
    ├── outputs.tf        # Output values
    └── versions.tf       # Provider requirements

Example: Creating a VPC module

1. Create the module directory

mkdir -p modules/vpc

2. Define resources (main.tf)

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = var.enable_dns_hostnames
  enable_dns_support   = var.enable_dns_support

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

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

  vpc_id                  = aws_vpc.main.id
  cidr_block              = var.public_subnet_cidrs[count.index]
  availability_zone       = data.aws_availability_zones.available.names[count.index]
  map_public_ip_on_launch = true

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

data "aws_availability_zones" "available" {
  state = "available"
}

3. Define variables (variables.tf)

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

variable "vpc_cidr" {
  description = "CIDR block for the VPC"
  type        = string

  validation {
    condition     = can(cidrhost(var.vpc_cidr, 0))
    error_message = "VPC CIDR must be a valid IPv4 CIDR block."
  }
}

variable "public_subnet_cidrs" {
  description = "List of CIDR blocks for public subnets"
  type        = list(string)
  default     = []
}

variable "enable_dns_hostnames" {
  description = "Enable DNS hostnames in the VPC"
  type        = bool
  default     = true
}

variable "enable_dns_support" {
  description = "Enable DNS support in the VPC"
  type        = bool
  default     = true
}

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

4. Define outputs (outputs.tf)

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 = "IDs of public subnets"
  value       = aws_subnet.public[*].id
}

5. Set version requirements (versions.tf)

terraform {
  required_version = ">= 1.5.0"

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

Using the module in an environment

Add the module to your environment configuration:

# environments/staging/main.tf
provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      Environment = "staging"
      ManagedBy   = "terraform"
      Repository  = "towardsthecloud/aws-terraform-starter-kit-demo"
    }
  }
}

# OIDC Provider Module
module "oidc_provider" {
  source = "../../modules/oidc-provider"

  use_existing_oidc_provider = var.use_existing_oidc_provider
  github_repo                = var.github_repo
  role_name                  = var.role_name
  managed_policy_arns        = var.managed_policy_arns
}

# VPC Module
module "vpc" {
  source = "../../modules/vpc"

  name    = "staging-vpc"
  vpc_cidr = "10.0.0.0/16"

  public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"]

  tags = {
    Environment = "staging"
  }
}

Using module outputs

Reference module outputs in your resources:

# Use VPC outputs in other resources
resource "aws_instance" "app" {
  ami           = "ami-12345678"
  instance_type = "t3.micro"
  subnet_id     = module.vpc.public_subnet_ids[0]

  tags = {
    Name = "app-server"
  }
}

Validating the module

Test your module:

# Validate
make validate-env ENV=staging

# Plan
make plan ENV=staging

# Apply
make apply ENV=staging

Module best practices

1. Use descriptive variable names

# Good
variable "vpc_cidr" {
  description = "CIDR block for the VPC"
  type        = string
}

# Avoid
variable "cidr" {
  type = string
}

2. Add validation rules

variable "instance_type" {
  description = "EC2 instance type"
  type        = string

  validation {
    condition     = can(regex("^t[23]\\.", var.instance_type))
    error_message = "Instance type must be t2 or t3 family."
  }
}

3. Provide sensible defaults

variable "enable_monitoring" {
  description = "Enable detailed monitoring"
  type        = bool
  default     = false
}

4. Use merge for tags

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

resource "aws_instance" "app" {
  # ...
  tags = merge(var.tags, {
    Name = "app-server"
    Role = "application"
  })
}

5. Export useful outputs

# Export individual values
output "vpc_id" {
  description = "ID of the VPC"
  value       = aws_vpc.main.id
}

# Or export objects
output "database" {
  description = "Database connection information"
  value = {
    endpoint = aws_db_instance.main.endpoint
    port     = aws_db_instance.main.port
  }
  sensitive = true
}

Next steps