What You'll Build
You will translate the architecture from M6 Lesson 2 into working Terraform code — provisioning the VPC with three-tier subnets across three AZs, IAM roles and policies for all services, the S3 ingestion bucket with event notification, the Lambda processor with DynamoDB and RDS write access, the RDS PostgreSQL Multi-AZ instance, the DynamoDB deliveries table, the EC2 Auto Scaling Group with ALB, and the Route 53 DNS records with health checks. This lab is the implementation phase of the capstone — turn your architecture decision into running cloud infrastructure.
Prerequisites
- Terraform 1.6+ installed — terraform version
- AWS CLI configured with Administrator access for initial provisioning
- Completed M6 Lesson 2: architecture diagram and ADRs produced
- An AWS account with billing enabled — estimated cost $5-15 for this lab if torn down within 4 hours
- Git repository initialised for version-controlling the Terraform code
Setup — Project Structure and Remote State
#!/bin/bash
# Set up Terraform project structure
mkdir -p ~/cricket_capstone/{modules/{vpc,iam,database,compute,dns},environments/production}
cd ~/cricket_capstone
# Bootstrap: create S3 backend and DynamoDB lock table
AWS_ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
STATE_BUCKET="cricket-tf-state-${AWS_ACCOUNT}"
LOCK_TABLE='cricket-tf-locks'
aws s3api create-bucket \
--bucket "$STATE_BUCKET" \
--region ap-south-1 \
--create-bucket-configuration LocationConstraint=ap-south-1 2>/dev/null || true
aws s3api put-bucket-versioning \
--bucket "$STATE_BUCKET" \
--versioning-configuration Status=Enabled
aws dynamodb create-table \
--table-name "$LOCK_TABLE" \
--billing-mode PAY_PER_REQUEST \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--region ap-south-1 2>/dev/null || true
echo "State bucket: ${STATE_BUCKET}"
echo "Lock table: ${LOCK_TABLE}"
echo
echo 'Project structure:'
find ~/cricket_capstone -type d | head -15Step 1 — VPC and Networking Module
Implement the VPC module first — all other modules depend on it for subnet IDs, security group IDs and VPC ID outputs. A well-designed VPC module encapsulates all network resources and exposes only the IDs needed by other modules, creating clean separation of concerns.
# modules/vpc/main.tf — VPC module
terraform {
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}
variable "vpc_cidr" { default = "10.0.0.0/16" }
variable "environment" { type = string }
variable "azs" { default = ["ap-south-1a", "ap-south-1b", "ap-south-1c"] }
locals {
public_cidrs = ["10.0.0.0/24", "10.0.1.0/24", "10.0.2.0/24"]
private_cidrs = ["10.0.10.0/22", "10.0.14.0/22", "10.0.18.0/22"]
data_cidrs = ["10.0.30.0/24", "10.0.31.0/24", "10.0.32.0/24"]
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = { Name = "cricket-${var.environment}" }
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = { Name = "cricket-igw-${var.environment}" }
}
resource "aws_subnet" "public" {
count = 3
vpc_id = aws_vpc.main.id
cidr_block = local.public_cidrs[count.index]
availability_zone = var.azs[count.index]
map_public_ip_on_launch = true
tags = { Name = "cricket-public-${count.index}-${var.environment}", Tier = "public" }
}
resource "aws_subnet" "private" {
count = 3
vpc_id = aws_vpc.main.id
cidr_block = local.private_cidrs[count.index]
availability_zone = var.azs[count.index]
tags = { Name = "cricket-private-${count.index}-${var.environment}", Tier = "private" }
}
resource "aws_subnet" "data" {
count = 3
vpc_id = aws_vpc.main.id
cidr_block = local.data_cidrs[count.index]
availability_zone = var.azs[count.index]
tags = { Name = "cricket-data-${count.index}-${var.environment}", Tier = "data" }
}
# NAT Gateways — one per AZ for high availability
resource "aws_eip" "nat" {
count = 3
domain = "vpc"
tags = { Name = "cricket-nat-eip-${count.index}" }
}
resource "aws_nat_gateway" "main" {
count = 3
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
tags = { Name = "cricket-nat-${count.index}" }
depends_on = [aws_internet_gateway.main]
}
# Route tables
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 = { Name = "cricket-public-rt" }
}
resource "aws_route_table" "private" {
count = 3
vpc_id = aws_vpc.main.id
route { cidr_block = "0.0.0.0/0"; nat_gateway_id = aws_nat_gateway.main[count.index].id }
tags = { Name = "cricket-private-rt-${count.index}" }
}
# Associations
resource "aws_route_table_association" "public" {
count = 3
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private" {
count = 3
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private[count.index].id
}
# VPC Endpoints — eliminate NAT costs for S3 and DynamoDB
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.ap-south-1.s3"
route_table_ids = aws_route_table.private[*].id
}
resource "aws_vpc_endpoint" "dynamodb" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.ap-south-1.dynamodb"
route_table_ids = aws_route_table.private[*].id
}
# VPC Flow Logs
resource "aws_flow_log" "main" {
vpc_id = aws_vpc.main.id
traffic_type = "ALL"
iam_role_arn = aws_iam_role.flow_logs.arn
log_destination = aws_cloudwatch_log_group.flow_logs.arn
}
resource "aws_cloudwatch_log_group" "flow_logs" {
name = "/aws/vpc/cricket-${var.environment}"
retention_in_days = 7
}
resource "aws_iam_role" "flow_logs" {
name = "cricket-vpc-flow-logs-${var.environment}"
assume_role_policy = jsonencode({
Statement = [{ Effect = "Allow", Principal = { Service = "vpc-flow-logs.amazonaws.com" }, Action = "sts:AssumeRole" }]
})
}
resource "aws_iam_role_policy" "flow_logs" {
role = aws_iam_role.flow_logs.id
policy = jsonencode({
Statement = [{ Effect = "Allow", Action = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"], Resource = "*" }]
})
}
# Outputs for other modules
output "vpc_id" { value = aws_vpc.main.id }
output "public_subnet_ids" { value = aws_subnet.public[*].id }
output "private_subnet_ids" { value = aws_subnet.private[*].id }
output "data_subnet_ids" { value = aws_subnet.data[*].id }Step 2 — Database, Lambda and Compute Modules
After the VPC module, implement the database module (RDS + DynamoDB), the Lambda ingestion processor, and the compute module (ALB + ASG) in that order. The database must exist before the compute module can reference the RDS endpoint in instance user data. Each module follows the same pattern: variables for inputs, resources for AWS objects, outputs for values other modules need.
# modules/database/main.tf — DynamoDB and RDS
variable "vpc_id" { type = string }
variable "subnet_ids" { type = list(string) }
variable "app_sg_id" { type = string }
variable "environment" { type = string }
# DynamoDB — live scores
resource "aws_dynamodb_table" "deliveries" {
name = "cricket-deliveries-${var.environment}"
billing_mode = "PAY_PER_REQUEST"
hash_key = "match_id"
range_key = "ball_ref"
attribute {
name = "match_id"
type = "S"
}
attribute {
name = "ball_ref"
type = "S"
}
point_in_time_recovery { enabled = true }
server_side_encryption { enabled = true }
stream_enabled = true
stream_view_type = "NEW_AND_OLD_IMAGES"
tags = { Name = "cricket-deliveries", Environment = var.environment }
}
# Security Group for RDS
resource "aws_security_group" "rds" {
name = "cricket-rds-sg-${var.environment}"
vpc_id = var.vpc_id
description = "Cricket RDS: PostgreSQL from app SG only"
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [var.app_sg_id]
}
}
resource "aws_db_subnet_group" "main" {
name = "cricket-db-subnet-group-${var.environment}"
subnet_ids = var.subnet_ids
}
# RDS PostgreSQL — analytics
resource "aws_db_instance" "main" {
identifier = "cricket-analytics-${var.environment}"
engine = "postgres"
engine_version = "16.1"
instance_class = "db.t3.medium"
allocated_storage = 100
storage_type = "gp3"
storage_encrypted = true
db_name = "cricket_analytics"
username = "cricket_admin"
password = random_password.db.result
multi_az = true
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.rds.id]
backup_retention_period = 7
deletion_protection = true
skip_final_snapshot = false
final_snapshot_identifier = "cricket-final-${var.environment}"
performance_insights_enabled = true
enabled_cloudwatch_logs_exports = ["postgresql"]
}
resource "random_password" "db" {
length = 32
special = true
}
# Store DB password in Secrets Manager
resource "aws_secretsmanager_secret" "db_password" {
name = "cricket/${var.environment}/db-password"
}
resource "aws_secretsmanager_secret_version" "db_password" {
secret_id = aws_secretsmanager_secret.db_password.id
secret_string = random_password.db.result
}
output "rds_endpoint" { value = aws_db_instance.main.endpoint }
output "dynamodb_table_name" { value = aws_dynamodb_table.deliveries.name }
output "db_secret_arn" { value = aws_secretsmanager_secret.db_password.arn }Step 3 — Root Module and Deployment
# environments/production/main.tf — root module
terraform {
required_version = ">= 1.6"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
backend "s3" {
bucket = "cricket-tf-state-ACCOUNT_ID"
key = "production/terraform.tfstate"
region = "ap-south-1"
dynamodb_table = "cricket-tf-locks"
encrypt = true
}
}
provider "aws" {
region = "ap-south-1"
default_tags {
tags = {
Project = "cricket-analytics"
Environment = "production"
ManagedBy = "terraform"
}
}
}
module "vpc" {
source = "../../modules/vpc"
environment = "production"
}
# Security Groups (defined in root, passed to modules)
resource "aws_security_group" "alb" {
name = "cricket-alb-sg"
vpc_id = module.vpc.vpc_id
description = "Cricket ALB: HTTPS from internet"
ingress { from_port = 443; to_port = 443; protocol = "tcp"; cidr_blocks = ["0.0.0.0/0"] }
ingress { from_port = 80; to_port = 80; protocol = "tcp"; cidr_blocks = ["0.0.0.0/0"] }
egress { from_port = 0; to_port = 0; protocol = "-1"; cidr_blocks = ["0.0.0.0/0"] }
}
resource "aws_security_group" "app" {
name = "cricket-app-sg"
vpc_id = module.vpc.vpc_id
description = "Cricket App: 8080 from ALB only"
ingress { from_port = 8080; to_port = 8080; protocol = "tcp"; security_groups = [aws_security_group.alb.id] }
egress { from_port = 0; to_port = 0; protocol = "-1"; cidr_blocks = ["0.0.0.0/0"] }
}
module "database" {
source = "../../modules/database"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.data_subnet_ids
app_sg_id = aws_security_group.app.id
environment = "production"
}
# Outputs
output "alb_dns" { value = "(from compute module)" }
output "rds_endpoint" { value = module.database.rds_endpoint }
output "ddb_table" { value = module.database.dynamodb_table_name }Step 4 — Apply, Verify and Document
#!/bin/bash
cd ~/cricket_capstone/environments/production
# Update backend bucket name
ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
sed -i "s/ACCOUNT_ID/${ACCOUNT}/" main.tf
# Initialise
terraform init
# Validate syntax
terraform validate
# Plan — review what will be created
terraform plan -out=cricket_prod.tfplan 2>&1 | tail -20
# Apply (takes ~10 minutes for RDS Multi-AZ)
terraform apply cricket_prod.tfplan
# Verify outputs
terraform output
# Verify VPC and subnets
VPC_ID=$(terraform output -raw vpc_id 2>/dev/null || \
aws ec2 describe-vpcs \
--filters 'Name=tag:Project,Values=cricket-analytics' \
--query 'Vpcs[0].VpcId' --output text)
echo "VPC: ${VPC_ID}"
aws ec2 describe-subnets \
--filters "Name=vpc-id,Values=${VPC_ID}" \
--query 'Subnets[*].{CIDR:CidrBlock,AZ:AvailabilityZone,Tag:Tags[?Key==`Tier`].Value|[0]}' \
--output table
# TEARDOWN — run when done with lab
# terraform destroy -auto-approve
# Note: RDS with deletion_protection=true requires manual disabling first:
# aws rds modify-db-instance --db-instance-identifier cricket-analytics-production \
# --no-deletion-protection --apply-immediately
# Then re-run: terraform destroyWarning: This lab creates an RDS Multi-AZ instance ($0.136/hour = ~$98/month) and 3 NAT Gateways ($0.045/hour each = ~$97/month combined). For a 4-hour lab session, estimated cost is approximately $3-5. Run terraform destroy immediately after completing the lab. RDS deletion_protection = true prevents accidental deletion — you must set it to false before terraform destroy will succeed. This is intentional: never disable deletion protection on production databases without an explicit review process.
Extension Challenge: Complete the full Terraform implementation by adding the compute module (ALB + Launch Template + ASG), the Lambda ingestion processor, and the Route 53 module. Then implement a GitHub Actions CI/CD pipeline that runs terraform plan on every pull request and terraform apply on merge to main — this is the production workflow used by engineering teams at Stripe, Airbnb and thousands of other companies. The full implementation should take 3-4 hours and produces a portfolio-worthy capstone project.
- Bootstrap the Terraform state backend (S3 bucket + DynamoDB table) before writing any other Terraform code — state cannot be migrated between backends without manual steps.
- Implement and test modules in dependency order: VPC first, then IAM, then databases, then compute — this ensures each module's outputs are available when the next module is applied.
- Use random_password and Secrets Manager for database passwords — never put passwords in Terraform variables (they appear in state files and CLI history) or hardcode them in code.
- Use deletion_protection = true on all production databases and lifecycle { prevent_destroy = true } on irreplaceable resources — these prevent terraform destroy from deleting critical data.
- The count = 3 pattern with a list index creates three resources (subnets, NAT Gateways, etc.) in one resource block — cleaner than three separate resource blocks and easier to update.
- Always run terraform plan -out=file before terraform apply in production — this ensures the apply executes exactly what was reviewed, even if the configuration changes between plan and apply.