100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Linux & Shell Scripting
90 minbeginner

Lab — Provision Core Infrastructure with Terraform

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.

Analogy🏏Cricket
🏏 Think of it like cricket: This script is like the pre-match ground inspection conducted by the match referee, pitch curator, and captains before a Test match begins. Before Rohit Sharma and the opposition captain walk out for the toss, the curator has measured pitch moisture, taken grass length readings, and documented the surface condition — creating a baseline against which any afternoon deterioration can be measured.Just as this structured inspection prevents surprises and creates a documented record, the inventory script creates a documented baseline for a server against which future anomalies can be compared. Just as a ground inspection without a checklist might miss a drainage issue that affects the afternoon session, a server assessment without a structured script might miss a nearly-full disk that causes a midnight deployment failure.The insight is that the value of a structured inspection is not just the current findings but the reproducible method — the same script run tomorrow highlights exactly what changed.

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

bash
#!/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 -15

Step 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.

hcl
# 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.

hcl
# 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

hcl
# 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

bash
#!/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 destroy

Warning: 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.
Lesson 38 of 40
0% complete