Skip to content

2: Migrating Existing Terraform Module

Overview

This tutorial walks through taking a real-world, messy Terraform VPC module and bringing it to full compliance with the DevOps Engineering Style Guide. You will fix formatting, add validation, write a CONTRACT.md, add metadata, create Terratest tests, and set up CI/CD.

What You Will Build

Before                              After
======                              =====
vpc-module/                         terraform-aws-vpc/
  main.tf (600 lines, messy)          main.tf
  vars.tf (no descriptions)           variables.tf
                                      outputs.tf
                                      versions.tf
                                      locals.tf
                                      CONTRACT.md
                                      README.md
                                      test/
                                        vpc_test.go
                                        fixtures/
                                          main.tf
                                      .github/
                                        workflows/
                                          ci.yml

Estimated Time

Step 1: Assess the Legacy Module       5 min
Step 2: Apply Formatting Standards      5 min
Step 3: Add Variable Validation         5 min
Step 4: Write CONTRACT.md              10 min
Step 5: Add Metadata                    3 min
Step 6: Write Terratest Tests          10 min
Step 7: Set Up CI/CD                    5 min
Step 8: Publish to Registry             2 min
─────────────────────────────────────────────
Total                                  45 min

Prerequisites

# Verify required tools
terraform version   # Terraform 1.6+
go version          # Go 1.21+
git version         # Git 2.30+
aws --version       # AWS CLI 2.x (for plan/apply)
# Install terratest (Go module)
go install github.com/gruntwork-io/terratest@latest

# Install terraform-docs
brew install terraform-docs    # macOS
# or
go install github.com/terraform-docs/terraform-docs@latest

Step 1: Assess the Legacy Module (5 min)

Below is the legacy module you are migrating. This is a typical "grown organically" module with common problems: everything in one file, no descriptions, hardcoded values, inconsistent naming, and no documentation.

The "Before" State

# vpc-module/main.tf - THE ENTIRE MODULE IN ONE FILE
# Created by: someone on the team, a while ago
# TODO: clean this up

terraform {
required_version = ">= 1.0"
required_providers {
aws = {
  source = "hashicorp/aws"
  version = "~> 4.0"
}
}
}

variable "cidr" {}
variable "Name" {}
variable "env" {
  default = "dev"
}
variable "AZs" {
  type = list(string)
  default = ["us-east-1a","us-east-1b"]
}
variable "publicSubnets" {
  default = ["10.0.1.0/24","10.0.2.0/24"]
}
variable "private_subnets" {
  default = ["10.0.10.0/24","10.0.11.0/24"]
}
variable "enable_nat" {
  default = true
}
variable "tags" {
  default = {}
}

resource "aws_vpc" "vpc" {
cidr_block = var.cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(var.tags, {
Name = var.Name
Environment = var.env
})
}

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

resource "aws_subnet" "public" {
count = length(var.publicSubnets)
vpc_id = aws_vpc.vpc.id
cidr_block = var.publicSubnets[count.index]
availability_zone = var.AZs[count.index]
map_public_ip_on_launch = true
tags = {
"Name" = "${var.Name}-public-${count.index}"
}
}

resource "aws_subnet" "private" {
count = length(var.private_subnets)
vpc_id = aws_vpc.vpc.id
cidr_block = var.private_subnets[count.index]
availability_zone = var.AZs[count.index]
tags = {
"Name" = "${var.Name}-private-${count.index}"
}
}

resource "aws_eip" "nat" {
count = var.enable_nat ? 1 : 0
vpc = true
}

resource "aws_nat_gateway" "nat" {
count = var.enable_nat ? 1 : 0
allocation_id = aws_eip.nat[0].id
subnet_id = aws_subnet.public[0].id
tags = {
Name = "${var.Name}-nat"
}
}

resource "aws_route_table" "public" {
vpc_id = aws_vpc.vpc.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
tags = {
Name = "${var.Name}-public-rt"
}
}

resource "aws_route_table" "private" {
count = var.enable_nat ? 1 : 0
vpc_id = aws_vpc.vpc.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.nat[0].id
}
tags = {
Name = "${var.Name}-private-rt"
}
}

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

resource "aws_route_table_association" "private" {
count = var.enable_nat ? length(var.private_subnets) : 0
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private[0].id
}

output "vpc_id" {
value = aws_vpc.vpc.id
}
output "public_subnets" {
value = aws_subnet.public[*].id
}
output "private_subnets" {
value = aws_subnet.private[*].id
}

Identify the Problems

Issue                           Severity    Style Guide Violation
──────────────────────────────────────────────────────────────────
No indentation / bad formatting HIGH        Terraform Style Guide: Formatting
Mixed naming (camelCase + snake) HIGH       Terraform Style Guide: Naming
No variable descriptions        HIGH        Terraform Style Guide: Variables
No variable validation          MEDIUM      Terraform Style Guide: Validation
Everything in one file          HIGH        Terraform Style Guide: File Layout
No type constraints on vars     MEDIUM      Terraform Style Guide: Variables
No output descriptions          MEDIUM      Terraform Style Guide: Outputs
No CONTRACT.md                  HIGH        CONTRACT.md Template
No metadata tags                MEDIUM      Metadata Schema
No tests                        HIGH        IaC Testing Standards
No CI/CD pipeline               MEDIUM      CI/CD Standards
Deprecated `vpc = true` on EIP  HIGH        Provider deprecation (use domain)
Hardcoded provider version ~>4  MEDIUM      Should use latest major
No versions.tf file             MEDIUM      Terraform Style Guide: File Layout

Checkpoint 1

# Clone or create the legacy module to work with
mkdir -p terraform-aws-vpc
cd terraform-aws-vpc
git init

# Create the legacy main.tf (copy the code above)
# Verify the starting state
terraform fmt -check -diff .
# Expected: formatting differences reported (non-zero exit code)
echo $?
# Expected output: 3

Step 2: Apply Formatting Standards (5 min)

2.1 Run terraform fmt

# Auto-format all .tf files
terraform fmt -recursive .

# Verify formatting is clean
terraform fmt -check -diff .
echo $?
# Expected output: 0

2.2 Split Into Standard File Layout

# versions.tf - Provider and Terraform version constraints
terraform {
  required_version = ">= 1.6"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}
# variables.tf - All input variables with descriptions, types, and defaults
variable "vpc_name" {
  description = "Name prefix for all VPC resources"
  type        = string
}

variable "vpc_cidr" {
  description = "CIDR block for the VPC (e.g., 10.0.0.0/16)"
  type        = string
}

