This capstone provisions the complete CricketPulse production infrastructure from a blank AWS account — everything needed to run a live cricket scores and statistics platform that handles IPL Final-scale traffic. The infrastructure includes: a custom VPC with public and private subnets across two availability zones; an EKS cluster (or ECS as an alternative) for containerised application workloads; an RDS PostgreSQL instance for persistent match data; an ElastiCache Redis cluster for live score caching; an S3 bucket for media assets; all networking (NAT gateways, security groups, route tables); IAM roles for the application; and secrets managed via AWS Secrets Manager. The entire infrastructure is built as a set of reusable Terraform modules in a well-structured repository, deployed through a CI/CD pipeline, with separate dev and production configurations. This project demonstrates mastery of every Terraform skill from this course working together in a realistic, production-grade scenario.
Learning Objectives
- Design and implement a multi-module Terraform repository structure with shared modules (vpc, eks-cluster, rds, elasticache) and environment-specific root modules (environments/dev, environments/production).
- Provision a production-grade VPC with public subnets (load balancers), private subnets (application and database tiers), NAT gateways for private subnet internet access, and VPC endpoints for S3 and Secrets Manager.
- Deploy an EKS cluster using the `terraform-aws-modules/eks/aws` community module with managed node groups, appropriate IAM roles, and OIDC provider for service account authentication.
- Manage database credentials and API secrets using AWS Secrets Manager with Terraform data sources, ensuring no secrets appear in configuration files or CI/CD logs.
- Implement the directory-per-environment pattern with separate state files, backend configurations, and variable values for dev (minimal resources, t3.micro instances) and production (HA configuration, production instance sizes).
- Build and validate a GitHub Actions CI/CD pipeline that runs terraform plan on pull requests (with OIDC authentication and PR comment output) and terraform apply on merge (with manual approval and saved plan file).
Technical Requirements
- Repository structure: `modules/` (vpc, eks-cluster, rds, elasticache, s3-media), `environments/dev/` and `environments/production/` each with `main.tf`, `variables.tf`, `outputs.tf`, `backend.tf`, and `terraform.tfvars.example`.
- VPC module: creates VPC, 2 public subnets (AZs: ap-south-1a, ap-south-1b), 2 private subnets (app tier), 2 database subnets (db tier), internet gateway, NAT gateway (1 in dev, 2 in production for HA), and appropriate route tables.
- EKS module: provisions EKS control plane, managed node group (t3.small × 1 in dev; t3.large × 3 in production with autoscaling 3–10), IAM OIDC provider, cluster IAM role, node IAM role with required managed policies.
- RDS module: PostgreSQL 15, db.t3.micro (dev) / db.r6g.large Multi-AZ (production), subnet group in db subnets, security group allowing access only from app-tier security group, automated backups (7-day retention in production).
- ElastiCache module: Redis 7, cache.t3.micro × 1 (dev) / cache.r6g.large × 2 in cluster mode (production), subnet group in private subnets, security group allowing access only from app-tier security group.
- Secrets: DB password and API secret stored in AWS Secrets Manager, retrieved via data sources — zero plaintext secrets in any Terraform file or CI/CD log.
- State: separate S3 + DynamoDB backend per environment, both buckets with KMS encryption, versioning, and public access blocked.
- CI/CD: GitHub Actions plan job (PR, OIDC plan role, fmt/validate/plan, PR comment) and apply job (main merge, OIDC apply role, saved plan, production environment approval gate).
Architecture & Design
The CricketPulse cluster uses a three-tier VPC architecture. The public tier (public subnets) hosts the Application Load Balancer that receives external HTTPS traffic. The application tier (private subnets) hosts the EKS worker nodes running CricketPulse API pods. The data tier (database subnets — a special private subnet with no route to the NAT gateway) hosts the RDS PostgreSQL instance and ElastiCache Redis cluster. Security groups enforce tier-to-tier access: the ALB security group allows HTTPS from 0.0.0.0/0; the EKS node security group allows traffic only from the ALB security group; the RDS security group allows PostgreSQL only from the EKS node security group; the Redis security group allows port 6379 only from the EKS node security group. No tier can be accessed except through its designated entry point.
# Repository structure and VPC module
# Directory structure
cricketpulse-infra/
modules/
vpc/
main.tf # VPC, subnets, IGW, NAT, route tables
variables.tf
outputs.tf
eks-cluster/
main.tf
variables.tf
outputs.tf
rds/
main.tf
variables.tf
outputs.tf
elasticache/
main.tf
variables.tf
outputs.tf
environments/
dev/
main.tf
backend.tf
terraform.tfvars.example # Committed placeholder
production/
main.tf
backend.tf
terraform.tfvars.example
.github/workflows/
terraform.yml
# modules/vpc/main.tf
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(var.common_tags, { Name = "${var.name}-vpc" })
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = merge(var.common_tags, { Name = "${var.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(var.common_tags, {
Name = "${var.name}-public-${count.index + 1}"
"kubernetes.io/role/elb" = "1" # Required for EKS ALB controller
})
}
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(var.common_tags, {
Name = "${var.name}-private-${count.index + 1}"
"kubernetes.io/role/internal-elb" = "1"
})
}
resource "aws_subnet" "database" {
count = length(var.db_subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = var.db_subnet_cidrs[count.index]
availability_zone = var.availability_zones[count.index]
tags = merge(var.common_tags, { Name = "${var.name}-db-${count.index + 1}" })
}
resource "aws_eip" "nat" {
count = var.nat_gateway_count
domain = "vpc"
tags = merge(var.common_tags, { Name = "${var.name}-nat-${count.index + 1}" })
}
resource "aws_nat_gateway" "main" {
count = var.nat_gateway_count
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
tags = merge(var.common_tags, { Name = "${var.name}-nat-${count.index + 1}" })
}
# modules/vpc/outputs.tf
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 "database_subnet_ids"{ value = aws_subnet.database[*].id }Phase 1 — VPC and Networking
Implement the VPC module and the environment root modules. Deploy the dev environment VPC (single NAT gateway, smaller CIDR blocks) and verify all subnets, route tables, and the NAT gateway are created and correctly associated.
# environments/dev/main.tf — Phase 1: VPC only
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}
provider "aws" { region = var.aws_region }
locals {
name = "cricketpulse-${var.environment}"
common_tags = {
project = "cricketpulse"
environment = var.environment
managed_by = "terraform"
}
}
module "vpc" {
source = "../../modules/vpc"
name = local.name
vpc_cidr = "10.0.0.0/16"
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"]
db_subnet_cidrs = ["10.0.20.0/24", "10.0.21.0/24"]
availability_zones = ["${var.aws_region}a", "${var.aws_region}b"]
nat_gateway_count = 1 # Dev: single NAT gateway (cost saving)
common_tags = local.common_tags
}
# Deploy and verify
# cd environments/dev && terraform init && terraform apply
# After apply:
output "vpc_id" { value = module.vpc.vpc_id }
output "public_subnet_ids" { value = module.vpc.public_subnet_ids }
output "private_subnet_ids" { value = module.vpc.private_subnet_ids }Phase 2 — Data Tier and Secrets
Add the RDS PostgreSQL and ElastiCache Redis modules with secrets managed via AWS Secrets Manager. The database password must never appear in any Terraform file. Deploy to dev and validate connectivity from within the VPC.
# modules/rds/main.tf
resource "aws_db_subnet_group" "main" {
name = "${var.name}-db-subnet-group"
subnet_ids = var.database_subnet_ids
tags = var.common_tags
}
resource "aws_security_group" "rds" {
name = "${var.name}-rds"
vpc_id = var.vpc_id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [var.app_security_group_id]
}
tags = var.common_tags
}
resource "aws_db_instance" "main" {
identifier = var.name
engine = "postgres"
engine_version = "15.4"
instance_class = var.instance_class
allocated_storage = var.storage_gb
db_name = "cricketpulse"
username = "cpadmin"
password = var.db_password # Passed in from Secrets Manager data source
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.rds.id]
skip_final_snapshot = var.skip_final_snapshot
backup_retention_period = var.backup_retention_days
tags = var.common_tags
}
output "endpoint" { value = aws_db_instance.main.endpoint }
output "db_name" { value = aws_db_instance.main.db_name }
# environments/dev/secrets.tf — retrieve secrets from Secrets Manager
data "aws_secretsmanager_secret_version" "db_password" {
secret_id = "cricketpulse/${var.environment}/db_password"
}
# Add to environments/dev/main.tf:
module "rds" {
source = "../../modules/rds"
name = "${local.name}-db"
vpc_id = module.vpc.vpc_id
database_subnet_ids = module.vpc.database_subnet_ids
app_security_group_id = module.eks.node_security_group_id
db_password = sensitive(data.aws_secretsmanager_secret_version.db_password.secret_string)
instance_class = var.db_instance_class # t3.micro for dev
storage_gb = 20
skip_final_snapshot = true # Dev: no final snapshot
backup_retention_days = 1
common_tags = local.common_tags
}Phase 3 — EKS Cluster and Final Assembly
Add the EKS cluster module using the community module from the registry (pinned version), wire all modules together in the environment root, deploy to dev and verify the complete infrastructure stack. Then create the production environment configuration with HA settings and validate it matches the production requirements.
# modules/eks-cluster/main.tf — using community module
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "~> 20.0" # Pinned minor version
cluster_name = var.cluster_name
cluster_version = "1.29"
vpc_id = var.vpc_id
subnet_ids = var.private_subnet_ids
cluster_endpoint_public_access = true
eks_managed_node_groups = {
main = {
instance_types = [var.node_instance_type]
min_size = var.min_nodes
max_size = var.max_nodes
desired_size = var.desired_nodes
labels = {
Environment = var.environment
Project = "cricketpulse"
}
}
}
enable_cluster_creator_admin_permissions = true
tags = var.common_tags
}
output "cluster_endpoint" { value = module.eks.cluster_endpoint }
output "cluster_name" { value = module.eks.cluster_name }
output "node_security_group_id"{ value = module.eks.node_security_group_id }
# environments/production/main.tf — production config (key differences)
module "vpc" {
source = "../../modules/vpc"
nat_gateway_count = 2 # HA: one NAT per AZ
# ... all other params same as dev with production CIDRs
}
module "rds" {
source = "../../modules/rds"
instance_class = "db.r6g.large" # Production: RDS optimised
multi_az = true # HA: standby in second AZ
storage_gb = 100
backup_retention_days = 7
skip_final_snapshot = false # Production: keep final snapshot
# ...
}
module "eks" {
source = "../../modules/eks-cluster"
node_instance_type = "t3.large" # Production: larger nodes
min_nodes = 3 # HA: always 3 nodes minimum
max_nodes = 10
desired_nodes = 3
# ...
}Evaluation Rubric
- Module boundary correctness: each module (vpc, eks-cluster, rds, elasticache) exposes only the outputs needed by other modules and accepts only variables needed for its own resources — no variables are passed through modules unnecessarily.
- Zero secrets in any file: `terraform plan` output shows no plaintext passwords or API keys, `*.tfvars` files contain no secrets, and `git log -- '*.tfvars' '*.tfstate'` shows no sensitive file was ever committed.
- Environment parity: the dev and production root modules call identical modules with only size/HA parameters differing — no structural differences that would prevent a production deploy from following the same module call pattern.
- Terraform plan idempotency: running `terraform plan` twice in succession against a deployed environment shows 'No changes' on the second run — confirming no resources have improper lifecycle configurations causing spurious diffs.
- Security group correctness: `aws ec2 describe-security-groups` confirms RDS security group allows only port 5432 from the EKS node security group (not from 0.0.0.0/0) and the EKS node security group is not open to the internet.
- CI/CD validation: the GitHub Actions plan job successfully posts a `terraform plan` output as a PR comment using OIDC authentication (no AWS_ACCESS_KEY_ID in Actions secrets), and the apply job requires and receives environment protection approval before running.
- Drift detection: nightly `terraform plan -refresh-only -detailed-exitcode` returns exit code 0 (no drift) immediately after deployment — baseline drift detection passing is the pre-requisite for the schedule to be meaningful.
Extension Challenges: (1) Add a Helm provider and `helm_release` resource to the EKS module that installs the AWS Load Balancer Controller, enabling Kubernetes Ingress resources to provision ALBs automatically. (2) Configure Terraform Cloud as the backend instead of S3+DynamoDB — migrate state using `terraform state push` and use Terraform Cloud's built-in plan-in-VCS trigger mode to replace the GitHub Actions workflow. (3) Add a `kubernetes` provider to the root module that configures a CricketPulse Namespace and deploys a ServiceAccount with the correct IAM role annotation for IRSA (IAM Roles for Service Accounts), enabling CricketPulse API pods to access S3 and Secrets Manager without node-level IAM credentials.