Introduction
In this project, I automated the deployment of a FastAPI-based Book Review API by provisioning all the necessary AWS resources using Terraform. The goal was to build a simple, scalable, and secure cloud architecture that separates the web and database tiers while maintaining infrastructure as code (IaC) principles.
Architecture Overview
In this project, we will create a few AWS resources. These resources include:
- Virtual Private Cloud (VPC)
- Public subnet (web server)
- Private subnet (database server)
- Internet gateway for public subnet connectivity
- NAT gateway for private subnet outbound access
- Route tables and routing configuration
- Security groups for both tiers
- EC2 instances
Let's go through each of these steps to gain a better understanding of how we will deploy our app.
Deploying our Web Server
We will need to first create a VPC (
Virtual Private Cloud), which, in simple terms, is a virtual network that is similar to a traditional network. The VPC will allow us to add subnets, which are a range of IP addresses in our VPC.
VPC
Here is a snippet of our Terraform configuration to create a VPC:
resource "aws_vpc" "test_vpc" {
cidr_block = "10.0.0.0/16"
tags = {
Name = "api_vpc"
}
enable_dns_hostnames = true
enable_dns_support = true
}
Public subnet
With our VPC now configured, we can work on creating a public subnet. To create our subnet, we will specify the VPC we want to create it in and also the IP address range. We will also add a property to allow us to map a public IP when we launch an EC2 instance in that subnet.
Here is a snippet of how we will create our public subnet:
resource "aws_subnet" "public_subnet" {
vpc_id = aws_vpc.test_vpc.id
cidr_block = "10.0.0.0/24"
availability_zone = var.aws_availability_zone
map_public_ip_on_launch = true
tags = {
Name = "public-subnet"
}
}
Internet Gateway
After creating our public subnet, we will now add an internet gateway to allow our instances access to the internet. This involves adding an internet gateway resource and attaching it to our VPC.
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.test_vpc.id
tags = {
Name = "test-igw"
}
}
Routing table and routes
We will then create a routing table and add routes for how our traffic in the public subnet will flow. In our routing table, we will specify a CIDR block of 0.0.0.0/0
, which will allow outbound traffic from resources in the public subnet to access the internet.
Routing table association
The final step is associating these route tables with the public subnet. This will apply all the rules to the public subnet and dictate how outbound traffic will flow.
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public_subnet.id
route_table_id = aws_route_table.public_route.id
}
Security groups
By now, our web server can access the internet, but we need to specify some inbound and outbound rules to limit the number of devices that can access our server. As you may have already thought, this is a safety measure to ensure only authorized personnel have access to our servers.
Security groups allow us the opportunity to explicitly declare these rules. Below are some of the rules I specified for my web server.
Creating the security group
resource "aws_security_group" "webserver" {
name = "webserversg"
description = "security group for webservers"
vpc_id = aws_vpc.test_vpc.id
tags = {
Name = "WebServerSG"
}
}
Adding inbound and outbound rules
# allow ssh, http, and https traffic to webserver
resource "aws_vpc_security_group_ingress_rule" "allow_ssh_webserver" {
security_group_id = aws_security_group.webserver.id
cidr_ipv4 = "${chomp(data.http.myip.response_body)}/32"
from_port = 22
ip_protocol = "tcp"
to_port = 22
}
resource "aws_vpc_security_group_ingress_rule" "allow_http" {
security_group_id = aws_security_group.webserver.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 80
ip_protocol = "tcp"
to_port = 80
}
resource "aws_vpc_security_group_ingress_rule" "allow_https" {
security_group_id = aws_security_group.webserver.id
cidr_ipv6 = "::/0"
from_port = 443
ip_protocol = "tcp"
to_port = 443
}
# adding egress rules for all traffic
resource "aws_vpc_security_group_egress_rule" "allow_all_traffic_webserver" {
security_group_id = aws_security_group.webserver.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
Ingress refers to the inbound rules, while egress refers to the outbound rules. From the snippet above, you see that I allow inbound traffic from SSH (port 22), HTTP (port 80), and HTTPS (port 443). In the SSH part, I specified my own IP since it was in development, but in a production setting, you would specify an IP address range.
Creating our EC2 Instance
The final step is creating an EC2 instance where our app will be deployed. We will be using an Ubuntu instance to deploy our app.
We will create this instance in our public subnet.
# create an EC2 instance
data "aws_ssm_parameter" "ubuntu_ami" {
name = "/aws/service/canonical/ubuntu/server/24.04/stable/current/amd64/hvm/ebs-gp3/ami-id"
}
# webserver instance
resource "aws_instance" "web_instance" {
ami = data.aws_ssm_parameter.ubuntu_ami.value
instance_type = "t3.micro"
key_name = "fredssh"
vpc_security_group_ids = [aws_security_group.webserver.id]
subnet_id = aws_subnet.public_subnet.id
associate_public_ip_address = true
user_data_base64 = filebase64("webserver.sh")
tags = {
Name = "webserver-instance"
}
}
The user_data_base64
attribute links to a bash script that downloads and starts nginx when the EC2 instance starts. Now we have a full web server with nginx ready to deploy our app.
We will follow the same steps when building our private instance that will have our database. However, there are a few differences when creating the private instance. Firstly, we do not want the server to be directly accessible from the internet. The server should only be accessible from the web server. This means we have to use a NAT gateway.
NAT gateway
To create this gateway, we need an Elastic IP. We will use this Elastic IP to create our NAT gateway. After creating this gateway, we will route it the same way we did the internet gateway, only this time to the private subnet. This enables the private subnet to access the internet securely without being directly exposed or allowing unauthorized inbound connections.
# allocate elastic ip to nat gateway
resource "aws_eip" "nat_eip" {
domain = "vpc"
tags = {
Name = "nat-eip"
}
}
# create a NAT gateway
resource "aws_nat_gateway" "nat" {
allocation_id = aws_eip.nat_eip.id
subnet_id = aws_subnet.public_subnet.id
tags = {
Name = "nat-gw"
}
depends_on = [aws_internet_gateway.igw]
}
# routing
resource "aws_route_table" "private_route" {
vpc_id = aws_vpc.test_vpc.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.nat.id
}
tags = {
Name = "private-rt"
}
}
The other thing that differs is how we define our security group. In the private instance, we will allow inbound traffic from SSH and Postgres (port 5432), which is the database we will be using.
Creating the security group
resource "aws_security_group" "dbserver" {
name = "dbserversg"
description = "security group for database servers"
vpc_id = aws_vpc.test_vpc.id
tags = {
Name = "DBServerSG"
}
}
Defining the inbound and outbound rules
# allow ssh, and Postgres traffic to dbserver from instances in the webserver
resource "aws_vpc_security_group_ingress_rule" "allow_ssh_db" {
security_group_id = aws_security_group.dbserver.id
referenced_security_group_id = aws_security_group.webserver.id
from_port = 22
ip_protocol = "tcp"
to_port = 22
}
resource "aws_vpc_security_group_ingress_rule" "allow_postgres" {
security_group_id = aws_security_group.dbserver.id
referenced_security_group_id = aws_security_group.webserver.id
from_port = 5432
ip_protocol = "tcp"
to_port = 5432
}
# adding egress rules for all traffic
resource "aws_vpc_security_group_egress_rule" "allow_all_traffic_db" {
security_group_id = aws_security_group.dbserver.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
We will create the EC2 instance the same way, but we will not associate a public IP address with the private instance.
Launching and Testing our App
We will now create all these resources with the following Terraform commands:
terraform init
terraform fmt
terraform validate
terraform apply
We now have our two instances running. It's time to test if our API app works as intended. For a more detailed guide on how to configure nginx, postgres, and launch our app, please refer to the README in the root of the aws
directory in this repo. It also contains all the Terraform configurations.
API Testing
Let's first run our app
python3 -m uvicorn main:app
You should see the following output if successful on your terminal
You should also see the following if you navigate to the docs page
Testing the users endpoint
Inside the docs, we will create a new user and see the response we receive. We will also confirm if this output is similar to that in our database.
We will start by creating a user. These are the results
Now, let's check if our database has the same record
Our results show that our app works well. Let's try for the books and review endpoints.
Testing the book endpoint
We will follow the same process as above. In the Swagger docs, we will create a book and ensure everything works fine. This is the result of creating a book.
Now, let's check if our database has the same record
Testing the review endpoint
For this to work, there has to be a user and a book record. Without these records, we cannot create a review. This makes sense because to create a review, there needs to be a book to review and also the person reviewing it. Luckily, we have already created a user and a book. So we can proceed to write a review for the book we created. Here is the output
Now, let's check if our database has the same record
This works for all the endpoints, showing that our deployed app works as we expected. This concludes our project.
Design Decisions and Trade-offs
Two-tier architecture: Separating the web and database layers improves security and scalability.
Private database subnet: Prevents external exposure of sensitive data.
Dynamic IP restriction: The web server’s SSH access is dynamically limited to my public IP, reducing attack surface.
Terraform automation: Enables consistent, version-controlled provisioning.
Conclusion
This project highlights the power of Infrastructure as Code in managing cloud deployments. Another advantage of Terraform, in particular, is that it is cloud-agnostic. We can use it with different cloud providers (Azure, GCP).
The source code of this project can be found here: Github Repository
Top comments (0)