variable "environment" {
  description = "Environment name (e.g., dev, staging, production)"
  type        = string
  default     = "dev"
}

variable "availability_zones" {
  description = "List of availability zones for subnet distribution"
  type        = list(string)
  default     = ["us-east-1a", "us-east-1b"]
}

variable "public_subnet_cidrs" {
  description = "CIDR blocks for public subnets (one per AZ)"
  type        = list(string)
  default     = ["10.0.1.0/24", "10.0.2.0/24"]
}

variable "private_subnet_cidrs" {
  description = "CIDR blocks for private subnets (one per AZ)"
  type        = list(string)
  default     = ["10.0.10.0/24", "10.0.11.0/24"]
}

variable "enable_nat_gateway" {
  description = "Whether to create a NAT gateway for private subnet internet access"
  type        = bool
  default     = true
}

variable "tags" {
  description = "Additional tags to apply to all resources"
  type        = map(string)
  default     = {}
}
# locals.tf - Computed values and common tags
locals {
  common_tags = merge(var.tags, {
    Environment = var.environment
    ManagedBy   = "terraform"
    Module      = "terraform-aws-vpc"
  })
}
# main.tf - Resource definitions
resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = merge(local.common_tags, {
    Name = "${var.vpc_name}-vpc"
  })
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = merge(local.common_tags, {
    Name = "${var.vpc_name}-igw"
  })
}

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       = var.availability_zones[count.index]
  map_public_ip_on_launch = true

  tags = merge(local.common_tags, {
    Name = "${var.vpc_name}-public-${var.availability_zones[count.index]}"
    Tier = "public"
  })
}

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

  vpc_id            = aws_vpc.main.id
  cidr_block        = var.private_subnet_cidrs[count.index]
  availability_zone = var.availability_zones[count.index]

  tags = merge(local.common_tags, {
    Name = "${var.vpc_name}-private-${var.availability_zones[count.index]}"
    Tier = "private"
  })
}

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

  domain = "vpc"

  tags = merge(local.common_tags, {
    Name = "${var.vpc_name}-nat-eip"
  })
}

resource "aws_nat_gateway" "main" {
  count = var.enable_nat_gateway ? 1 : 0

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

  tags = merge(local.common_tags, {
    Name = "${var.vpc_name}-nat"
  })

  depends_on = [aws_internet_gateway.main]
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id
  }

  tags = merge(local.common_tags, {
    Name = "${var.vpc_name}-public-rt"
  })
}

resource "aws_route_table" "private" {
  count = var.enable_nat_gateway ? 1 : 0

  vpc_id = aws_vpc.main.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.main[0].id
  }

  tags = merge(local.common_tags, {
    Name = "${var.vpc_name}-private-rt"
  })
}

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

  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table_association" "private" {
  count = var.enable_nat_gateway ? length(var.private_subnet_cidrs) : 0

  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private[0].id
}
# outputs.tf - All outputs with descriptions
output "vpc_id" {
  description = "The ID of the VPC"
  value       = aws_vpc.main.id
}

output "vpc_cidr_block" {
  description = "The 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
}

output "internet_gateway_id" {
  description = "The ID of the Internet Gateway"
  value       = aws_internet_gateway.main.id
}

output "nat_gateway_id" {
  description = "The ID of the NAT Gateway (null if disabled)"
  value       = var.enable_nat_gateway ? aws_nat_gateway.main[0].id : null
}

output "public_route_table_id" {
  description = "The ID of the public route table"
  value       = aws_route_table.public.id
}

output "private_route_table_id" {
  description = "The ID of the private route table (null if NAT disabled)"
  value       = var.enable_nat_gateway ? aws_route_table.private[0].id : null
}

output "nat_gateway_public_ip" {
  description = "The public IP address of the NAT Gateway"
  value       = var.enable_nat_gateway ? aws_eip.nat[0].public_ip : null
}

2.3 Naming Changes Summary

Before (inconsistent)        After (snake_case)
─────────────────────────────────────────────────
variable "cidr"              variable "vpc_cidr"
variable "Name"              variable "vpc_name"
variable "env"               variable "environment"
variable "AZs"               variable "availability_zones"
variable "publicSubnets"     variable "public_subnet_cidrs"
variable "private_subnets"   variable "private_subnet_cidrs"
variable "enable_nat"        variable "enable_nat_gateway"
resource "aws_vpc" "vpc"     resource "aws_vpc" "main"
resource "aws_igw" "igw"     resource "aws_internet_gateway" "main"
output "public_subnets"      output "public_subnet_ids"
output "private_subnets"     output "private_subnet_ids"

Checkpoint 2

# Verify file structure
ls -1 *.tf
# Expected output:
# locals.tf
# main.tf
# outputs.tf
# variables.tf
# versions.tf

# Verify formatting
terraform fmt -check .
echo $?
# Expected output: 0

# Verify syntax validity
terraform validate
# Expected: Success! The configuration is valid.

Step 3: Add Variable Validation (5 min)

Add validation blocks to variables.tf to catch misconfigurations before plan/apply.

3.1 CIDR Validation

variable "vpc_cidr" {
  description = "CIDR block for the VPC (e.g., 10.0.0.0/16)"
  type        = string

  validation {
    condition     = can(cidrhost(var.vpc_cidr, 0))
    error_message = "The vpc_cidr must be a valid CIDR block (e.g., 10.0.0.0/16)."
  }

  validation {
    condition     = tonumber(split("/", var.vpc_cidr)[1]) >= 16 && tonumber(split("/", var.vpc_cidr)[1]) <= 24
    error_message = "The vpc_cidr prefix length must be between /16 and /24."
  }
}

3.2 Environment Validation

variable "environment" {
  description = "Environment name (e.g., dev, staging, production)"
  type        = string
  default     = "dev"

  validation {
    condition     = contains(["dev", "staging", "production", "sandbox"], var.environment)
    error_message = "The environment must be one of: dev, staging, production, sandbox."
  }
}

3.3 Subnet and AZ Consistency Validation

variable "availability_zones" {
  description = "List of availability zones for subnet distribution"
  type        = list(string)
  default     = ["us-east-1a", "us-east-1b"]

  validation {
    condition     = length(var.availability_zones) >= 2
    error_message = "At least 2 availability zones are required for high availability."
  }

  validation {
    condition     = length(var.availability_zones) == length(distinct(var.availability_zones))
    error_message = "Availability zones must be unique (no duplicates)."
  }
}

