← Back to list

Deploying a Highly Available Web App on AWS Using Terraform

Introduction

Stephen Mugo · 2026-03-20 22:23 · 0 claps · 6.2 min read
#aws #auto-scaling-groups #application-load-balancer #terraform #aws-security-group
Open on Medium ↗
Wiki topics: 🌐 · Web Development ☁️ · DevOps & Cloud

Deploying a Highly Available Web App on AWS Using Terraform

Introduction

In this walk through, we break down how to leverage Terraform to deploy a highly available, horizontally scalable & fault tolerant web server architecture. The setup pairs an Application Load Balancer with an Auto Scaling Group to distribute traffic across multiple EC2 instances that span several availability zones.

Highly Available Web server Architecture

Highly Available Web server Architecture

Prerequisites

  • AWS Account with permissions to create EC2, VPC, Load Balancers, Auto Scaling group resources.
  • AWS CLI installed & configured with a named or default profile credentials.
  • Terraform v1.3 or late installed. Verify by running terraform versioncommand on the terminal. Expected output
mugo@sunny:$ terraform version
Terraform v1.14.7
  • Git (optional but recommended for version controll of the Terraform code).

Codebase Walkthrough

In a new directory, create a main.tf file

  1. Provider: Input this provider block that tell Terraform to target AWS on region us-east-2 for resource provisioning
provider "aws" {
  region = "us-east-2"
}

2. Data Sources:

A data source represents a piece of read-only information that is fetched from the provider every time you run Terraform.

Rather than hard-coding values, these data sources dynamically resolve them at apply time.

Here we query the provider’s API for :

  • amazon linux 2 AMI ID from AWS Systems Manager Parameter Store(aws_ssm_parameter). Instances will always launch with the current patched AMI (latest version).
  • default VPC: *aws_vpc.default* retrieves the default VPC in the region.
  • availability zones: *aws_availability_zones.all* fetches all AZs available in the region.
  • subnets: *aws_subnets.default* filters for all subnets that belong to the default VPC, gives you one public subnet per AZ.

Add this to main.tf

data "aws_ssm_parameter" "amzn2_ami" {
  name = "/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2"
}

data "aws_vpc" "default" {
  default = true
}

data "aws_availability_zones" "all" {}

data "aws_subnets" "default" {
  filter {
    name   = "vpc-id"
    # References the Id of the default vpc
    values = [data.aws_vpc.default.id]
  }
}

Note: The default VPC approach is convenient for demos but unsuitable for production. Default VPCs have all subnets public and no network segmentation. In a production setup, you would provision a custom VPC with private subnets where instances will be launched, a public subnet for the load balancer & route outbound traffic through a NAT gateway.

3. Security Groups:

These act as virtual firewalls that control inbound & oubound traffic for AWS resources like EC2 instance. They operate at tehe instace level.

Add this to main.tf

# Application Load Balancer security group
resource "aws_security_group" "alb_sg" {
  name        = "alb-sg"
  description = "Allow HTTP inbound to ALB"
  vpc_id      = data.aws_vpc.default.id

#Allow Http traffic from anywhere 
  ingress {
    from_port   = var.app_port      # varible reference -80
    to_port     = var.app_port
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

#Allows all outbound traffic for ALB forwarding
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}
# Instance security group
resource "aws_security_group" "instance_sg" {
  name   = "instance-sg"
  vpc_id = data.aws_vpc.default.id

#Allow only to reach instances
  ingress {
    from_port       = var.server_port          # Variable Reference - 8080
    to_port         = var.server_port
    protocol        = "tcp"
    security_groups = [aws_security_group.alb_sg.id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}
  • The key security design decision in the whole architecture, there’s no direct internet access to the application servers. Your EC2 instances are effectively shielded behind the load balancer.

4. Variables

They keep our code DRY (Don’t Repeat Yourself) making it easy to tweak our infrastructure.

  • Create a variables.tf file
variable "instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t3.micro"
}

#
variable "app_port" {
  description = "Port the web app listens on"
  type        = number
  default     = 80
}

variable "server_port" {
  description = "Port the web app listens on"
  type        = number
  default     = 8080
}

variable "asg_min_size" {
  description = "Minimum number of instances in ASG"
  type        = number
  default     = 2
}

variable "asg_max_size" {
  description = "Maximum number of instances in ASG"
  type        = number
  default     = 5
}

variable "asg_desired_capacity" {
  description = "Desired number of instances in ASG"
  type        = number
  default     = 2
}

5. Launch Template — EC2 Blueprint

This is the blueprint the Auto Scaling group uses to spin up new instances. It makes sure their identical.

  • append this to the main.tf
resource "aws_launch_template" "web-template" {
  image_id      = data.aws_ssm_parameter.amzn2_ami.value
  name_prefix   = "web-lt-"
  instance_type = var.instance_type 

  vpc_security_group_ids = [aws_security_group.instance_sg.id]

  user_data = base64encode(<<-EOF
              #!/bin/bash
              echo "Hello, World" > index.html
              nohup python3 -m http.server ${var.server_port} &
              EOF
            )

  tag_specifications {
    resource_type = "instance"
    tags = {
      Name = "web-server"
    }
  }

#Enables zero downtime updates.
  lifecycle {
    create_before_destroy = true
  }
}

The user_data script runs at first boot and does two things: writes a simple index.html to the working directory, then starts Python's built-in HTTP server in the background (nohup ... &) to serve that file on port 8080.

The lifecycle { create_before_destroy = true } block is critical for zero-downtime updates. When you modify the template (e.g., change instance type), Terraform will spin up the new version before destroying the old one.

6. Application Load Balancer ( ALB )

This operates at Layer 7 ( HTTP/HTTPS), enabling path based & host based routing. It’s internet facing & gets a public DNS name.

  • add this to your main.tf
resource "aws_lb" "web_lb" {
  name               = "web-lb"
  internal           = false   #Makes it internet facing
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb_sg.id] 

#Deploys across all subnets thus ALL AZ's
  subnets            = data.aws_subnets.default.ids

  tags = {
    Name = "web-cluster-alb"
  }
}

Aws requires at least 2 subnets in different availability zones for ALB thus eliminating single point of failure.

7. Target Group

This is a pool of instances the ALB routes traffic to. Distributes traffic across healthy instances.

  • add this to your main.tf
resource "aws_lb_target_group" "web_tg" {
  name     = "web-tg"
  port     = var.server_port
  protocol = "HTTP"
  vpc_id   = data.aws_vpc.default.id

  health_check {
    enabled             = true
    path                = "/"
    protocol            = "HTTP"
    matcher             = "200"
    interval            = 30
    timeout             = 5
    healthy_threshold   = 2
    unhealthy_threshold = 2
  }
}

The health check: Every 30 seconds the ALB sends GET requests to / on port 80. If it gets a 200 OK back within 5 seconds, the instance is healthy. It acts as a glue between ALB and Auto scaling group(ASG) when combined with health_check_type = "ELB" on the ASG.

Failed health checks trigger automatic instance replacement on top of stopping traffic

8. Application Load Balancer Listener

This tells the ALB what to do with traffic arriving on port 80 ( internet traffic)

  • add this to your main.tf
resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.web_lb.arn
  port              = var.app_port       # 80
  protocol          = "HTTP"

#Forwards traffic to target group 
  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.web_tg.arn
  }
}

