Terraform Module Development
Build, validate, and test reusable Terraform modules using LSP features
Prerequisites
- Terry-Form MCP installed and configured
- Completed the "Your First Project" tutorial
- Understanding of Terraform resource blocks
Terraform Module Development
This tutorial walks you through building a reusable Terraform module, validating it with Terry-Form MCP, and using LSP features for code intelligence during development.
What You’ll Learn
- How to structure a Terraform module
- How to use LSP hover and completions for faster development
- How to validate modules with
terry - How to test modules by calling them from a root configuration
Module Architecture
We’ll build a VPC module that creates a complete network setup:
graph TB
subgraph "Module: vpc"
A[VPC] --> B[Internet Gateway]
A --> C[Public Subnets]
A --> D[Private Subnets]
C --> E[Public Route Table]
D --> F[Private Route Table]
B --> E
end
subgraph "Root Configuration"
G["module vpc"] --> A
G --> H[Variables passed in]
A --> I[Outputs returned]
end
Step 1: Create the Module Structure
Set up your workspace:
workspace/
├── modules/
│ └── vpc/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── environments/
└── dev/
├── main.tf
└── variables.tf
Step 2: Define Module Variables
Start with the module’s interface — its input variables.
modules/vpc/variables.tf:
variable "name" {
description = "Name prefix for all VPC resources"
type = string
validation {
condition = length(var.name) > 0 && length(var.name) <= 32
error_message = "Name must be between 1 and 32 characters."
}
}
variable "cidr_block" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
validation {
condition = can(cidrhost(var.cidr_block, 0))
error_message = "Must be a valid CIDR block."
}
}
variable "availability_zones" {
description = "List of availability zones to use"
type = list(string)
validation {
condition = length(var.availability_zones) >= 2
error_message = "At least 2 availability zones are required."
}
}
variable "public_subnet_cidrs" {
description = "CIDR blocks for public subnets (one per AZ)"
type = list(string)
}
variable "private_subnet_cidrs" {
description = "CIDR blocks for private subnets (one per AZ)"
type = list(string)
}
variable "enable_nat_gateway" {
description = "Whether to create a NAT gateway for private subnets"
type = bool
default = true
}
variable "tags" {
description = "Additional tags for all resources"
type = map(string)
default = {}
}
Use LSP Hover for Documentation
While writing resource blocks, ask your assistant to check documentation:
“Can you hover over the aws_vpc resource type at line 1 of modules/vpc/main.tf to see its documentation?”
{
"tool": "terraform_hover",
"arguments": {
"file_path": "modules/vpc/main.tf",
"line": 0,
"character": 10
}
}
This returns provider documentation for the aws_vpc resource, including all available attributes.
Step 3: Create Module Resources
modules/vpc/main.tf:
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(var.tags, {
Name = "${var.name}-vpc"
})
}
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
tags = merge(var.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 = merge(var.tags, {
Name = "${var.name}-public-${count.index + 1}"
Type = "public"
})
}
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 = merge(var.tags, {
Name = "${var.name}-private-${count.index + 1}"
Type = "private"
})
}
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
}
tags = merge(var.tags, {
Name = "${var.name}-public-rt"
})
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.this.id
tags = merge(var.tags, {
Name = "${var.name}-private-rt"
})
}
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
}
resource "aws_route_table_association" "private" {
count = length(aws_subnet.private)
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private.id
}
resource "aws_eip" "nat" {
count = var.enable_nat_gateway ? 1 : 0
domain = "vpc"
tags = merge(var.tags, {
Name = "${var.name}-nat-eip"
})
}
resource "aws_nat_gateway" "this" {
count = var.enable_nat_gateway ? 1 : 0
allocation_id = aws_eip.nat[0].id
subnet_id = aws_subnet.public[0].id
tags = merge(var.tags, {
Name = "${var.name}-nat"
})
depends_on = [aws_internet_gateway.this]
}
resource "aws_route" "private_nat" {
count = var.enable_nat_gateway ? 1 : 0
route_table_id = aws_route_table.private.id
destination_cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.this[0].id
}
Step 4: Define Module Outputs
modules/vpc/outputs.tf:
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.this.id
}
output "vpc_cidr_block" {
description = "CIDR block of the VPC"
value = aws_vpc.this.cidr_block
}
output "public_subnet_ids" {
description = "IDs of public subnets"
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
description = "IDs of private subnets"
value = aws_subnet.private[*].id
}
output "internet_gateway_id" {
description = "ID of the Internet Gateway"
value = aws_internet_gateway.this.id
}
output "nat_gateway_id" {
description = "ID of the NAT Gateway (null if disabled)"
value = var.enable_nat_gateway ? aws_nat_gateway.this[0].id : null
}
Step 5: Use LSP Completions
While editing, use LSP completions to discover available attributes:
“Get Terraform completions in modules/vpc/main.tf at line 3, character 2 to see what attributes aws_vpc supports”
{
"tool": "terraform_complete",
"arguments": {
"file_path": "modules/vpc/main.tf",
"line": 2,
"character": 2
}
}
This returns available attributes like assign_generated_ipv6_cidr_block, enable_dns_hostnames, instance_tenancy, and more.
Step 6: Validate the Module
Initialize and validate the module in isolation:
“Initialize and validate the VPC module at modules/vpc”
{
"tool": "terry",
"arguments": {
"path": "modules/vpc",
"actions": ["init", "validate"]
}
}
Validating a module directory directly checks HCL syntax and variable declarations, but won't catch errors that depend on how the module is called (like missing required variables).
Step 7: Call the Module
Create a root configuration that uses your module.
environments/dev/main.tf:
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = "module-tutorial"
Environment = var.environment
ManagedBy = "Terraform"
}
}
}
module "vpc" {
source = "../../modules/vpc"
name = "module-tutorial"
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"]
enable_nat_gateway = var.environment == "prod"
tags = {
Environment = var.environment
}
}
output "vpc_id" {
value = module.vpc.vpc_id
}
output "public_subnets" {
value = module.vpc.public_subnet_ids
}
output "private_subnets" {
value = module.vpc.private_subnet_ids
}
environments/dev/variables.tf:
variable "aws_region" {
description = "AWS region"
type = string
default = "us-east-1"
}
variable "environment" {
description = "Environment name"
type = string
default = "dev"
}
Step 8: Validate the Full Configuration
Now validate the root configuration with the module:
“Initialize and plan my dev environment at environments/dev”
{
"tool": "terry",
"arguments": {
"path": "environments/dev",
"actions": ["init", "validate", "plan"],
"vars": {"environment": "dev"}
}
}
The plan will show all the resources the module creates — VPC, subnets, route tables, and internet gateway. Since environment is "dev", the NAT gateway will be skipped.
Step 9: Analyze the Module
Run the analysis tools on your module:
“Analyze the VPC module for best practices”
{
"tool": "terry_analyze",
"arguments": {
"path": "modules/vpc"
}
}
A well-structured module should pass with zero or minimal warnings because every variable has a description, resources use variables (not hardcoded values), and tags are applied via merge().
Step 10: Format the Code
Ensure consistent formatting:
“Format the VPC module and the dev environment”
{
"tool": "terry",
"arguments": {
"path": "modules/vpc",
"actions": ["fmt"]
}
}
{
"tool": "terry",
"arguments": {
"path": "environments/dev",
"actions": ["fmt"]
}
}
Module Best Practices
| Practice | Example |
|---|---|
| All variables have descriptions | description = "CIDR block for the VPC" |
| Use validation blocks | validation { condition = ... } |
Use merge() for tags |
tags = merge(var.tags, { Name = "..." }) |
Name resources this for single instances |
resource "aws_vpc" "this" |
| Provide sensible defaults | default = "10.0.0.0/16" |
| Document every output | description = "ID of the VPC" |
Summary
In this tutorial, you learned how to:
- Structure a reusable Terraform module with variables, resources, and outputs
- Use LSP hover and completions for provider documentation
- Validate modules both in isolation and as part of a root configuration
- Apply best practices: descriptions, validation blocks, tag merging
Next Steps
- GitHub Actions Pipeline — Automate validation in CI/CD
- Multi-Environment Setup — Use modules across dev/staging/prod
- LSP Integration Guide — Full LSP reference