variable "public_subnet_cidrs" {
  description = "CIDR blocks for public subnets (one per AZ)"
  type        = list(string)
  default     = ["10.0.1.0/24", "10.0.2.0/24"]

  validation {
    condition     = length(var.public_subnet_cidrs) >= 1
    error_message = "At least one public subnet CIDR is required."
  }

  validation {
    condition     = alltrue([for cidr in var.public_subnet_cidrs : can(cidrhost(cidr, 0))])
    error_message = "All public_subnet_cidrs must be valid CIDR blocks."
  }
}

variable "private_subnet_cidrs" {
  description = "CIDR blocks for private subnets (one per AZ)"
  type        = list(string)
  default     = ["10.0.10.0/24", "10.0.11.0/24"]

  validation {
    condition     = length(var.private_subnet_cidrs) >= 1
    error_message = "At least one private subnet CIDR is required."
  }

  validation {
    condition     = alltrue([for cidr in var.private_subnet_cidrs : can(cidrhost(cidr, 0))])
    error_message = "All private_subnet_cidrs must be valid CIDR blocks."
  }
}

3.4 Name Validation

variable "vpc_name" {
  description = "Name prefix for all VPC resources"
  type        = string

  validation {
    condition     = length(var.vpc_name) >= 3 && length(var.vpc_name) <= 28
    error_message = "The vpc_name must be between 3 and 28 characters."
  }

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]*$", var.vpc_name))
    error_message = "The vpc_name must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens."
  }
}

3.5 Tags Validation

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

  validation {
    condition     = alltrue([for k, v in var.tags : length(k) <= 128 && length(v) <= 256])
    error_message = "Tag keys must be <= 128 characters and values <= 256 characters (AWS limits)."
  }
}

Checkpoint 3

# Test that invalid inputs are caught
cat > /tmp/test_invalid.tfvars <<'EOF'
vpc_cidr    = "not-a-cidr"
vpc_name    = "my-vpc"
environment = "dev"
EOF

terraform plan -var-file=/tmp/test_invalid.tfvars 2>&1 | head -5
# Expected: Error - The vpc_cidr must be a valid CIDR block

cat > /tmp/test_valid.tfvars <<'EOF'
vpc_cidr    = "10.0.0.0/16"
vpc_name    = "my-vpc"
environment = "dev"
EOF

terraform validate
# Expected: Success! The configuration is valid.

Step 4: Write CONTRACT.md (10 min)

Create a CONTRACT.md at the module root with numbered guarantees that map directly to tests.

# Module Contract: terraform-aws-vpc

> **Version**: 1.0.0
> **Last Updated**: 2026-02-14
> **Maintained by**: Platform Engineering Team
> **Status**: Active

## 1. Purpose

This module creates a production-ready AWS VPC with public and private subnets,
internet gateway, optional NAT gateway, and associated route tables. It provides
network isolation and internet connectivity for application workloads running
in AWS.

## 2. Guarantees

### Resource Guarantees

- **G1**: Creates exactly 1 VPC with DNS hostnames and DNS support enabled
- **G2**: Creates N public subnets distributed across at least 2 availability zones
- **G3**: Creates N private subnets distributed across at least 2 availability zones
- **G4**: Creates exactly 1 internet gateway attached to the VPC
- **G5**: When `enable_nat_gateway = true`, creates exactly 1 NAT gateway in the first public subnet
- **G6**: When `enable_nat_gateway = false`, creates no NAT gateway or EIP resources

### Behavior Guarantees

- **G7**: All resources are tagged with the common tag set (Environment, ManagedBy, Module)
- **G8**: Public subnets have `map_public_ip_on_launch = true`
- **G9**: Private subnets do NOT have `map_public_ip_on_launch` enabled
- **G10**: Public route table routes 0.0.0.0/0 through the internet gateway
- **G11**: Private route table (when NAT enabled) routes 0.0.0.0/0 through the NAT gateway
- **G12**: Module is idempotent - running apply twice produces no changes on second run

### Security Guarantees

- **G13**: No security groups with 0.0.0.0/0 ingress are created by this module
- **G14**: VPC flow logs are NOT enabled by default (consumer responsibility)

## 3. Inputs

| Variable               | Type         | Required | Default              | Validation                          |
|------------------------|--------------|----------|----------------------|-------------------------------------|
| `vpc_name`             | `string`     | Yes      | -                    | 3-28 chars, lowercase + hyphens     |
| `vpc_cidr`             | `string`     | Yes      | -                    | Valid CIDR, /16 to /24              |
| `environment`          | `string`     | No       | `"dev"`              | dev, staging, production, sandbox   |
| `availability_zones`   | `list(string)` | No    | `["us-east-1a","us-east-1b"]` | Min 2, unique                |
| `public_subnet_cidrs`  | `list(string)` | No    | `["10.0.1.0/24","10.0.2.0/24"]` | Valid CIDRs, min 1          |
| `private_subnet_cidrs` | `list(string)` | No    | `["10.0.10.0/24","10.0.11.0/24"]` | Valid CIDRs, min 1        |
| `enable_nat_gateway`   | `bool`       | No       | `true`               | -                                   |
| `tags`                 | `map(string)` | No      | `{}`                 | Key <= 128, value <= 256 chars      |

## 4. Outputs

| Output                   | Type         | Description                                    |
|--------------------------|--------------|------------------------------------------------|
| `vpc_id`                 | `string`     | The ID of the created VPC                      |
| `vpc_cidr_block`         | `string`     | The CIDR block of the VPC                      |
| `public_subnet_ids`      | `list(string)` | List of public subnet IDs                   |
| `private_subnet_ids`     | `list(string)` | List of private subnet IDs                  |
| `internet_gateway_id`    | `string`     | The ID of the internet gateway                 |
| `nat_gateway_id`         | `string`     | The NAT gateway ID (null if disabled)          |
| `public_route_table_id`  | `string`     | The public route table ID                      |
| `private_route_table_id` | `string`     | The private route table ID (null if disabled)  |
| `nat_gateway_public_ip`  | `string`     | The public IP of the NAT gateway               |

## 5. Platform Requirements

| Requirement        | Version      | Notes                               |
|--------------------|-------------|--------------------------------------|
| Terraform          | >= 1.6      | Required for validation blocks       |
| AWS Provider       | ~> 5.0      | Required for `domain` on EIP         |
| AWS Account        | Any         | Must have VPC creation permissions   |
| AWS Region         | Any         | Specified AZs must exist in region   |

