Step By Step Three Tier Application Using AWS CloudFormation — Part 2

azuax
11 min readOct 22, 2024

--

As a continuation of my previous post Step By Step Three tier application using AWS CloudFormation — Part 1 , we will now explore how to build and deploy the application load balancer and the application tier components. These resources include an Auto Scaling Group (ASG), launch templates, security groups, and web servers using Infrastructure as a Code (IaC) with CloudFormation. You can review the project’s code on my Github.

What we already have

In the previous post, we created a template (three-tier.yaml) that includes the following resources:

  • A VPC
  • An Internet Gateway
  • Two public subnets in different Availability Zones (AZs)
  • Four private subnets in two different AZs (two for the app tier, two for the database tier)
  • An EC2 instance used as a Bastion Host
  • A security group for the Bastion Host
  • A route table for public routing associated to our VPC

Our current architecture is depicted in the following diagram.

The current state of the art

Also, we have defined some outputs in the template:

  • The VPC ID
  • The subnet ID for the two public subnet

Note: During the development of this second part, we will need to update the existing template. You can review the changes in the Git history on the GitHub repository.

New Resources

For the application tier, we will build the following resources:

  • An application load balancer (ALB)
  • A listener for the ALB
  • An auto-scaling group (ASG)
  • A target group for the ASG
  • A launch template for creating the instances as needed
  • Security groups for the ALB and the web server instances.

The architecture diagram for the proposed solution is shown below.

What we will build.

Base Template

We’ll create a new template (app-tier.yaml). We’ll define a description and parameters for a flexible Auto-Scaling Group setup.

Additionally, we’ll include a parameter to specify the key pair name used for accessing the EC2 instances.

AWSTemplateFormatVersion: "2010-09-09"
Description: "Deployment of high-available and scalable application tier. Depends on Three Tier VPC stack."
Parameters:
minValue:
Description: "Minimum number of available instances for the ASG (default: 1)"
Type: String
Default: 1
desValue:
Description: "Desired capacity instances for the ASG (default: 2)"
Type: String
Default: 2
maxValue:
Description: "Maximum number of available instances for the ASG (default: 3)"
Type: String
Default: 3
KeyNameValue:
Description: "Key Name to use in instances"
Type: AWS::EC2::KeyPair::KeyName

Application Load Balancer

Brief Overview

Let’s review the key concepts involved in implementing an Application Load Balancer.

The ALB is responsible for distributing incoming traffic across various resources. It uses listeners, which are configured to receive traffic on specific ports. Listeners apply predefined rules to determine how the traffic should be routed to different targets.

A target group specifies the targets to which requests can be routed. It uses health checks to identify healthy targets, ensuring that the ALB routes requests only to those.

The following diagram provides a simplified visual explanation.

Basic schema on how an ALB works

Implementation

We will attach our ALB to our public subnets, making it accessible from the Internet.

    PublicALB:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
Properties:
Subnets:
- !ImportValue PublicSubnet1AZaId
- !ImportValue PublicSubnet2AZbId
SecurityGroups:
- !Ref PublicALBSG
Tags:
- Key: Name
Value: "PublicALB"

Additionally, we need to define the security group (PublicALBSG) to allow access on port 80 from any IP address.

We will use the VPC ID value exported from our previous template.

    PublicALBSG:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: "Security group for ALB"
VpcId: !ImportValue ThreeTierVPCId
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 80
ToPort: 80
CidrIp: 0.0.0.0/0
Tags:
- Value: "PublicALBSG"
Key: Name

We need to define the target group with the appropriate protocol (HTTP) and port (80). We will later define the targets associated with this target group.

    PublicALBTargetGroup:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Properties:
VpcId: !ImportValue ThreeTierVPCId
Port: 80
Protocol: "HTTP"

We do not explicitly define health checks for our target group, as the default values will suffice for our case. You can review these default values in the official AWS documentation.

The listener definition is as follows, with a default action type of forward.

    PublicALBListener:
Type: AWS::ElasticLoadBalancingV2::Listener
Properties:
LoadBalancerArn: !Ref PublicALB
DefaultActions:
- Type: "forward"
TargetGroupArn: !Ref PublicALBTargetGroup
Port: 80
Protocol: "HTTP"

