How to run a web application in AWS on a tight budget
I run web applications of different scales on AWS.
This is how I run small-scale (personal or hobby) web applications.
This architecture has minimum, but enough security and availability for such applications though it will not meet the stringent requirements of enterprise, business-critical applications. But it meets one of the most critical criteria for a hobby app - keep the cost at minimum.
Even though it's a hobby app, I highly recommend using an IaC tool to create this setup on AWS. We will first go through the AWS services and their key parameters used in this design. Then, I will give you an OpenTofu configuration to provision this setup in your preferred AWS region.
The architecture for a small-scale web application
Monolithic is the preferred architecture for a small-scale web application.

You can create this kind of web applications using one of the many frameworks/languages/runtimes like Node.js, Python, Django, PHP, Ruby on Rails, insert your favorite, etc.
You can also create web applications architected in microservices using the said languages/frameworks. But, you will not use microservices architecture for a hobby app. At least, I will not. If you do, you will find it hard to deploy on this AWS setup. Instead, you'll need to design your AWS architecture around AWS EKS.
Before moving further, I want to emphasize this. When I say monolithic architecture is preferred for small-scale web applications, it does not imply that monolithic architecture is meant for small-scale applications only. You can scale up monolithic web application to handle any amount of traffic. There are many ultra-high-scale monolithic web applications out there, Shopify and Instagram are two that comes immediately to my mind.
A typical monolithic web app consists of:
1. Reverse proxy
All web applications need a reverse proxy. You can read it up here why.
There are many good open-source reverse proxies like Nginx, HAProxy, Apache, and Traefik, I choose Nginx due to its simplicity, resource efficiency and wide community support.
2. Application server
This is where your code handles the business logic by accepting oncoming requests, processing, and sending out the responses.
3. Database server
You can use a self-managed open-source database server or one of the managed database services from AWS. We will decide which one later below.
4. Object storage
An object storage can serve static assets like images, CSS, and JavaScript without burdening the application server. If your web application accepts user-generated binary content like images and videos, you must use an object storage to store those content too.
Since we are running this app on AWS, we can use AWS S3 object storage.
5. CDN (Content Delivery Network)
If your users are spread across the globe, you must serve them via a CDN even though your app is small. If not, most users (who are geographically far away from where your application is hosted) may feel unacceptable delays.
We will use AWS CloudFront as the CDN for this app.
Choosing AWS app architecture
There are several options to host this type of a web application on AWS.
- EC2 instance
- ECS with Fargate
- AWS Elastic Beanstalk
- AWS Amplify (If you are using a JavaScript web framework)
We will stick to the option #1 because:
- We are on a tight budget. The EC2 instance option has the lowest cost at the beginning as well as at scale.
- It gives us the maximum flexibility (to run background tasks, WebSockets, or serve non-HTTP protocols.)
Let me also briefly tell you why we don't choose other options.
ECS with Fargate
When using ECS with Fargate, we must use AWS Elastic Load Balancer to channel traffic to our Fargate instances. The Elastic Load Balancer has a minimum monthly cost of 16.20 USD as this simple calculation shows.
Hourly charge for AWS ELB: $0.0225 per hour
Monthly calculation: $0.0225 * 24 hours * 30 days = $16.20
This does not fall inside our tight budget.
AWS Elastic Beanstalk
AWS Elastic Beanstalk is a platform as a service (PAAS) from Amazon for running web applications. Being a PAAS, it suffers from many of the limitations that similar PAAS services do. It's less flexible and less configurable.
In the past, I had very bad DevExperience trying to use AWS Elastic Beanstalk. I would not choose it for a personal project.
AWS Amplify
AWS Amplify works only if your app is full-stack JavaScript. Even then, Amplify has limitations for WebSockets. So, let's stay away from that too.
That leaves us with option #1 - running the web application on an EC2 instance.
But here's a caveat. It demands more work. Options like AWS Elastic Beanstalk, let you just upload the code. AWS takes care of running the code. But it's not so with the EC2 option.
We need to do some work to setup the AWS resources and deploy the app. Then, we must make sure to take regular backups, monitor that everything is up and running, apply security fixes and patches to the OS, etc.
But, that's the work we as DevOps engineers love. ❤️
Managed databases vs self-managed databases
After deciding to run our app on an EC2 instance, we encounter the next key decision.
How do we run the database?
We can use an AWS managed database or go with a self-managed database.
AWS offers a wide range of managed SQL and NoSQL databases. The starting cost of managed PostgreSQL database would be $ 5-20 per month after the AWS free tier.
Since we are on a tight budget, let's drop the managed database option and go with a self-managed database.
Like the EC2 option we opted in, the self-managed databases demand more work from our side. But, since this is a small-scale app this additional work is manageable. And we can move to a managed database later if we choose so.
Building AWS infrastructure for the web app
Now, let's go through the key configurations and pricing of AWS resources used in this app.

