DEV Community

Fred Munjogu
Fred Munjogu

Posted on

Automating AWS Infrastructure for a FastAPI Application with Terraform

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.

Architecture diagram

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
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

You should see the following output if successful on your terminal

app launch success

You should also see the following if you navigate to the docs page

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

creates user

Now, let's check if our database has the same record

database results

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.

books endpoint

Now, let's check if our database has the same record

database result

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

reviews

Now, let's check if our database has the same record

reviews database

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)