### Required IAM Permissions

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ec2:CreateVpc", "ec2:DeleteVpc", "ec2:DescribeVpcs", "ec2:ModifyVpcAttribute",
        "ec2:CreateSubnet", "ec2:DeleteSubnet", "ec2:DescribeSubnets",
        "ec2:CreateInternetGateway", "ec2:DeleteInternetGateway",
        "ec2:AttachInternetGateway", "ec2:DetachInternetGateway",
        "ec2:DescribeInternetGateways",
        "ec2:AllocateAddress", "ec2:ReleaseAddress", "ec2:DescribeAddresses",
        "ec2:CreateNatGateway", "ec2:DeleteNatGateway", "ec2:DescribeNatGateways",
        "ec2:CreateRouteTable", "ec2:DeleteRouteTable", "ec2:DescribeRouteTables",
        "ec2:CreateRoute", "ec2:DeleteRoute",
        "ec2:AssociateRouteTable", "ec2:DisassociateRouteTable",
        "ec2:CreateTags", "ec2:DeleteTags", "ec2:DescribeTags",
        "ec2:DescribeAvailabilityZones",
        "ec2:ModifySubnetAttribute"
      ],
      "Resource": "*"
    }
  ]
}

6. Side Effects and Cost Implications

Side Effects

  • Creates VPC, which counts against the regional VPC limit (default: 5)
  • Allocates Elastic IP when NAT gateway is enabled
  • Creates route table entries that affect network routing for all resources in the VPC

Cost Implications

Resource Estimated Cost (us-east-1) Condition
VPC Free Always
Subnets Free Always
Internet Gateway Free (data transfer charges) Always
NAT Gateway ~$32/month + data transfer enable_nat_gateway
Elastic IP Free (when attached to NAT) enable_nat_gateway

Warning: NAT Gateway costs approximately $32/month. Set enable_nat_gateway = false for development environments where private subnet internet access is not required.

7. Idempotency Contract

  • First terraform apply: Creates all resources
  • Second terraform apply: No changes (0 added, 0 changed, 0 destroyed)
  • terraform destroy: Removes all resources cleanly with no orphans

8. Testing Requirements

All guarantees must have corresponding test coverage:

Test File Guarantees Tested Type
vpc_test.go G1, G2, G3, G4, G7, G8 Integration
vpc_test.go G5, G6, G10, G11 Integration
vpc_test.go G12 Idempotency

9. Breaking Changes Policy

  • Minor versions (1.x.0): New variables with defaults, new outputs
  • Patch versions (1.0.x): Bug fixes, documentation updates
  • Major versions (x.0.0): Renamed variables, removed outputs, changed defaults
  • Deprecation notice: Minimum 1 minor version before removal

10. Known Limitations

  • Single NAT gateway (not HA across AZs) - use terraform-aws-vpc-ha for multi-NAT
  • No IPv6 support
  • No VPC flow log configuration (consumer must add separately)
  • No VPC endpoints included
  • Subnet count must match AZ count

11. Support

  • Repository: github.com/your-org/terraform-aws-vpc
  • Issues: GitHub Issues
  • Slack: #platform-engineering

12. Usage Examples

Minimal

module "vpc" {
  source   = "github.com/your-org/terraform-aws-vpc?ref=v1.0.0"
  vpc_name = "my-app"
  vpc_cidr = "10.0.0.0/16"
}

Production

module "vpc" {
  source   = "github.com/your-org/terraform-aws-vpc?ref=v1.0.0"
  vpc_name = "prod-platform"
  vpc_cidr = "10.100.0.0/16"

  environment        = "production"
  availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]

  public_subnet_cidrs  = ["10.100.1.0/24", "10.100.2.0/24", "10.100.3.0/24"]
  private_subnet_cidrs = ["10.100.10.0/24", "10.100.11.0/24", "10.100.12.0/24"]

  enable_nat_gateway = true

  tags = {
    Team        = "platform-engineering"
    CostCenter  = "infrastructure"
    Compliance  = "soc2"
  }
}

Development (No NAT, Cost Savings)

module "vpc" {
  source   = "github.com/your-org/terraform-aws-vpc?ref=v1.0.0"
  vpc_name = "dev-sandbox"
  vpc_cidr = "10.200.0.0/16"

  environment        = "dev"
  enable_nat_gateway = false

  tags = {
    Team = "development"
  }
}

13. Test Mapping

Guarantee Test Function Test File
G1 TestVpcCreation vpc_test.go
G2 TestPublicSubnetsAcrossAZs vpc_test.go
G3 TestPrivateSubnetsAcrossAZs vpc_test.go
G4 TestInternetGatewayAttached vpc_test.go
G5 TestNatGatewayCreatedWhenEnabled vpc_test.go
G6 TestNatGatewaySkippedWhenDisabled vpc_test.go
G7 TestCommonTagsApplied vpc_test.go
G8 TestPublicSubnetsAutoAssignIP vpc_test.go
G9 TestPrivateSubnetsNoAutoAssignIP vpc_test.go
G10 TestPublicRouteTableDefaultRoute vpc_test.go
G11 TestPrivateRouteTableNatRoute vpc_test.go
G12 TestIdempotency vpc_test.go
G13 (Verified by code review) N/A
G14 (Verified by code review) N/A

Checkpoint 4

# Verify CONTRACT.md exists and has all sections
grep -c "^## " CONTRACT.md
# Expected output: 13

# Verify all guarantees are numbered
grep -c "^\- \*\*G[0-9]" CONTRACT.md
# Expected output: 14

# Verify test mapping table is complete
grep -c "G[0-9]" CONTRACT.md | head -1
# Expected: multiple references per guarantee

Step 5: Add Metadata (3 min)

Add @module metadata comments to each .tf file following the DevOps Engineering Style Guide metadata schema.

5.1 Metadata for main.tf

# @module terraform_aws_vpc
# @description Production-ready AWS VPC module with public/private subnets,
#   internet gateway, optional NAT gateway, and route tables
# @version 1.0.0
# @author Tyler Dukes
# @last_updated 2026-02-14
# @status stable

