100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Terraform & Infrastructure as Code
55 minintermediate

Modules Practice — Cluster Module

What You'll Build

You will author a reusable `cricketpulse-cluster` Terraform module that provisions a complete application server cluster: an autoscaling group of EC2 instances, an Application Load Balancer, target groups, and necessary security groups. The module has a clean variable interface (instance type, min/max replicas, VPC inputs), meaningful outputs (load balancer DNS, ASG name), and an `examples/` directory with a basic usage example. You will then call this module from a root configuration for two environments (dev and production) and observe how the same module produces different infrastructure based on input values.

Analogy🏏Cricket
🏏 Think of it like cricket: This exercise is your first bat at the nets — not a match, but the first real contact between bat and ball. You're not yet managing complex match strategies or multi-ground tournaments; you're simply practicing the forward defensive — the fundamental stroke. Provisioning a single VM with its network prerequisites is the forward defensive of Terraform: not glamorous, but the foundation of every subsequent shot. Each command (init, plan, apply) is a distinct movement to practise until it becomes automatic, because every future Terraform operation builds on this same three-step sequence.

Prerequisites

  • Terraform 1.5+ installed with AWS provider configured.
  • AWS credentials with EC2, ELB, and IAM permissions (or LocalStack for local testing).
  • Lessons 13–15 completed — understanding of module authoring, versioning, and composition.
  • Completion of Lesson 12's networking configuration, or create a VPC and subnets manually to reference.
  • Familiarity with AWS Application Load Balancer and EC2 autoscaling concepts.

Setup & Project Structure

Create the module directory structure following Terraform conventions, then scaffold all required files before implementing the resource logic.

Analogy🏏Cricket
🏏 Think of it like cricket: setting up separate files for providers, network, and compute is like organising your kit bag before a match with distinct compartments — pads in one, gloves in another, bat and helmet each in their place. Just as a well-ordered kit bag lets you grab exactly the gear you need without rummaging, splitting your Terraform config by concern lets you find the network or provider settings instantly. Just as every player knows the batting order is written down so nobody argues about who's next in, the file-per-concern convention means any teammate reading your CricketPulse config knows precisely where the compute or networking lives. And just as a tidy kit bag survives a chaotic changeroom, a readable structure survives a growing configuration. The payoff: like a professional who never scrambles for a missing glove at the crease, you keep the config navigable and calm as the infrastructure grows.
bash
mkdir -p cricketpulse-modules/modules/cricketpulse-cluster/examples/basic
cd cricketpulse-modules

# Create module files
touch modules/cricketpulse-cluster/{variables.tf,main.tf,outputs.tf,versions.tf,README.md}
touch examples/basic/{main.tf,outputs.tf}

# Initialize the root configuration for calling the module
cat > main.tf << 'EOF'
terraform {
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
}
provider "aws" { region = var.aws_region }
EOF

cat > variables.tf << 'EOF'
variable "aws_region" { type = string; default = "ap-south-1" }
variable "environment" { type = string }
EOF

terraform init
echo 'Project structure created'

Step 1 — Foundation

Define the module's variables, outputs, and versions files — the module's interface contract. Getting the interface right first avoids later breaking changes that affect callers.

Analogy🏏Cricket
🏏 Think of it like cricket: Step 1 builds the ground infrastructure before the match begins. The VPC is the stadium boundary walls — the outer container that defines the playing area. The subnet is the designated field within the boundary. The internet gateway is the public entrance gate that allows spectators (traffic) to enter. You build the ground infrastructure before placing the players (EC2 instances) on the field.
bash
cat > modules/cricketpulse-cluster/variables.tf << 'EOF'
variable "environment" {
  type        = string
  description = "Deployment environment (dev, staging, production)"
  validation {
    condition     = contains(["dev", "staging", "production"], var.environment)
    error_message = "Environment must be dev, staging, or production."
  }
}

variable "vpc_id" {
  type        = string
  description = "VPC ID to deploy the cluster into"
}

variable "subnet_ids" {
  type        = list(string)
  description = "List of subnet IDs for the autoscaling group (private) and ALB (public)"
}

variable "alb_subnet_ids" {
  type        = list(string)
  description = "List of public subnet IDs for the ALB"
}

variable "instance_type" {
  type        = string
  description = "EC2 instance type for API servers"
  default     = "t3.medium"
}

variable "min_size" {
  type        = number
  description = "Minimum number of instances in the autoscaling group"
  default     = 2
}

variable "max_size" {
  type        = number
  description = "Maximum number of instances in the autoscaling group"
  default     = 10
}

variable "desired_capacity" {
  type        = number
  description = "Desired number of instances"
  default     = 2
}

variable "ami_id" {
  type        = string
  description = "AMI ID for CricketPulse API servers"
}

variable "project" {
  type        = string
  description = "Project name for resource naming and tagging"
  default     = "cricketpulse"
}
EOF

cat > modules/cricketpulse-cluster/versions.tf << 'EOF'
terraform {
  required_version = ">= 1.5.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.0.0, < 6.0.0"
    }
  }
}
EOF