1. VPC with Egress Only Internet Gateway
VPC is one of the fundamental building blocks in AWS. All EC2 instances reside inside a VPC. When you create a new AWS account, a default VPC is created in all available regions. You can create EC2 instances in this default VPC. But, we will create a new VPC (without using the default VPC) for this project.
When creating the VPC, make sure to assign an Amazon-provided IPv6 CIDR block so our EC2 instances can use IPv6.
Egress Only Internet Gateway allows outbound Internet access for EC2 instances via IPv6 while preventing inbound access. This is a secure mechanism for our EC2 instance to download the code and OS updates.
Then, we don't have to use NAT gateway which would have added about $32 to our monthly bill.
2. EC2 instance
Since this is a small-scale app and we are on a tight budget, let's try to fit it into the smallest possible EC2 instance.
AWS has an array of EC2 instance types. For this web app, t4g instance type is suitable. t4g is powered by AWS Graviton processors and its price is slightly less than EC2s with x86 CPUs.
The lowest capacity t4g instance, t4g.nano has 2 CPU cores and 0.5GB memory. That's barely enough to run a web app and a database server. So, let's use t4g.small which has 2 CPU cores and 1GB of memory. That's enough for our simple web application.
This EC2 instance costs about $6 per month in on-demand pricing model. You can further optimize it with AWS EC2 instance savings plans.
3. Amazon CloudFront
Amazon CloudFront is the CDN service from AWS.
Cloud Front receives HTTPS traffic from the Internet and decides to send it to our application based on caching policy and cache availability.
Using CloudFront CDN gives us two benefits:
- Improve response time for users: CloudFront is a globally distributed CDN and can serve content from locations that are closer to the users.
- Gives an extra layer of protection for the web app: We don't need to expose our EC2 instances to the Internet because CloudFront accepts inbound traffic from the Internet and send that traffic to our EC2 instance's private IP address.
To use CloudFront for an application, you must create a CloudFront Distribution. The CloudFront distribution defines how to cache content and route requests to the backend application.
In our case, we have two backends; the EC2 instance and the S3 bucket. So, we must create two Origins with different caching Behaviors inside the CloudFront distribution.
CloudFront uses Caching policies to control caching Behavior. You can define custom caching policies or use predefined ones. We will use predefined caching policies:
Cache Behavior for EC2 instance
Our web application on the EC2 instance, runs an API on the relative path/api. The API responses are dynamically generated based on the content stored in the database. So, we will useCaching Disabledpolicy for this caching behavior.Cache Behavior for S3 bucket
S3 bucket serves cachable content. So, we will useCaching Optimizedpolicy.
CloudFront bills you for the data transfer out and HTTP requests count. AWS has a good free tier for CloudFront that you don't have to worry about the cost until you scale past 10M HTTP(S) requests per month.
4. EC2 Instance Connect Endpoint
To SSH into our EC2 instance, we use EC2 Instance Connect Endpoint. It establishes a secure SSH connection to our EC2 instances from a web-based shell inside the AWS console.
5. Security groups
Security groups act as a stateful firewall for EC2 instances.
We need two security groups
Security group 1: Allow outbound SSH from EC2 Instance Connect Endpoint
This security group, assigned to the EC2 Instance Connect Endpoint, needs only one rule to allow outbound SSH to any IP.Security group 2: Allow inbound SSH and HTTP to EC2 instance
This security group is assigned to the EC2 instance. It has three rules to allow inbound SSH from the EC2 Instance Connect Endpoint, inbound HTTP from CloudFront, and outbound public Internet access.
6. Elastic Block Storage (EBS)
EC2 instances have ephemeral storage by default. This is not suitable for a web application that handles persistent data. So, we must provision our EC2 instance with AWS Elastic Block Storage.
To keep the application data (the database) and the OS separate, let's use two EBS volumes. The volume for the OS needs to be at least 10GB for a typical Linux OS like Ubuntu. For the data disk 5GB is enough to get started. You can always increase these volume sizes later if required.
There are several EBS types in AWS. For this web application let's use General Purpose SSD (gp3) - Storage type. It has the lowest cost and enough performance for our small web application.
The price of this volume type is $ 0.08 per GB per month.
You'll get 30GB of EBS free for the first 12 months of AWS account creation as part of the free tier.
After that the cost for 15GB (10GB OS disk + 5GB data disk) will be $ 1.2 per month.
7. Amazon S3
We use Amazon S3 to store static content like CSS, JavaScript and static content like images.
S3 has several storage types. S3 Standard, has the lowest pricing and is good enough for our web app.
When creating the bucket, make sure to keep all public access blocked. We are getting all HTTP traffic via CloudFront. So we need not allow any public access directly to the bucket.
AWS bills S3 based on volume (GBs used) and transactions (PUT, COPY, LIST, etc). Also, you'll incur charges when you data transfer out to the Internet.
I will not worry about that pricing at this point. If you are not handling hundreds of Gigabytes of data, you also need not.
8. IAM role and bucket policy
We created S3 bucket denying all public access. So, we must explicitly allow the EC2 instance and CloudFront to access the S3 bucket.
For EC2 instance to write data (upload files) to the S3 bucket, we use an IAM role with an IAM policy that allows write permission to the specific bucket.
Here's a sample IAM policy that defines write access to a S3 bucket.
{
"Version": "2012-10-17",
"Statement": [
{
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject",
"s3:ListBucket",
],
"Resource": [
"arn:aws:s3:::bucket-name"
],
"Effect": "Allow"
}
]
}
We also need to allow read access to the bucket from CloudFront distribution. This is done via Bucket policy.
Here's a sample S3 Bucket policy to allow read only (s3:GetObject) access for a CloudFront distribution. You'll have to replace arn:aws:cloudfront::xyz with the ARN of your CloudFront distribution.
{
"Version": "2008-10-17",
"Id": "PolicyForCloudFrontPrivateContent",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipal",
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::cloudqubes-app-prod/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::xyz"
}
}
}
]
}
9. Amazon Route 53
Route 53 is the DNS service from AWS.
If you purchased a domain name from an external (not AWS) domain registrar, you'll probably not need to use Route 53. You can use the DNS service from that domain registrar to point your web application URL to the URL of the CloudFront distribution.
But if you purchased the domain name from AWS or if you don't want to use the DNS service from the external domain registrar, you need to use Route 53 to resolve your URL.
Route 53 configuration has two parts.
1. Hosted zone: Each domain name (including its subdomains) need a different hosted zone in Route 53. Think of a hosted zone *as a container holding the DNS records of a particular domain.
2. *Record: A record defines how Route 53 responds to DNS queries of a URL. Since we are using AWS CloudFront as the front-end of our application, the URL needs to be resolved to the CloudFront distribution's URL and not to the EC2 instance's IP address. So, in the Route 53 hosted zone, add a record of type CNAME and point it to the URL of the CloudFront distribution you created.
Creating the AWS resources
You can create this setup using the AWS web console.
But, if you are going to use this setup to run an app in production, I recommend using an IaC tool. Here's a GitHub repo with OpenTofu configurations for creating this setup.
Give it a try and let me know how things turn out.
In the next post, let's see how to deploy an actual web application on this setup.