If we wanted to redirect traffic from port 80 to 443 to use the HTTPS protocol, we would define the listener like this:

    PublicALBListener:
Type: "AWS::ElasticLoadBalancingV2::Listener"
Properties:
LoadBalancerArn: !Ref PublicALB
DefaultActions:
- Type: "redirect"
RedirectConfig:
Protocol: "HTTPS"
Port: 443
Host: "#{host}"
Path: "/#{path}"
Query: "#{query}"
StatusCode: "HTTP_301"
Port: 80
Protocol: "HTTP"

For now, we’ll keep it simple and proceed with the forward action type only.

Auto Scaling group

Brief overview

What is the purpose of an Auto Scaling group?

It is used to manage the availability of instances in our system according to minimum and maximum capacity constraints. We can also set a desired number of instances, ensuring that the application can handle client requests properly.

A launch template or a launch configuration (now deprecated) is needed to define the configuration of our EC2 instances.

The ASG also requires a mechanism to trigger or alarm it when adjustments in the number of instances is necessary, as well as a policy for scaling those instances.

Let’s build all these components now.

Template for the ASG

Since we want our EC2 instances to be set in private subnets, we need to modify the output values in our three-tier stack template for use in our current template.

# File: three-tier.yaml

Outputs:
# ...

PrivateSubnet1AZaOut:
Description: "Private subnet for App tier AZ A, CIDR 10.0.3.0/24"
Value: !Ref AppPrivateSubnet1AZa
Export:
Name: AppSubnet1AZaId

PrivateSubnet2AZbOut:
Description: "Private subnet for App tier AZ B, CIDR 10.0.4.0/24"
Value: !Ref AppPrivateSubnet2AZb
Export:
Name: AppSubnet2AZbId

Now we can attach the ASG to our private subnets and define the capacity values according to the parameters we previously set.

    WebserverASG:
Type: AWS::AutoScaling::AutoScalingGroup
Properties:
MaxSize: !Ref maxValue
MinSize: !Ref minValue
DesiredCapacity: !Ref desValue
LaunchTemplate:
Version: "1"
LaunchTemplateId: !Ref WebserverLaunchTemplate
VPCZoneIdentifier:
- !ImportValue AppSubnet1AZaId
- !ImportValue AppSubnet2AZbId
TargetGroupARNs:
- !Ref PublicALBTargetGroup

We use the TargetGroupARNs property to associate the ASG with our previously created ALB target group. This way, the ALB knows that the target resources are provided by the ASG. As shown, we reference a launch template that we will build next.

Launch Template

Now we need to define the type of instance that the ASG will deploy as needed. We will reuse the private key we created in our previous post for accessing the bastion host.

    WebserverLaunchTemplate:
Type: AWS::EC2::LaunchTemplate
Properties:
LaunchTemplateData:
ImageId: "ami-0fff1b9a61dec8a5f"
InstanceType: "t2.micro"
KeyName: !Ref theKeyName
SecurityGroupIds:
- !Ref WebserverSG
NetworkInterfaces:
- DeviceIndex: 0
AssociatePublicIpAddress: false
UserData:
# We will review this next #

Notes:
1. We reference a security group WebserverSG that we will build later.

2. We must set the assignment of public IP addresses to our instances as false.

What about the User Data?

Our instances needs to connect to remote repositories for installing the web server, in this case, Apache.

However, since the resources created with the ASG will be in private subnets without internet access, we must solve this.

There are several ways to address this:

The simplest solution is to create NAT gateways and attach them to our public subnets. As we are using two AZs, we need two NAT Gateways — one attached to each public subnet in each AZ.

The downside with this approach is the cost associated. With NAT gateways you are charged for each hour of availability and for the traffic passing through them (Check: Pricing For NAT gateways).

An alternative and less elegant solution is to start with the previous approach, creating and using the NAT gateways to install necessary packages and then remove the NAT gateways. While this works is an awful path to follow in my opinion.

I propose a different approach which is efficient, and cost-effective.

  1. As we already have our Bastion Host, we can configure a proxy server for using this host as a NAT instance.

Why do not use native NAT instances?

