Loading
Reza Chegini

Junior DevOps Engineer

Junior Cloud Engineer

Junior Site Reliability Engineer

Software Engineer

Backend Developer

Reza Chegini

Junior DevOps Engineer

Junior Cloud Engineer

Junior Site Reliability Engineer

Software Engineer

Backend Developer

Blog Post

Provisioning EC2 and Configuring an ALB in AWS with Terraform

May 30, 2025 AWS, DevOps, Infrastructure, Terraform
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
Tags:
Write a comment