resource "aws_vpc" "main" {
  # ... (existing resource definitions follow)

5.2 Metadata for variables.tf

# @module terraform_aws_vpc_variables
# @description Input variable definitions for the AWS VPC module
# @version 1.0.0
# @author Tyler Dukes
# @last_updated 2026-02-14
# @status stable

variable "vpc_name" {
  # ... (existing variable definitions follow)

5.3 Metadata for outputs.tf

# @module terraform_aws_vpc_outputs
# @description Output definitions for the AWS VPC module
# @version 1.0.0
# @author Tyler Dukes
# @last_updated 2026-02-14
# @status stable

output "vpc_id" {
  # ... (existing output definitions follow)

5.4 Metadata for versions.tf

# @module terraform_aws_vpc_versions
# @description Provider and Terraform version constraints for the AWS VPC module
# @version 1.0.0
# @author Tyler Dukes
# @last_updated 2026-02-14
# @status stable

terraform {
  # ... (existing version constraints follow)

5.5 Metadata for locals.tf

# @module terraform_aws_vpc_locals
# @description Local values and computed tags for the AWS VPC module
# @version 1.0.0
# @author Tyler Dukes
# @last_updated 2026-02-14
# @status stable

locals {
  # ... (existing locals follow)

Checkpoint 5

# Validate metadata across all .tf files
python scripts/validate_metadata.py .

# Expected output:
# Validating metadata in .
# ✅ main.tf: Valid metadata found
# ✅ variables.tf: Valid metadata found
# ✅ outputs.tf: Valid metadata found
# ✅ versions.tf: Valid metadata found
# ✅ locals.tf: Valid metadata found
# Summary: 5 files validated, 0 errors

Step 6: Write Terratest Tests (10 min)

Create a Go test file that validates each guarantee from the CONTRACT.md.

6.1 Test Fixture

# test/fixtures/main.tf - Test fixture that invokes the module
terraform {
  required_version = ">= 1.6"

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

provider "aws" {
  region = "us-east-1"
}

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

  vpc_name    = "test-vpc"
  vpc_cidr    = "10.0.0.0/16"
  environment = "dev"

  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 = true

  tags = {
    TestRun = "terratest"
  }
}

output "vpc_id" {
  value = module.vpc.vpc_id
}

output "vpc_cidr_block" {
  value = module.vpc.vpc_cidr_block
}

output "public_subnet_ids" {
  value = module.vpc.public_subnet_ids
}

output "private_subnet_ids" {
  value = module.vpc.private_subnet_ids
}

output "internet_gateway_id" {
  value = module.vpc.internet_gateway_id
}

output "nat_gateway_id" {
  value = module.vpc.nat_gateway_id
}

output "public_route_table_id" {
  value = module.vpc.public_route_table_id
}

output "private_route_table_id" {
  value = module.vpc.private_route_table_id
}

output "nat_gateway_public_ip" {
  value = module.vpc.nat_gateway_public_ip
}

6.2 Test Fixture Without NAT

# test/fixtures-no-nat/main.tf - Test fixture with NAT disabled
terraform {
  required_version = ">= 1.6"

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

provider "aws" {
  region = "us-east-1"
}

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

  vpc_name    = "test-no-nat"
  vpc_cidr    = "10.1.0.0/16"
  environment = "dev"

  availability_zones   = ["us-east-1a", "us-east-1b"]
  public_subnet_cidrs  = ["10.1.1.0/24", "10.1.2.0/24"]
  private_subnet_cidrs = ["10.1.10.0/24", "10.1.11.0/24"]

  enable_nat_gateway = false

  tags = {
    TestRun = "terratest"
  }
}

output "vpc_id" {
  value = module.vpc.vpc_id
}

output "nat_gateway_id" {
  value = module.vpc.nat_gateway_id
}

output "private_route_table_id" {
  value = module.vpc.private_route_table_id
}

6.3 Go Module Setup

# Initialize Go module for tests
cd test
go mod init github.com/your-org/terraform-aws-vpc/test

# Add terratest dependency
go get github.com/gruntwork-io/terratest/modules/terraform
go get github.com/gruntwork-io/terratest/modules/aws
go get github.com/stretchr/testify/assert
go get github.com/stretchr/testify/require
// test/go.mod (generated, shown for reference)
{
  "module": "github.com/your-org/terraform-aws-vpc/test",
  "go": "1.21",
  "require": {
    "github.com/gruntwork-io/terratest": "v0.47.2",
    "github.com/stretchr/testify": "v1.9.0"
  }
}

6.4 Complete Test File

// test/vpc_test.go
package test

import (
 "fmt"
 "testing"

 "github.com/gruntwork-io/terratest/modules/aws"
 "github.com/gruntwork-io/terratest/modules/terraform"
 "github.com/stretchr/testify/assert"
 "github.com/stretchr/testify/require"
)

// ============================================================================
// Test: G1 - Creates exactly 1 VPC with DNS hostnames and DNS support enabled
// ============================================================================

func TestVpcCreation(t *testing.T) {
 t.Parallel()

 terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
  TerraformDir: "./fixtures",
 })

 defer terraform.Destroy(t, terraformOptions)
 terraform.InitAndApply(t, terraformOptions)

 vpcID := terraform.Output(t, terraformOptions, "vpc_id")
 require.NotEmpty(t, vpcID, "G1: VPC ID must not be empty")

 vpc := aws.GetVpcById(t, vpcID, "us-east-1")
 assert.True(t, vpc.EnableDnsHostnames, "G1: DNS hostnames must be enabled")
 assert.True(t, vpc.EnableDnsSupport, "G1: DNS support must be enabled")

 cidr := terraform.Output(t, terraformOptions, "vpc_cidr_block")
 assert.Equal(t, "10.0.0.0/16", cidr, "G1: VPC CIDR must match input")
}

// ============================================================================
// Test: G2 - Creates N public subnets across at least 2 AZs
// ============================================================================

func TestPublicSubnetsAcrossAZs(t *testing.T) {
 t.Parallel()

 terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
  TerraformDir: "./fixtures",
 })

 defer terraform.Destroy(t, terraformOptions)
 terraform.InitAndApply(t, terraformOptions)

 publicSubnetIDs := terraform.OutputList(t, terraformOptions, "public_subnet_ids")
 assert.Equal(t, 2, len(publicSubnetIDs), "G2: Must create 2 public subnets")

 // Verify subnets are in different AZs
 azSet := make(map[string]bool)
 for _, subnetID := range publicSubnetIDs {
  subnet := aws.GetSubnetById(t, subnetID, "us-east-1")
  azSet[subnet.AvailabilityZone] = true
 }
 assert.GreaterOrEqual(t, len(azSet), 2, "G2: Public subnets must span at least 2 AZs")
}

// ============================================================================
// Test: G3 - Creates N private subnets across at least 2 AZs
// ============================================================================

func TestPrivateSubnetsAcrossAZs(t *testing.T) {
 t.Parallel()

 terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
  TerraformDir: "./fixtures",
 })

 defer terraform.Destroy(t, terraformOptions)
 terraform.InitAndApply(t, terraformOptions)

 privateSubnetIDs := terraform.OutputList(t, terraformOptions, "private_subnet_ids")
 assert.Equal(t, 2, len(privateSubnetIDs), "G3: Must create 2 private subnets")

 azSet := make(map[string]bool)
 for _, subnetID := range privateSubnetIDs {
  subnet := aws.GetSubnetById(t, subnetID, "us-east-1")
  azSet[subnet.AvailabilityZone] = true
 }
 assert.GreaterOrEqual(t, len(azSet), 2, "G3: Private subnets must span at least 2 AZs")
}