AWS documentation indicates that NAT AMI end its maintenance support on December 31, 2023.

We already have the Bastion Host and we need it, so, why not using it for this purpose too?

For this, we need to modify the bastion host User Data in the three-tier.yaml stack. We install the Squid proxy and allow connection from any IPs in our current VPC.

# File: three-tier.yaml
# Resource: BastionHost

UserData:
Fn::Base64: !Sub |
#!/bin/bash
yum update -y
yum install -y squid
cat <<EOL > /etc/squid/squid.conf
http_port 3128
acl allowed_clients src ${ThreeTierVPC.CidrBlock}
http_access allow allowed_clients
http_access deny all
EOL
systemctl restart squid
systemctl enable squid

2. We need to modify the Bastion Host’s security group to allow ingress traffic from our VPC range.

# File: three-tier.yaml

BastionSG:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: "Enable SSH access"
VpcId: !Ref MyVPC
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 22
ToPort: 22
CidrIp: !Ref publicIPUserValue
- IpProtocol: tcp
FromPort: 3128
ToPort: 3128
CidrIp: !GetAtt MyVPC.CidrBlock
Tags:
- Value: "Bastion SG"
Key: Name

This security group needs to be exported so it can be utilized in our current template.

# File: three-tier.yaml

Outputs:
# ...
BastionHostSG:
Description: "Bastion Host Security Group ID"
Value: !Ref BastionSG
Export:
Name: BastionHostSG

3. Additionally, we need to add the private IP of the bastion host to the three-tier stack’s output. Exporting the public IP is also advisable.

# File: three-tier.yaml

Outputs:
# ...
PrivateIPBastionHost:
Description: "Private IP assigned to Bastion Host"
Value: !GetAtt BastionHost.PrivateIp
Export:
Name: PrivateIPBastionHost

PublicIPBastionHost:
Description: "Public IP assigned to Bastion Host"
Value: !GetAtt BastionHost.PublicIp
Export:
Name: PublicIPBastionHost

4. Now, we can craft the User Data for the instances created by the ASG.

UserData:
Fn::Base64: !Sub
- |
#!/bin/bash
bastion_ip=${PrivateBastionIP}
echo "export http_proxy=http://$bastion_ip:3128" >> /etc/profile
echo "export https_proxy=http://$bastion_ip:3128" >> /etc/profile
source /etc/profile
yum install -y httpd
systemctl start httpd
systemctl enable httpd
echo "<h1>ALB with ASG!</h1>" > /var/www/html/index.html
- PrivateBastionIP: !ImportValue BastionHostPrivateIP

In the code snippet above, you can see the use of the bastion host’s private IP, exported from the three-tier stack.

This setup configures the use of the bastion host as proxy.

Finally, the server we configured will respond to requests for index.html with a simple HTML message.

Security group

We need to add two inbound rules for the web server security group: Allow access from the ALB security group on port 80 and allow SSH access from the Bastion Host security group.

    WebserverSG:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: "Webserver security group"
VpcId: !ImportValue ThreeTierVPCId
SecurityGroupIngress:
# Allow access from ALB
- IpProtocol: tcp
FromPort: 80
ToPort: 80
SourceSecurityGroupId: !Ref PublicALBSG
# Allow SSH access from Bastion host SG
- IpProtocol: tcp
FromPort: 22
ToPort: 22
SourceSecurityGroupId: !ImportValue BastionHostSG

Scaling

We need to define rules for scaling our instances which involves setting up an alarm and a scaling policy. Using a CloudWatch alarm, we evaluate the health of our instances.

In our scenario, if the average instance CPU usage exceeds 70% over a 5-minute interval, the scaling policy will be triggered.

    HighCPUAlarmForASG:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmDescription: "CPU over 70%"
MetricName: "CPUUtilization"
Namespace: "AWS/EC2"
Statistic: "Average"
Period: "300"
EvaluationPeriods: "1"
Threshold: "70"
ComparisonOperator: GreaterThanThreshold
Dimensions:
- Name: AutoScalingGroupName
Value: !Ref WebserverASG
AlarmActions:
- !Ref ScaleOutASGPolicy

Keep in mind that you can apply multiple conditions for your scaling strategy, such as network throughput or disk I/O metrics. For simplicity, we are sticking with CPU usage.