cat > modules/cricketpulse-cluster/outputs.tf << 'EOF'
output "alb_dns_name" {
  value       = aws_lb.main.dns_name
  description = "DNS name of the Application Load Balancer"
}

output "alb_arn" {
  value       = aws_lb.main.arn
  description = "ARN of the Application Load Balancer"
}

output "asg_name" {
  value       = aws_autoscaling_group.main.name
  description = "Name of the EC2 autoscaling group"
}

output "security_group_id" {
  value       = aws_security_group.api.id
  description = "Security group ID for the API servers"
}
EOF

Step 2 — Core Logic

Implement `main.tf` with the security group, launch template, autoscaling group, and Application Load Balancer resources. These are the core compute and network resources the module encapsulates.

Analogy🏏Cricket
🏏 Think of it like cricket: Step 2 is placing the players on the prepared ground. The security group is the access accreditation policy — which players (traffic sources) are allowed through which gates (ports). The EC2 instance is the opening batsman taking the crease. The apply command is the toss coin flip that starts the match — the moment the plan becomes real infrastructure.
hcl
cat > modules/cricketpulse-cluster/main.tf << 'EOF'
locals {
  name_prefix = "${var.project}-${var.environment}"
  common_tags = {
    Project     = var.project
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

# Security group for API servers
resource "aws_security_group" "api" {
  name        = "${local.name_prefix}-api-sg"
  description = "Security group for CricketPulse API servers"
  vpc_id      = var.vpc_id

  ingress {
    from_port   = 3000
    to_port     = 3000
    protocol    = "tcp"
    security_groups = [aws_security_group.alb.id]
    description = "Allow traffic from ALB"
  }
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
  tags = merge(local.common_tags, { Name = "${local.name_prefix}-api-sg" })
}

# Security group for ALB
resource "aws_security_group" "alb" {
  name        = "${local.name_prefix}-alb-sg"
  description = "Security group for CricketPulse ALB"
  vpc_id      = var.vpc_id

  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"]
  }
  tags = merge(local.common_tags, { Name = "${local.name_prefix}-alb-sg" })
}

# Launch template
resource "aws_launch_template" "api" {
  name_prefix   = "${local.name_prefix}-api-"
  image_id      = var.ami_id
  instance_type = var.instance_type

  network_interfaces {
    security_groups = [aws_security_group.api.id]
  }
  tag_specifications {
    resource_type = "instance"
    tags          = merge(local.common_tags, { Name = "${local.name_prefix}-api" })
  }
  lifecycle { create_before_destroy = true }
}

# Autoscaling Group
resource "aws_autoscaling_group" "main" {
  name                = "${local.name_prefix}-asg"
  min_size            = var.min_size
  max_size            = var.max_size
  desired_capacity    = var.desired_capacity
  vpc_zone_identifier = var.subnet_ids
  target_group_arns   = [aws_lb_target_group.api.arn]

  launch_template {
    id      = aws_launch_template.api.id
    version = "$Latest"
  }
  tag {
    key                 = "Environment"
    value               = var.environment
    propagate_at_launch = true
  }
}

# Application Load Balancer
resource "aws_lb" "main" {
  name               = "${local.name_prefix}-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = var.alb_subnet_ids
  tags               = merge(local.common_tags, { Name = "${local.name_prefix}-alb" })
}

resource "aws_lb_target_group" "api" {
  name     = "${local.name_prefix}-api-tg"
  port     = 3000
  protocol = "HTTP"
  vpc_id   = var.vpc_id
  health_check {
    path                = "/health"
    healthy_threshold   = 2
    unhealthy_threshold = 3
  }
}

resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.main.arn
  port              = 80
  protocol          = "HTTP"
  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.api.arn
  }
}
EOF

Step 3 — Integration & Enhancement

Create the examples directory with a basic usage example and call the module from the root configuration for both dev and production environments.

Analogy🏏Cricket
🏏 Think of it like cricket: Step 3 is making a tactical adjustment mid-match — changing the batting position (instance type) of an already-fielded player. Some changes are in-place (the batsman changes their stance without leaving the crease — `~` in the plan). Others require the player to leave and re-enter (the player goes back to the pavilion and returns — `-/+` replace in the plan). Terraform's plan output tells you which type of change is required before you commit to it.
hcl
# examples/basic/main.tf — complete working example
cat > modules/cricketpulse-cluster/examples/basic/main.tf << 'EOF'
provider "aws" { region = "ap-south-1" }

data "aws_vpc" "default" { default = true }
data "aws_subnets" "default" {
  filter { name = "vpc-id"; values = [data.aws_vpc.default.id] }
}
data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"]
  filter { name = "name"; values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"] }
}

module "cluster" {
  source          = "../.."  # Points to module root
  environment     = "example"
  vpc_id          = data.aws_vpc.default.id
  subnet_ids      = data.aws_subnets.default.ids
  alb_subnet_ids  = data.aws_subnets.default.ids
  ami_id          = data.aws_ami.ubuntu.id
  instance_type   = "t3.micro"
  min_size        = 1
  max_size        = 3
  desired_capacity = 1
}
output "alb_dns" { value = module.cluster.alb_dns_name }
EOF