// ============================================================================
// Test: G4 - Creates exactly 1 internet gateway attached to the VPC
// ============================================================================

func TestInternetGatewayAttached(t *testing.T) {
 t.Parallel()

 terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
  TerraformDir: "./fixtures",
 })

 defer terraform.Destroy(t, terraformOptions)
 terraform.InitAndApply(t, terraformOptions)

 igwID := terraform.Output(t, terraformOptions, "internet_gateway_id")
 require.NotEmpty(t, igwID, "G4: Internet gateway ID must not be empty")

 vpcID := terraform.Output(t, terraformOptions, "vpc_id")
 igws := aws.GetInternetGatewayById(t, igwID, "us-east-1")
 assert.Equal(t, vpcID, igws.VpcId, "G4: Internet gateway must be attached to the VPC")
}

// ============================================================================
// Test: G5 - NAT gateway created when enable_nat_gateway = true
// ============================================================================

func TestNatGatewayCreatedWhenEnabled(t *testing.T) {
 t.Parallel()

 terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
  TerraformDir: "./fixtures",
 })

 defer terraform.Destroy(t, terraformOptions)
 terraform.InitAndApply(t, terraformOptions)

 natGatewayID := terraform.Output(t, terraformOptions, "nat_gateway_id")
 require.NotEmpty(t, natGatewayID, "G5: NAT gateway ID must not be empty when enabled")

 publicIP := terraform.Output(t, terraformOptions, "nat_gateway_public_ip")
 assert.NotEmpty(t, publicIP, "G5: NAT gateway must have a public IP")
}

// ============================================================================
// Test: G6 - No NAT gateway when enable_nat_gateway = false
// ============================================================================

func TestNatGatewaySkippedWhenDisabled(t *testing.T) {
 t.Parallel()

 terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
  TerraformDir: "./fixtures-no-nat",
 })

 defer terraform.Destroy(t, terraformOptions)
 terraform.InitAndApply(t, terraformOptions)

 natGatewayID := terraform.Output(t, terraformOptions, "nat_gateway_id")
 assert.Empty(t, natGatewayID, "G6: NAT gateway must not be created when disabled")

 privateRTID := terraform.Output(t, terraformOptions, "private_route_table_id")
 assert.Empty(t, privateRTID, "G6: Private route table must not be created when NAT disabled")
}

// ============================================================================
// Test: G7 - All resources tagged with common tag set
// ============================================================================

func TestCommonTagsApplied(t *testing.T) {
 t.Parallel()

 terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
  TerraformDir: "./fixtures",
 })

 defer terraform.Destroy(t, terraformOptions)
 terraform.InitAndApply(t, terraformOptions)

 vpcID := terraform.Output(t, terraformOptions, "vpc_id")
 vpc := aws.GetVpcById(t, vpcID, "us-east-1")

 assert.Equal(t, "dev", vpc.Tags["Environment"], "G7: Environment tag must be set")
 assert.Equal(t, "terraform", vpc.Tags["ManagedBy"], "G7: ManagedBy tag must be set")
 assert.Equal(t, "terraform-aws-vpc", vpc.Tags["Module"], "G7: Module tag must be set")
 assert.Equal(t, "terratest", vpc.Tags["TestRun"], "G7: Custom tags must be merged")
}

// ============================================================================
// Test: G8 - Public subnets auto-assign public IPs
// ============================================================================

func TestPublicSubnetsAutoAssignIP(t *testing.T) {
 t.Parallel()

 terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
  TerraformDir: "./fixtures",
 })

 defer terraform.Destroy(t, terraformOptions)
 terraform.InitAndApply(t, terraformOptions)

 publicSubnetIDs := terraform.OutputList(t, terraformOptions, "public_subnet_ids")
 for _, subnetID := range publicSubnetIDs {
  subnet := aws.GetSubnetById(t, subnetID, "us-east-1")
  assert.True(t, subnet.MapPublicIpOnLaunch,
   fmt.Sprintf("G8: Public subnet %s must auto-assign public IPs", subnetID))
 }
}

// ============================================================================
// Test: G12 - Idempotency - second apply produces no changes
// ============================================================================

func TestIdempotency(t *testing.T) {
 t.Parallel()

 terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
  TerraformDir: "./fixtures",
 })

 defer terraform.Destroy(t, terraformOptions)

 // First apply
 terraform.InitAndApply(t, terraformOptions)

 // Second apply - should produce no changes
 planOutput := terraform.Plan(t, terraformOptions)
 assert.Contains(t, planOutput, "No changes",
  "G12: Second apply must produce no changes (idempotency)")
}

6.5 Test Helper for Parallel Test Suites

// test/helpers_test.go
package test

import (
 "fmt"
 "math/rand"
 "strings"
 "time"
)

// uniqueID generates a unique suffix for test resource names to avoid conflicts
// when running tests in parallel
func uniqueID() string {
 r := rand.New(rand.NewSource(time.Now().UnixNano()))
 return fmt.Sprintf("%06d", r.Intn(999999))
}

// formatTestName creates a descriptive test name with guarantee reference
func formatTestName(guarantee string, description string) string {
 return fmt.Sprintf("[%s] %s", guarantee, description)
}

// assertTagsContain verifies that a tag map contains all expected key-value pairs
func assertTagsContain(tags map[string]string, expected map[string]string) []string {
 var missing []string
 for k, v := range expected {
  actual, ok := tags[k]
  if !ok {
   missing = append(missing, fmt.Sprintf("missing tag key: %s", k))
  } else if actual != v {
   missing = append(missing, fmt.Sprintf("tag %s: expected %q, got %q", k, v, actual))
  }
 }
 return missing
}

// containsSubstring checks if any string in a slice contains the given substring
func containsSubstring(slice []string, substr string) bool {
 for _, s := range slice {
  if strings.Contains(s, substr) {
   return true
  }
 }
 return false
}

Checkpoint 6