Next, define the scaling policy to make adjustments to the number of available instances, with a cooldown period of 5 minutes.

    ScaleOutASGPolicy:
Type: AWS::AutoScaling::ScalingPolicy
Properties:
AutoScalingGroupName: !Ref WebserverASG
PolicyType: SimpleScaling
ScalingAdjustment: "1"
Cooldown: "300"
AdjustmentType: ChangeInCapacity

With this, we’ve completed our auto-scaling template.

Deployment

We need to update the three-tier stack with the changes we’ve made.

aws cloudformation update-stack --stack-name threeTierVPC \
--template-body file://three-tier.yaml \
--parameters \
ParameterKey=KeyNameValue,ParameterValue="<name of the created key>" \
ParameterKey=publicIPUserValue,ParameterValue="<your public ip"

After updating, you can review the stack outputs using the AWS CLI or view them through the AWS Management Console, for a graphical overview.

Output values from three-tier.yaml template.

Now, we can deploy the app-tier stack. We set the minimum ASG capacity as 1, the maximum capacity as 3, and the desired capacity as 2.

aws cloudformation create-stack --stack-name app-tier --template-body file://app-tier.yaml \
--parameters \
ParameterKey=minValue,ParameterValue=1 \
ParameterKey=desValue,ParameterValue=2 \
ParameterKey=maxValue,ParameterValue=3 \
ParameterKey=keyNameValue,ParameterValue="<name of the created key>"

You can check the resources created in the AWS management console.

Created resources 1.
Created resources 2.

Testing

Website Exposed to the Internet

Let’s test the application. Check the DNS name assigned to your Application Load Balancer that’s exposed to the Internet to ensure it’s functioning correctly.

Get the DNS name assigned to the ALB.

Access to the URL with your browser. You should see your application working as expected.

Website available

In the ALB’s resource map, you’ll see an overview of your setup.

ALB resource map.

There are two healthy instances as targets for our ALB’s target group.

The two instances, plus the bastion host, are running in our environment.

Available instances.

Scaling Test

Since it’s not straightforward to simulate a CPU usage overload of 70%, we can test auto-scaling by terminating one of the instances.

Terminate one of the web server instances.
Terminated instance.

The ASG will detect this and, given that wee’ve set a desired capacity of 2, it will create a new EC2 instance.

A new instance is created by the ASG.

Our auto-scaling architecture effectively maintains the desired capacity.

Access Through Bastion Host

Since the private key is the same for all instances including our bastion host, we can access inner instances without having to copy the key to our bastion host.

For this, you need to add your key as an identity to your host machine.

ssh-add <the key filename>.pem

Then, connect to the bastion host using your private key and the -A parameter to the SSH command.

ssh -A -i <the key filename>.pem ec2-user@<public bastion ip>
Connection to the bastion host via SSH.

Once connected, verify access to one of your instances using its private IP (you can obtain it from the AWS Management Console).

SSH connection to a private web server instance.

It should work as expected.

Test connectivity to the other instance, deployed in a different subnet to ensure it’s operational.

SSH connection to the other EC2 instance.

Everything should work as expected too.

Next Suggested Steps

Consider creating an RDS database and deploy it in the private data subnets. Ensure you properly configure security groups to provide ingress access from the WebserverSG security group on the relevant database port (e.g., Mysql 3306, PostgreSQL 5432).

I leave this part to your exploration. You can ask me if you have doubts.

Key takeaways

In this two-part series, we developed a fully-functional three-tier web application on AWS using Infrastructure as a Code with CloudFormation.
We implemented an application load balancer and auto-scaling to ensure high-availability.

The goal of these templates it to automate the building and deployment of your architectures while helping you understand each component and the rationale behind the implementation decisions. Remember, many improvements can be made to refine these templates further.

Check the templates on my GitHub Repository.

if you found this post helpful, please applaud and follow me for more updates. I publish stories weekly.

--

--

azuax
azuax

Written by azuax

Cloud Security & Architecture w/ 10+ years in AWS. Pentester: offensive & defensive security. "The cloud is the edge, master it, and keep pushing boundaries"

No responses yet