# Root: call module for two environments
cat >> main.tf << 'EOF'

data "aws_vpc" "cricketpulse" {
  tags = { Name = "cricketpulse-${var.environment}" }
}
data "aws_subnets" "private" {
  filter { name = "vpc-id"; values = [data.aws_vpc.cricketpulse.id] }
  tags = { Tier = "private" }
}
data "aws_subnets" "public" {
  filter { name = "vpc-id"; values = [data.aws_vpc.cricketpulse.id] }
  tags = { Tier = "public" }
}
data "aws_ami" "ubuntu" {
  most_recent = true; owners = ["099720109477"]
  filter { name = "name"; values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"] }
}

module "cricketpulse_cluster" {
  source           = "./modules/cricketpulse-cluster"
  environment      = var.environment
  vpc_id           = data.aws_vpc.cricketpulse.id
  subnet_ids       = data.aws_subnets.private.ids
  alb_subnet_ids   = data.aws_subnets.public.ids
  ami_id           = data.aws_ami.ubuntu.id
  instance_type    = var.environment == "production" ? "m5.large" : "t3.small"
  min_size         = var.environment == "production" ? 3 : 1
  max_size         = var.environment == "production" ? 20 : 3
  desired_capacity = var.environment == "production" ? 3 : 1
}

output "api_endpoint" {
  value = "http://${module.cricketpulse_cluster.alb_dns_name}"
}
EOF

Step 4 — Testing & Verification

Validate the module structure and run plans for both environments to verify the module produces different infrastructure configurations based on the environment variable.

Analogy🏏Cricket
🏏 Think of it like cricket: verifying the provisioned VM works, inspecting the state file, then destroying it cleanly is like the post-innings routine — confirm the scoreboard matches what happened on the field, review the official scorebook ball by ball, then clear and cover the ground so nothing is left running overnight. Just as you'd check the batsman's recorded runs actually reflect the shots played, you verify the infrastructure Terraform claims to have built is genuinely reachable and functioning. Just as the scorebook is the authoritative record you consult to settle any dispute, the state file is where you inspect exactly what Terraform tracks as existing. And just as a responsible side clears its kit and covers the pitch after stumps to avoid damage and cost, `terraform destroy` tears everything down cleanly so no resources keep billing you. The payoff: like a match properly closed out and the ground restored, you confirm the work, understand the record, and leave a clean slate behind.
bash
# Validate module structure
terraform validate

# Plan dev environment
terraform plan -var="environment=dev"
# Expected: small instance type, 1 replica, all tagged 'dev'

# Plan production environment  
terraform plan -var="environment=production"
# Expected: m5.large instances, 3 replicas, all tagged 'production'

# Verify module outputs are accessible
terraform plan -var="environment=dev" 2>&1 | grep 'alb_dns_name'
# Changes to outputs: api_endpoint = (known after apply)

# Test the example independently
cd modules/cricketpulse-cluster/examples/basic
terraform init
terraform plan  # Should work with default VPC

# Validate the module can be consumed from a subdirectory
cd ../../../../
terraform -chdir=modules/cricketpulse-cluster/examples/basic validate
echo 'Module exercise complete!'

Warning: The `create_before_destroy = true` lifecycle rule on the launch template is critical for zero-downtime scaling group updates. Without it, Terraform tries to delete the old launch template before creating the new one — but the autoscaling group still references the old template, causing a dependency error. `create_before_destroy` tells Terraform to create the new template first, update the ASG to reference it, then delete the old one. This pattern applies to any resource that other resources reference and that might need to be replaced.

Extension Challenge: Add a `health_check_path` variable to the module (default: `/health`) and use it in the target group health check. Then add a `tags` variable of type `map(string)` with default `{}` and merge it with `local.common_tags` on every resource: `tags = merge(local.common_tags, var.tags, { Name = ... })`. This makes the module generic enough that callers can add arbitrary tags without modifying the module internals — a critical capability for organisations with mandatory tagging requirements.

  • Create the module's variable and output interfaces (variables.tf, outputs.tf) before implementing resources — the interface contract is more important than the implementation details.
  • The `versions.tf` file in a module declares minimum Terraform and provider version requirements — callers will fail early with a clear error if they use an incompatible version.
  • Module resource state is namespaced as `module.<name>.<resource_type>.<resource_name>` — calling the same module twice with different names creates separate, non-conflicting state keys.
  • Use `create_before_destroy = true` on launch templates and other resources that are referenced by other resources and may need replacement — prevents dependency errors during updates.
  • The `examples/basic` directory serves as the module's working documentation and can be used for manual integration testing with `terraform plan` from the examples directory.
  • Conditional expressions in the caller (`instance_type = var.environment == "production" ? "m5.large" : "t3.small"`) enable environment-specific sizing without requiring the module to know about environment-specific logic.
Lesson 16 of 24
0% complete