Provisioning EC2 and Configuring an ALB in AWS with Terraform

This is Part 2 of my AWS-Terraform walkthrough, where we move from VPC and security groups to launching EC2 instances and setting up a fully functional load balancer.
In Part 1, we explored how to build a secure foundation using VPCs and security groups.
In Part 2, we’re deploying compute resources and load balancing which means:
- EC2 Instances (Bastion Host + Private Instances)
- Elastic IP Allocation
- SSH Provisioning with null_resource
- Application Load Balancer (ALB)
- Target Groups + Attachments
Let’s break it down 👇
☑️ Defining EC2 Variables
instance_type
variable "instance_type" {
description = "EC2 Instance Type"
type = string
default = "t2.micro"
}
🔹 Explanation: Specifies the size/type of EC2 instance. “t2.micro” is a free-tier eligible and lightweight instance — perfect for learning, testing, or small web apps.
instance_keypair
variable "instance_keypair" {
description = "AWS EC2 Key pair that need to be associated with EC2 Instance"
type = string
default = "terraform-key"
}
🔹 Explanation: This is the SSH key you’ll use to log in to your EC2 instance. Make sure the key “terraform-key” already exists in your AWS region.
private_instance_count
variable "private_instance_count" {
description = "AWS EC2 Private Instances Count"
type = number
default = 1
}
🔹 Explanation: Controls how many private EC2 instances are launched. You can increase this to scale your backend.
☑️ Launching the Public Bastion Host EC2
module "ec2_public" {
source = "terraform-aws-modules/ec2-instance/aws"
version = "5.7.0"
🔹 Uses the official EC2 module to simplify EC2 creation. Locking the version keeps your code stable.
name = "${var.environment}-BastionHost"
ami = data.aws_ami.amzlinux2.id
instance_type = var.instance_type
🔹 Gives the EC2 a meaningful name
🔹 ami: This pulls the latest Amazon Linux 2 image
🔹 instance_type: Uses the type we defined earlier
key_name = var.instance_keypair
subnet_id = module.vpc.public_subnets[0]
vpc_security_group_ids = [module.public_bastion_sg.security_group_id]
tags = local.common_tags
}
🔹 SSH key for login
🔹 Launched into the public subnet
🔹 Attached to a security group that allows SSH
🔹 Adds consistent project tags
☑️ Elastic IP for Bastion Host
resource "aws_eip" "bastion_eip" {
depends_on = [ module.ec2_public, module.vpc ]
🔹Ensures the EIP is created after EC2 and VPC
instance = module.ec2_public.id
domain = "vpc"
tags = local.common_tags
}
🔹 Assigns this EIP to the EC2 instance
🔹 domain = “vpc” ties the EIP to your VPC
📌 Why Elastic IP? So your bastion host always has the same public IP, even after restarts critical for SSH or firewall rules.
☑️ Remote Provisioning via null_resource
resource "null_resource" "name" {
depends_on = [ module.ec2_public ]
🔹 Waits for the instance to be available before starting provisioning
connection {
type = "ssh"
host = aws_eip.bastion_eip.public_ip
user = "ec2-user"
private_key = file("private-key/terraform-key.pem")
}
🔹 SSH connection details assumes you’re using Amazon Linux 2 (ec2-user) 🔹 Loads the PEM key to authenticate
provisioner "file" {
source = "private-key/terraform-key.pem"
destination = "/home/ec2-user/terraform-key.pem"
}
🔹 Securely copies the key file into the EC2 instance
provisioner "remote-exec" {
inline = [
"sudo chmod 400 /home/ec2-user/terraform-key.pem",
"echo VPC created on `date` and VPC ID: ${module.vpc.vpc_id} >> creation-time-vpc-id.txt"
]
}
}
🔹 Makes the PEM file secure
🔹 Creates a basic log file showing when/where the VPC was created
☑️ Application Load Balancer (ALB) Module
module "alb" {
source = "terraform-aws-modules/alb/aws"
version = "9.11.0"
🔹 Uses the official ALB module
name = "${local.name}-alb"
load_balancer_type = "application"
vpc_id = module.vpc.vpc_id
subnets = module.vpc.public_subnets
security_groups = [module.loadbalancer_sg.security_group_id]
enable_deletion_protection = false
🔹 Gives the ALB a unique name
🔹 Launches in public subnets
🔹 Connected to a security group that allows HTTP (port 80)
🔹 Disables deletion protection for easy testing
☑️ Listener, Target Group Setup, and Health Check Block
🔹 Listener Block
listeners = {
my-http-listener = {
port = 80
protocol = "HTTP"
forward = {
target_group_key = "mytg1"
}
}
}
🔹 my-http-listener: A custom name to identify this listener
🔹 port = 80: The ALB listens for HTTP requests on port 80 (insecure). In production, you’d also add a second listener for port 443 (HTTPS).
🔹 protocol = “HTTP”: Protocol for incoming requests
🔹 forward: Tells the ALB what to do when it receives traffic — here, it forwards to a target group identified by mytg1.
✅ If you had different paths like /api or /app, you could define multiple target groups and add rules here.
🔹 Target Group Block
target_groups = {
mytg1 = {
create_attachment = false
name_prefix = "mytg1-"
protocol = "HTTP"
port = 80
target_type = "instance"
deregistration_delay = 10
load_balancing_cross_zone_enabled = false
protocol_version = "HTTP1"
🔹 create_attachment = false: This tells the module not to automatically register EC2s. You’ll manually attach them using a separate resource.
🔹 name_prefix: The actual name of the target group will be generated starting with “mytg1”.
🔹 protocol: Target group protocol. HTTP means Layer 7 traffic.
🔹 port: Port on your EC2s to receive traffic — must match your app’s listening port, typically 80.
🔹 target_type:
- instance = uses EC2 instance ID (classic)
- ip = useful when targeting IP addresses or containers We’re using EC2-based backend here.
🔹 deregistration_delay: How long to wait before stopping traffic to a removed EC2 instance.
🔹 load_balancing_cross_zone_enabled: If false, ALB sends traffic only to instances in the same AZ as the request.
🔹 protocol_version = “HTTP1”: Specifies the protocol used to talk to your backend EC2s.
🔹 Health Check Block
health_check = {
enabled = true
interval = 30
path = "/app1/index.html"
port = "traffic-port"
healthy_threshold = 3
unhealthy_threshold = 3
timeout = 6
protocol = "HTTP"
matcher = "200-399"
}
🔹 This configures how the ALB knows if your EC2s are healthy.
- path: The file or endpoint it pings (you must ensure this path exists)
- matcher: Treat responses with HTTP status 200–399 as “healthy”
- interval: Time between checks
- timeout: If no response within 6 seconds, consider it failed
- healthy/unhealthy_threshold: How many times it must pass/fail before being marked healthy/unhealthy
- port = “traffic-port”: Uses the same port (80) the listener sends traffic to
☑️ Attaching EC2 Instances to the Target Group – Deep Dive
resource "aws_lb_target_group_attachment" "mytg1" {
for_each = { for k, v in module.ec2_private: k => v }
target_group_arn = module.alb.target_groups["mytg1"].arn
target_id = each.value.id
port = 80
}
🔹 This resource manually registers each EC2 instance with the ALB target group.
🔹 for_each: Loops over all private EC2 instances using module.ec2_private, which returns a map. The key (k) can be the index or name, and the value (v) contains instance attributes like .id.
🔹 target_group_arn: The ARN of the target group created in the ALB module
🔹 target_id: The EC2 instance ID being registered
🔹 port = 80: Must match the listener → target group → EC2 flow. (If your app listens on a different port like 3000, update this.)
📌 Why Not Use create_attachment = true?
Sometimes, you want more control over:
- Which EC2s are registered
- When to attach/detach (e.g., in a dynamic environment)
- Using for_each gives flexibility and avoids tight coupling inside the module
✅ Wrapping Up
In this deep-dive article, we:
- Launched EC2 instances in public and private subnets
- Attached an Elastic IP to the bastion host
- Used null_resource for remote provisioning
- Configured an ALB with listeners and target groups
- Connected backend EC2s to the ALB