# Verify test file structure
tree test/
# Expected output:
# test/
# ├── fixtures/
# │   └── main.tf
# ├── fixtures-no-nat/
# │   └── main.tf
# ├── go.mod
# ├── go.sum
# ├── helpers_test.go
# └── vpc_test.go

# Verify Go compilation (does not run tests)
cd test && go vet ./...
# Expected: no errors

# Run the tests (requires AWS credentials)
cd test && go test -v -timeout 30m -run TestVpcCreation
# Expected: PASS

Step 7: Set Up CI/CD (5 min)

Create a GitHub Actions workflow that runs formatting checks, validation, planning, and tests on every pull request.

7.1 CI Workflow

# .github/workflows/ci.yml
name: Terraform Module CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions:
  contents: read

env:
  TF_VERSION: "1.9.0"
  GO_VERSION: "1.21"

jobs:
  # ──────────────────────────────────────────────────────────────
  # Stage 1: Format and Validate
  # ──────────────────────────────────────────────────────────────
  format-and-validate:
    name: Format & Validate
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Terraform Format Check
        run: terraform fmt -check -recursive -diff

      - name: Terraform Init
        run: terraform init -backend=false

      - name: Terraform Validate
        run: terraform validate

  # ──────────────────────────────────────────────────────────────
  # Stage 2: Security Scanning
  # ──────────────────────────────────────────────────────────────
  security:
    name: Security Scan
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Run tfsec
        uses: aquasecurity/tfsec-action@v1.0.3
        with:
          working_directory: .
          soft_fail: false

      - name: Run checkov
        uses: bridgecrewio/checkov-action@v12
        with:
          directory: .
          framework: terraform
          quiet: true

  # ──────────────────────────────────────────────────────────────
  # Stage 3: Terraform Plan
  # ──────────────────────────────────────────────────────────────
  plan:
    name: Terraform Plan
    runs-on: ubuntu-latest
    needs: [format-and-validate]
    if: github.event_name == 'pull_request'
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: us-east-1

      - name: Terraform Init
        run: |
          cd test/fixtures
          terraform init

      - name: Terraform Plan
        run: |
          cd test/fixtures
          terraform plan -no-color -input=false
        continue-on-error: true

  # ──────────────────────────────────────────────────────────────
  # Stage 4: Integration Tests
  # ──────────────────────────────────────────────────────────────
  test:
    name: Integration Tests
    runs-on: ubuntu-latest
    needs: [format-and-validate, security]
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}
          terraform_wrapper: false

      - name: Setup Go
        uses: actions/setup-go@v5
        with:
          go-version: ${{ env.GO_VERSION }}
          cache-dependency-path: test/go.sum

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: us-east-1

      - name: Run Terratest
        working-directory: test
        run: |
          go mod download
          go test -v -timeout 30m -count=1 ./...
        env:
          AWS_DEFAULT_REGION: us-east-1

  # ──────────────────────────────────────────────────────────────
  # Stage 5: Documentation
  # ──────────────────────────────────────────────────────────────
  docs:
    name: Generate Docs
    runs-on: ubuntu-latest
    needs: [format-and-validate]
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.ref }}

      - name: Generate terraform-docs
        uses: terraform-docs/gh-actions@v1
        with:
          working-dir: .
          output-file: README.md
          output-method: inject
          git-push: "true"

7.2 Pre-commit Configuration

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/antonbabenko/pre-commit-tf
    rev: v1.96.1
    hooks:
      - id: terraform_fmt
      - id: terraform_validate
      - id: terraform_docs
        args:
          - --args=--config=.terraform-docs.yml
      - id: terraform_tflint
        args:
          - --args=--config=__GIT_WORKING_DIR__/.tflint.hcl

  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-merge-conflict
      - id: detect-private-key

7.3 terraform-docs Configuration

# .terraform-docs.yml
formatter: markdown table

header-from: doc-header.md

sections:
  show:
    - header
    - requirements
    - providers
    - inputs
    - outputs
    - resources

content: ""

output:
  file: README.md
  mode: inject
  template: |-
    <!-- BEGIN_TF_DOCS -->
    {{ .Content }}
    <!-- END_TF_DOCS -->

sort:
  enabled: true
  by: required

Checkpoint 7

# Verify workflow file is valid YAML
python -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml'))" && echo "Valid YAML"
# Expected: Valid YAML

# Verify pre-commit config
pre-commit validate-config
# Expected: no errors

# Run pre-commit hooks locally
pre-commit run --all-files
# Expected: all checks pass

# Dry-run the CI pipeline stages locally
terraform fmt -check -recursive -diff
terraform init -backend=false
terraform validate
# Expected: all pass

Step 8: Publish to Registry (2 min)

8.1 Tag and Release

# Ensure you are on main with all changes committed
git checkout main
git pull origin main

# Create a semantic version tag
git tag -a v1.0.0 -m "feat: initial release of terraform-aws-vpc module

Compliant with DevOps Engineering Style Guide:
- Full CONTRACT.md with 14 guarantees
- Terratest coverage for G1-G12
- Variable validation for all inputs
- Metadata tags on all .tf files
- CI/CD pipeline with format, validate, security, and test stages"

# Push the tag
git push origin v1.0.0

8.2 Module Source References

# Reference by Git tag (recommended)
module "vpc" {
  source = "git::https://github.com/your-org/terraform-aws-vpc.git?ref=v1.0.0"

  vpc_name = "my-app"
  vpc_cidr = "10.0.0.0/16"
}
# Reference from Terraform Registry (if published)
module "vpc" {
  source  = "your-org/vpc/aws"
  version = "1.0.0"

  vpc_name = "my-app"
  vpc_cidr = "10.0.0.0/16"
}

8.3 Registry Naming Convention

Terraform Registry Module Naming Convention
════════════════════════════════════════════
Repository name:   terraform-{provider}-{name}
Example:           terraform-aws-vpc

Required files:
  ├── main.tf          (required)
  ├── variables.tf     (required)
  ├── outputs.tf       (required)
  ├── versions.tf      (recommended)
  ├── README.md        (required - auto-generated by terraform-docs)
  ├── CONTRACT.md      (required by DevOps Engineering Style Guide)
  ├── LICENSE           (required)
  └── examples/
      └── complete/
          └── main.tf  (recommended)

Checkpoint: Final Verification

Run through this checklist to confirm full compliance.

# 1. File structure
echo "=== File Structure ==="
find . -name "*.tf" -o -name "*.go" -o -name "*.md" -o -name "*.yml" | \
  grep -v ".terraform" | sort