9. Auto Scaling Group (ASG)

It reads the Launch template, maintains desired number of instances and replaces failed instances ( ELB health check ).

  • add this to your main.tf
resource "aws_autoscaling_group" "web_asg" {
  name                = "web-sg"
  max_size            = var.asg_max_size            # 5
  min_size            = var.asg_min_size            # 2
  desired_capacity    = var.asg_desired_capacity    # 2
  vpc_zone_identifier = data.aws_subnets.default.ids

  launch_template {
    id      = aws_launch_template.web-template.id
 #Use most recent Version of Launch Template
    version = "$Latest"  
  }

  target_group_arns = [aws_lb_target_group.web_tg.arn]

  health_check_type         = "ELB"
  health_check_grace_period = 60

  tag {
    key                 = "Name"
    value               = "web-server"
    propagate_at_launch = true
  }

  lifecycle {
    create_before_destroy = true
  }
}

EC2 health check v ELB: EC2 only marks an instance unhealthy if it’s terminated or stopped. ALB reports unhelathy when application is unreachable.

health_check_grace_period = 60 gives new instances 60 seconds after launch before the ASG starts evaluating health checks. This prevents instances from being terminated during their startup phase while user_data is still running.

10. Outputs — outputs.tf

This file stores ouput declarations that expose specific data from your infrastructure example Ip addresses to other modules or scripts after terraform apply.

  • create outputs.tf and add this;
#URl to test the web server
output "alb_dns_name" {
  description = "Public DNS name of the Application Load Balancer"
  value       = "http://${aws_lb.web_lb.dns_name}"
}

output "asg_name" {
  description = "Name of the Auto Scaling Group"
  value       = aws_autoscaling_group.web_asg.name
}

output "vpc_id" {
  description = "Default VPC used for deployment"
  value       = data.aws_vpc.default.id
}

output "availability_zones" {
  description = "AZs instances are spread across"
  value       = data.aws_availability_zones.all.names
}

Deployments steps

  1. Initialize Terraform While in your project directory , open terminal & run;
# Initialize Terraform (downloads AWS provider)
terraform init
  1. Review the deployment
# visualize resources to be created
terraform plan
  1. Deploy the Infrastructure
# Apply the configuration
terraform apply
# Type 'yes' when prompted to confirm
  1. Get the Load Balancer URL
# View outputs ALB DNS name
terraform output alb_dns_name
  1. Test your Application
# Use the ALB DNS name from the output
curl http://your-alb-dns-name.us-east-2.elb.amazonaws.com

You should see "Hello, world" returned.

  1. Monitor Your infrastructure
  • Check the AWS console to see your instances.
  • Test High availability: Terminate an instance manually and wait for the Auto scaling group to replace it.
  • Check Auto scaling group tab in the EC2 console. Confirm that the Terminated instance is declared unhealthy. Thus ASG replaces it.
  1. Clean Up

When you’re done testing , destroy the infrastructure.

terraform destroy
# Type 'yes' to confirm deletion

Conclusion

The clustered architecture solves for the majority of issue that come with single instance deployment such as manual recovery, no fault isolation, no redudancy and the confinement to vertiacal scaling. The architecture is a practical baseline for web servers in production.


메타데이터
post_id
091d3833c705
slug
deploying-a-highly-available-web-app-on-aws-using-terraform-091d3833c705
url
https://medium.com/@stephenmugo/deploying-a-highly-available-web-app-on-aws-using-terraform-091d3833c705
canonical_url
https://medium.com/@stephenmugo/deploying-a-highly-available-web-app-on-aws-using-terraform-091d3833c705
author_url
https://medium.com/@stephenmugo
status
ok
fetched_at
2026-06-09 15:37:30