Step by Step Three-tier application infrastructure using AWS CloudFormation — Part 1
In this 2-part blog post, We will review in depth how to build a highly-available and scalable three-tier application infrastructure in AWS using Infrastructure as a Code (IaC) AWS CloudFormation.
We will build the following main components:
- 1 VPC
- 1 Internet Gateway (IGW)
- 2 public subnets in 2 different availability zones (AZ).
- 2 private subnets for web server application in 2 AZs.
- 2 private subnets for database in 2 AZs
- 1 bastion host for access and management of instances in the private subnets.
- 1 Application Load Balancer (ALB)
- 1 Auto Scaling Group (ASG)
- 1 Target Group
- 2 web server instances in private subnets
- 1 Security Group (SG) for the Bastion Host
- 1 Security Group (SG) for the web servers.
The overall architecture diagram is as follows.
If you want to review and use the template code, you can check the project in Github.
Things to consider beforehand
- If you want to follow along the creation of components, you need to configure the access and secret key of an AWS IAM user with the proper permissions for managing the stack we are going to build.
- As you are going to deploy services in your cloud environment, these could generate costs, so make sure to delete the CloudFormation stack if you are not going to make use of it.
- If there is a typo or you have any doubt about what are we building, please write me a comment in this post and I’ll review it and answer as soon as I can.
Networking
The VPC
First thing first, we want to reserve our little piece of cloud. Let’s consider the CIDR range 10.0.0.0/16 for having enough IPs available to all of our services.
ThreeTierVPC:
Type: AWS::EC2::VPC
Properties:
CidrBlock: "10.0.0.0/16"
EnableDnsHostnames: true
EnableDnsSupport: true
Tags:
- Value: ThreeTierVPC
Key: Name
Things to consider:
- The VPC definition is inside the Resources section.
- EnableDnsHostnames will give a DNS hostname to the instances launched in the VPC.
- EnableDnsSupport enables the DNS resolution in the VPC.
Internet Access
For allowing internet access to our VPC, we need to set an internet gateway and attach it to the VPC. We make use of !Ref function to reference the VPC with its logical name.
InternetGateway:
Type: AWS::EC2::InternetGateway
Properties:
Tags:
- Value: ThreeTierVPC-IGW
Key: Name
AttachGateway:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
VpcId: !Ref ThreeTierVPC
InternetGatewayId: !Ref InternetGateway
Public Subnets
As you can saw in the diagram, we will create 2 public subnets one for each different availability zone we use, so we will have high availability.
PublicSubnet1AZa:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref ThreeTierVPC
CidrBlock: "10.0.1.0/24"
AvailabilityZone: !Select [0, !GetAZs ""]
MapPublicIpOnLaunch: true
Tags:
- Value: PublicSubnet1AZa
Key: Name
PublicSubnet2AZb:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref ThreeTierVPC
CidrBlock: "10.0.2.0/24"
AvailabilityZone: !Select [1, !GetAZs ""]
MapPublicIpOnLaunch: true
Tags:
- Value: PublicSubnet2AZb
Key: Name
Notes:
- We use the function !Ref to reference the previously created VPC.
- The CIDR blocks we use are 10.0.1.0/24 and 10.0.2.0/24.
- The parameter MapPublicIpOnLaunch defines that for an instance launched in this subnet, a public IPv4 address will be assigned.
- For the availability zone, we will use the function !Select to specify the first and second element respectively from the list of Availability Zones for the region. This list is obtained with the intrinsic function !GetAzs. To define the current region, we set the parameter for GetAzs as an empty value (””).
Public Route
What makes a subnet public?
It must have route tables to the internet gateway. Then, we can set the table and its route as follows.
PublicRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref ThreeTierVPC
Tags:
- Value: PublicRouteTable
Key: Name
PublicRoute:
Type: AWS::EC2::Route
DependsOn: AttachGateway
Properties:
RouteTableId: !Ref PublicRouteTable
DestinationCidrBlock: "0.0.0.0/0"
GatewayId: !Ref InternetGateway
We can note the following:
- The route table is related to our VPC.
- In the route definition, we use the parameter DependsOn to ensure the Internet Gateway is attached to our VPC.
- As the DestinationCidrBlock is defined for all IPs (0.0.0.0/0), all IP requests will go to the Gateway defined in GatewayId, i.e. the Internet Gateway.
Public Subnets associated with the Route
Now, we can associate the 2 public subnets with the route table and hence making them internet available.
PublicSubnet1AZaRouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref PublicSubnet1AZa
RouteTableId: !Ref PublicRouteTable
PublicSubnet2AZbRouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref PublicSubnet2AZb
RouteTableId: !Ref PublicRouteTable
Application Private Subnets
We define 2 private subnets with CIDR IP ranges 10.0.3.0/24 and 10.0.4.0/24.
AppPrivateSubnet1AZa:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref ThreeTierVPC
CidrBlock: "10.0.3.0/24"
AvailabilityZone: !Select [0, !GetAZs ""]
Tags:
- Value: AppPrivateSubnet1AZa
Key: Name
AppPrivateSubnet2AZb:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref ThreeTierVPC
CidrBlock: "10.0.4.0/24"
AvailabilityZone: !Select [1, !GetAZs ""]
Tags:
- Value: AppPrivateSubnet2AZb
Key: Name
Nothing much different from public subnet definition. The parameter MapPublicIpOnLaunch, when not defined, assumes its default value of false.
Database Private Subnets
The database subnets are almost identical to applications private subnets. The CIDRs we will use are 10.0.5.0/24 and 10.0.6.0/24.
DataPrivateSubnet1AZa:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref ThreeTierVPC
CidrBlock: "10.0.5.0/24"
AvailabilityZone: !Select [0, !GetAZs ""]
Tags:
- Value: DataPrivateSubnet1AZa
Key: Name
DataPrivateSubnet2AZb:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref ThreeTierVPC
CidrBlock: "10.0.6.0/24"
AvailabilityZone: !Select [1, !GetAZs ""]
Tags:
- Value: DataPrivateSubnet2AZb
Key: Name
Bastion Host
In our architecture, we defined an EC2 instance as bastion host so we can access our private instances from internet through this instance.
Let’s define in our CloudFormation template the bastion instance and set it in one of the Public Subnets.
BastionHost:
Type: AWS::EC2::Instance
Properties:
InstanceType: t2.micro
ImageId: ami-0fff1b9a61dec8a5f
SubnetId: !Ref PublicSubnet1A
SecurityGroupIds:
- !Ref BastionSG
KeyName: !Ref KeyNameValue
Tags:
- Value: BastionHost
Key: Name
The image ID ami-0fff1b9a61dec8a5f is related to Amazon Linux 2023 in Region us-east-1.
This value can be easily obtained from AWS Management Console, when creating a new EC2 instance.
The key name will be defined as parameter (we will see this later on this post).
Also, we must define the security group for SSH access.
BastionSG:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: "Enable SSH access"
VpcId: !Ref ThreeTierVPC
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 22
ToPort: 22
CidrIp: !Ref publicIPUserValue
For security reasons, we should give access only to our specific IP. This value will be assigned to the template via a parameter called publicIPUserValue.
Parameters
As we mentioned earlier, we will need some dynamic values we can pass to the template on stack creation.
First, we will need our public IP address for allowing SSH access in the security group.
We can use the following curl request in our command line.
curl ip.me
The server response will be our public IP address. Take note of this value for further usage.
To create a key pair for SSH access, we can use the AWS CLI. We will store the private key as bastionThreeTier.pem. Just be careful on where you store that key.
aws ec2 create-key-pair --key-name bastionThreeTier --query KeyMaterial --output text > bastionThreeTier.pem
Then, you need to change the permissions to the file for SSH.
chmod 0600 bastionThreeTier.pem
In our CloudFormation template, we define these parameters as follows.
Parameters:
KeyNameValue:
Description: "Key name used for SSH access to bastion host"
Type: AWS::EC2::KeyPair::KeyName
ConstraintDescription: must be the name of an existing EC2 KeyPair.
publicIPUserValue:
Description: "Public IP enabled to SSH access on bastion host"
Type: String
Output Values
As we will split our architecture into two different templates (and 2 different blog posts), we can make use of CloudFormation outputs to obtain some required values we will use later.
We define the output for VPC and public subnets in the template.
Outputs:
ThreeTierVpcIdSOut:
Description: "VPC ID, CIDR 10.0.0.0/16"
Value: !Ref ThreeTierVPC
Export:
Name: ThreeTierVPCId
PublicSubnet1AZaOut:
Description: "Public subnet 1 AZ A, CIDR 10.0.1.0/24"
Value: !Ref PublicSubnet1AZa
Export:
Name: PublicSubnet1AZaId
PublicSubnet2AZbOut:
Description: "Public subnet 2 AZ B, CIDR 10.0.2.0/24"
Value: !Ref PublicSubnet2AZb
Export:
Name: PublicSubnet2AZbId
Deployment
Finally, we can deploy our infrastructure. We use the following command, replacing the value of the parameters as required.
aws cloudformation create-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"
We can check via AWS CLI the progress with describe-instances command.
aws cloudformation describe-instances --stack-name threeTierVPC
When the deployment is complete, can see the created resources using the AWS management console.
Also, the output values are available in the Output tab of that interface.
Everything seems good so far.
Bastion Host access
Let’s verify if we can login to our Bastion Host.
We will need the public IP of the EC2 instance, which can be obtained with the following command and the value of the instance ID created.
aws ec2 describe-instances --instance-id <your instance id>
In the JSON result, will search for the parameter PublicIpAddress.
Or you can get this value directly from the previous command with a small adjustment.
aws ec2 describe-instances --instance-id <your instance id> \
--query 'Reservations[*].Instances[*].PublicIpAddress' \
--output text
Then, we can SSH into our Bastion Host EC2 instance.
ssh -i <the path to your .pem key> ec2-user@<the instance IP>
We are in the bastion host. Good work 😎.
Key Takeaways
In this post, we reviewed the step by step process for creating the base infrastructure for a three-tier web application. As result, we now have a reusable template with the required and basic components needed for a highly-scalable and highly-available application.
Be sure this template can be improved in so many ways, but the foundations are there and I leave to you add or modify anything according to your specific needs.
As said before, any question, comment, or feedback will be highly appreciated and you can leave it in the comments below.
I leave you invited to wait for the next part, where we will dive deep on creating an Application Load Balancer (ALB) with their configurations and also, we will set up the initial web server configuration, along other commonly used elements.