# Expected:
# ./.github/workflows/ci.yml
# ./.pre-commit-config.yaml
# ./.terraform-docs.yml
# ./CONTRACT.md
# ./locals.tf
# ./main.tf
# ./outputs.tf
# ./test/fixtures-no-nat/main.tf
# ./test/fixtures/main.tf
# ./test/helpers_test.go
# ./test/vpc_test.go
# ./variables.tf
# ./versions.tf
# 2. Formatting
echo "=== Formatting ==="
terraform fmt -check -recursive .
echo "Format check exit code: $?"
# Expected: 0
# 3. Validation
echo "=== Validation ==="
terraform init -backend=false
terraform validate
# Expected: Success! The configuration is valid.
# 4. Metadata
echo "=== Metadata ==="
for f in main.tf variables.tf outputs.tf versions.tf locals.tf; do
  if grep -q "@module" "$f"; then
    echo "✅ $f has metadata"
  else
    echo "❌ $f missing metadata"
  fi
done
# Expected: all ✅
# 5. CONTRACT.md guarantees
echo "=== CONTRACT.md ==="
echo "Guarantees defined: $(grep -c '^\- \*\*G[0-9]' CONTRACT.md)"
echo "Sections: $(grep -c '^## ' CONTRACT.md)"
# Expected: 14 guarantees, 13 sections
# 6. Test coverage
echo "=== Test Coverage ==="
grep -c "^func Test" test/vpc_test.go
# Expected: 8 test functions

echo "Guarantees covered by tests:"
grep -oP 'G\d+' test/vpc_test.go | sort -u
# Expected: G1, G2, G3, G4, G5, G6, G7, G8, G12
# 7. Variable validation
echo "=== Variable Validation ==="
grep -c "validation {" variables.tf
# Expected: 10 validation blocks
Final Compliance Checklist
══════════════════════════
[x] terraform fmt passes with no changes
[x] All files follow standard layout (main.tf, variables.tf, outputs.tf, versions.tf, locals.tf)
[x] All variables have descriptions and types
[x] All variables have validation blocks where applicable
[x] All outputs have descriptions
[x] All resources use snake_case naming
[x] All resources tagged with common tags via locals
[x] CONTRACT.md exists with numbered guarantees
[x] @module metadata on all .tf files
[x] Terratest tests cover all testable guarantees
[x] CI/CD workflow covers format, validate, security, test, and docs
[x] Pre-commit hooks configured
[x] Semantic version tag created
[x] No hardcoded values (uses variables with validation)
[x] Deprecated resource arguments updated (vpc = true -> domain = "vpc")

Common Troubleshooting

Problem: terraform fmt reports changes after manual editing

# Symptom
terraform fmt -check .
# Exit code 3, shows diff

# Cause: Editor inserted tabs instead of spaces, or misaligned = signs

# Solution: Run terraform fmt to auto-fix
terraform fmt -recursive .

# Prevention: Configure your editor
# VS Code: settings.json
# "editor.formatOnSave": true,
# "[terraform]": { "editor.defaultFormatter": "hashicorp.terraform" }

Problem: terraform validate fails with provider errors

# Symptom
terraform validate
# Error: Missing required provider

# Cause: Running validate without init, or wrong provider version

# Solution: Initialize without backend first
terraform init -backend=false
terraform validate

# If provider version conflict:
rm -rf .terraform .terraform.lock.hcl
terraform init -backend=false

Problem: Terratest times out during apply

# Symptom
go test -v -timeout 10m ./...
# panic: test timed out after 10m

# Cause: NAT gateway creation takes 2-5 minutes, total test > 10 min

# Solution: Increase timeout
go test -v -timeout 30m ./...

# For CI, set in workflow:
# run: go test -v -timeout 30m -count=1 ./...

Problem: Validation blocks reject valid input

# Symptom
# Error: The vpc_cidr prefix length must be between /16 and /24.
# Input: "10.0.0.0/8"

# Cause: Validation rule restricts to /16-/24 range for safety

# Solution: If /8 is intentional, update the validation rule in variables.tf
variable "vpc_cidr" {
  # ...
  validation {
    condition     = tonumber(split("/", var.vpc_cidr)[1]) >= 8 && tonumber(split("/", var.vpc_cidr)[1]) <= 24
    error_message = "The vpc_cidr prefix length must be between /8 and /24."
  }
}

Problem: Tests fail with "no default VPC" or credential errors

# Symptom
TestVpcCreation 2026/02/14 10:00:00 retry.go:91:
# error: NoCredentialProviders

# Cause: AWS credentials not configured for test environment

# Solution: Set AWS credentials
export AWS_ACCESS_KEY_ID="your-key"
export AWS_SECRET_ACCESS_KEY="your-secret"
export AWS_DEFAULT_REGION="us-east-1"

# Or use AWS SSO
aws sso login --profile your-profile
export AWS_PROFILE="your-profile"

# Verify credentials
aws sts get-caller-identity

Problem: Pre-commit terraform_docs hook fails

# Symptom
terraform_docs..........................................................Failed
# Error: Could not find .terraform-docs.yml

# Cause: Missing terraform-docs config file

# Solution: Create .terraform-docs.yml at repository root
# (See Step 7.3 for the configuration)

# Verify
terraform-docs markdown table --config .terraform-docs.yml .

Problem: CONTRACT.md guarantee numbers are not sequential

# Symptom: G1, G2, G4, G5 (missing G3)

# Cause: Guarantee was removed without renumbering

# Solution: Always renumber sequentially, and update test mapping
# Use grep to find all references:
grep -rn "G3" . --include="*.go" --include="*.md"
# Update all references to maintain sequential numbering

Next Steps

After completing this tutorial, you have a fully compliant Terraform module. Here are recommended next steps:

Recommended Path
════════════════
1. Add VPC Flow Logs           → Extend the module with optional flow log support
2. Multi-NAT HA                → Create a high-availability variant with per-AZ NAT gateways
3. VPC Endpoints               → Add optional S3 and DynamoDB gateway endpoints
4. Tutorial 3: Full-Stack App  → Build on this VPC module in a complete application stack
5. Team Onboarding             → Use Tutorial 4 to onboard your team to the style guide
Related Style Guide References
══════════════════════════════
- Terraform Style Guide        → docs/02_language_guides/terraform.md
- CONTRACT.md Template         → docs/04_templates/contract_template.md
- IaC Testing Standards        → docs/05_ci_cd/iac_testing.md
- Metadata Schema              → docs/03_metadata_schema/
- CI/CD Standards              → docs/05_ci_cd/