Hosting low traffic websites for cheap (on AWS)

Nassir Al-Khishman
14 min readOct 28, 2023

--

Deploying a very-low traffic web app can cost you much more than it should.

Specifically, it is an over-allocation to use AWS’s EC2 and RDS to handle a couple thousand requests per-day (figure 1).

A crane (labeled EC2 & RDS) lifting a tiny ball
Figure 1. Using EC2 and RDS for a low traffic load is an over-allocation of resources, like using a crane to lift a tiny ball. You’re paying too much to lift a tiny ball (low traffic load).

I get into specifics in the respective sections below, but it can cost you $15/month at no-traffic for minimal resources on AWS:

- $12 for a SQL database on a db.t4g.micro.
- $3 for a Backend on a t4g.nano EC2.
- $0 for a Frontend on AWS Amplify.

If you have more traffic, costs will initially increase proportionally to Amplify usage. Eventually, you will have to spend more on EC2 when it cannot handle any more requests. To optimize EC2 usage, make sure you read https://nalkhish.medium.com/should-you-use-django-asynchronous-support-f86ef611d29f.

This can get expensive if you like building many personal web apps and want them deployed for cross-device access. Namely, you do not want concerns about a $2,000 annual personal cloud bill to stop you from having fun!

If you go serverless on AWS, no-traffic monthly spend is $0.

- $0 for a Database on AWS NoSQL DynamoDB.
- $0 for a Backend on AWS Lambda.
- $0 for a Frontend on AWS Amplify.

And the cost doesn’t cross $1/month for about 50,000 requests. See the math on this later.

If you use that to independently host 10 personal web apps with no-traffic and assume they take away the free-forever tier, you’ll only be paying for AWS Amplify build minutes ($0.01/minute).

To demonstrate: let’s discuss low-traffic costs, code, then deploy:

  • Typescript-NextJs-React frontend on AWS Amplify
  • A Python-FastAPI backend on AWS Lambda
  • AWS’s NoSQL DynamoDB

Environment Setup

To keep this blog focused and relatable, I avoided necessitating great tools including Serverless (framework), Bash3Boilerplate, and infrastructure-as-code frameworks such as Terraform and AWS Cloudformation.

Deployment dependencies

That said, the deployment requires Docker and the AWS cli.

Docker:

AWS CLI:

Development dependencies

For non-deployment, ie. development, I used Node.js and Python.

Node.js (for frontend):

Python (for backend):

Now that our environment is ready, we can begin building.

Building

To avoid unavailability errors as we go along, let’s go through the services in the reverse-order of dependence:

AWS Permissions -> Database -> Backend -> Frontend

For each section of the stack components, I’ll briefly discuss costs at low traffic, the infrastructure choice, the framework choice, code the component if applicable, and then deploy it.

Everything below except the permissions is in this demo repo.

AWS Permissions (https://aws.amazon.com/iam/)

Let’s get permissions for our CLI and Lambda.

The Lambda needs to be able to access DynamoDB and Cloudwatch. I’m going to do this on the console and provide urls assuming an AWS region of us-east-1.

  1. Click create role on the IAM roles page (https://us-east-1.console.aws.amazon.com/iamv2/home?region=us-east-1#/roles).
  2. Select a trusted entity type of AWS service and use-case of Lambda.
  3. On the next page, search and attach the policies: AmazonDynamoDBFullAccess and CloudWatchLogsFullAccess.
  4. On the next page, specify a name. I specified the name “cheap_demo_lambda”. The role name is required in the backend deployment script, so you might want to come back to this role page and keep the tab open (figure 2).
  5. Click create role at the bottom.
AWS Console view of adding a role, in the second step (permissions). It shows an sts:assumerole of a Lambda prinicipal and attachment of AmazonDynamoDbFullAccess and CloudwatchLogsFullAccesss.
Figure 2. Creating a Lambda role that can access DynamoDB and CloudWatch. I called it cheap_demo_lambda.

For the CLI, I’m going to (i) attach overly-permissive policies to a group, (ii) append that group with a user, and (iii) then use the user’s credentials as my environment’s default.

(i) Attach permissions to a group

  1. Go to the AWS IAM groups create view https://us-east-1.console.aws.amazon.com/iamv2/home#/groups/create
  2. Name the group something you like. I chose “cheap_fullstack_hosting_group”. We will need to remember this for when appending a user to the group.
  3. Attach the AWS-managed permission policies (Figure 3):
  • To create the DynamoDB database tables: AmazonDynamoDBFullAccess
  • To deploy the Lambda backend: AWSLambda_FullAccess and AmazonEC2ContainerRegistryFullAccess.
  • We do not need AdministratorAccess-Amplify (for frontend) as we’re going to have to use the console again to authenticate Amplify access to your repository.
Amazon console view of user group permissions showing attachment of AmazonDynamoDBFullAccess, AWSLambda_FullAccess, and AmazonEC2ContainerRegistryFullAccess.
Figure 3. Creating a user group with AmazonDynamoDBFullAccess, AWSLambda_FullAccess, and AmazonEC2ContainerRegistryFullAccess.

(ii) Append that group with a user

  1. Go to the AWS IAM user create view https://us-east-1.console.aws.amazon.com/iamv2/home#/users/create
  2. Specify a username you like. I specified a username of user_1.
  3. Attach the user to the group.

(iii) Use the user’s credentials as my environment’s default

  1. Go to the user’s security credentials access key create view:
    https://us-east-1.console.aws.amazon.com/iamv2/home#/users
    /details/user_1/create-access-key
  2. Specify a use-case of CLI. They’ll recommend more secure authentication methods, but what we’re doing temporarily is fine for a quick demo. Later, we will deactivate the access key.
  3. Create the access key and do not close the window with the access key and secret access key.
  4. Open your terminal and go to your user’s aws directory (cd ~/.aws).
  5. If it does not exist, create a ‘credentials’ file.
  6. Open to edit the ‘credentials’ file.
  7. Copy and paste the access key and secret access key to the credentials file as aws_access_key_id and aws_secret_access_key (Figure 4).
A screenshot of the .aws/credentials file showing how to format the key id and access key.
Figure 4. Adding AWS credentials as the default credentials on MacOS.

Check that you correctly configured your credentials:

aws sts get-caller-identity

You should see a return indicating you’re the user we just created.

Check that the user has access to permissions via the group we created:

aws dynamodb list-tables

You should not get an AccessDeniedException. If you do and need help debugging, post it in the comments and I’ll help if I get the chance.

AWS DynamoDB database (https://aws.amazon.com/dynamodb/)

At about 50,000 requests/month, AWS DynamoDB will cost about $0.02/month (https://aws.amazon.com/dynamodb/pricing/on-demand/)

  • 40,000 Read Request Units = $0.01
  • 10,000 Write Request Units = $0.0125
  • <25 GB of storage = $0.00

In comparison, it would cost $12/month for a SQL database using PostgreSQL/MySQL on a db.t4g.micro. RDS PostgreSQL ($0.016/hour https://aws.amazon.com/rds/pricing/)

I chose AWS’s DynamoDB because it has no minimum cost, but it is NoSQL.

Unfortunately, as of this writing, the only serverless AWS SQL database (Aurora) has a higher minimum cost and requires you to commit to 0.5 of an “instance”. If you decide to migrate to AWS DynamoDB from SQL, you’re going to go through some pains in migrating and initial performance losses until you get to large scale (https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/SQLtoNoSQL.WhyDynamoDB.html).

If you haven’t made the app and can get comfortable with a loose schema, use AWS DynamoDB!

All we have to do is create a table. You can do this using the AWS cli:

# Avoid pagination of AWS CLI output
export AWS_PAGER=""

(aws dynamodb create-table \
--table-name items \
--attribute-definitions AttributeName=id,AttributeType=S \
--key-schema AttributeName=id,KeyType=HASH \
--billing-mode PAY_PER_REQUEST)

That’s all we need for DynamoDB, so we completed 1/3. Next, let’s make and deploy the Lambda backend.

AWS Lambda Backend (https://aws.amazon.com/lambda)

At about 50,000 requests/month, AWS Lambda will cost $0.68/month (https://aws.amazon.com/lambda/pricing/)

  • 50,000 Requests = $0.01
  • 50,000 Lambda-GB-second = $0.666
  • 100,000 Ephemeral Storage GB-second = $0.003

Lambdas will stop being a relatively cheap option at higher traffic. To show this, I made figure 5. Figure 5 plots data I produced using:

A graph described in the caption below
Figure 5. Compute cost of using Lambda vs M6g. $USD/hour (y) are plotted against total requests per hour (x). The y-axis was capped at $0.10/hour, which shows the benefit of using Lambda at low traffic but hides the disadvantage of using Lambda at high traffic. Total Requests/Hour are on a Logarithmic scale to show that Lambda cost is sensitive to the number of requests, but this makes the relationship look exponential instead of linear.

It takes 2305 requests every hour to make this intersect with M6g medium’s hourly rate ($0.0385 / $0.0000166667). Comparatively, M6g medium could be optimized to serve over 100,000 requests per hour. This is because EC2 instances like M6g medium can take advantage of the cost-saving provided by asynchronous servers being able to concurrently serve hundreds of requests (see https://nalkhish.medium.com/should-you-use-django-asynchronous-support-f86ef611d29f).

With that said, given our low-traffic specification, I decided to go with AWS Lambda for now.

I say for now because there’s a chance that traffic increases to a point where it is cost-effective to switch to EC2 or the app gets so large the Lambda cold start time becomes a no-go. Accordingly, we should take any easy opportunity to architect a backend that can be easily adapted to Lambda/EC2. Such an opportunity exists: we can use Mangum to wrap most python backend frameworks to handle an event in Lambda.

If you’re using a non-compatible language/framework, you might be able to hack it by matching the url in the Lambda event to the appropriate url handler.

I then chose FastAPI because it has a small learning curve but is still friendly to static type-checking.

Let’s make 1 index route, 1 create route, and 1 get-list route

Index route:

import datetime

from fastapi import FastAPI

app = FastAPI()


@app.get("/")
async def root():
"""Use this route to warm the lambda and reduce cold starts."""
return f"You are at the index page. The time is: {datetime.datetime.utcnow()}"

Now let’s add an interface for the ‘item’ model we created in the DynamoDB section:

Item interface

import datetime
from enum import Enum

from fastapi import FastAPI
from pydantic import BaseModel


class Item(BaseModel):
id: str
description: str


class ItemsTable(Enum):
"""A lighweight facade for the DynamoDB items table."""

name_ = "items"
primary_key = "id"

@staticmethod
def deserializer(item) -> Item:
return Item(
id=item["id"]["S"],
description=item["description"]["S"],
)

...(former code)...

Create route

import datetime
from enum import Enum

from aiobotocore.session import get_session
from fastapi import FastAPI
from fastapi.exceptions import HTTPException
from pydantic import BaseModel
from starlette import status


def client():
return get_session().create_client("dynamodb", region_name="us-east-1")


...(former code)...

@app.post("/items", status_code=status.HTTP_201_CREATED)
async def create_item(item: Item) -> Item:
async with client() as db:
# Check if the item already exists
response = await db.get_item(
TableName=ItemsTable.name_.value,
Key={ItemsTable.primary_key.value: {"S": item.id}},
)
if "Item" in response:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Item with id {item.id} already exists.",
)
# Create the item
await db.put_item(
TableName=ItemsTable.name_.value,
Item={
ItemsTable.primary_key.value: {"S": item.id},
"description": {"S": item.description},
},
)
return item

Get list route

import datetime
from enum import Enum

from aiobotocore.session import get_session
from fastapi import FastAPI
from fastapi.exceptions import HTTPException
from pydantic import BaseModel
from starlette import status


...(former code)...


@app.get("/items")
async def get_items() -> dict[str, Item]:
async with client() as db:
# Get all the keys
response = await db.scan(
TableName=ItemsTable.name_.value,
ProjectionExpression=ItemsTable.primary_key.value,
)
keys = [item[ItemsTable.primary_key.value]["S"] for item in response["Items"]]
if not keys:
return {}

# Get all the items
response = await db.batch_get_item(
RequestItems={
ItemsTable.name_.value: {
"Keys": [{ItemsTable.primary_key.value: {"S": key}} for key in keys]
}
}
)
return {
item[ItemsTable.primary_key.value]["S"]: ItemsTable.deserializer(item)
for item in response["Responses"][ItemsTable.name_.value]
}

Don’t forget to allow the frontend to access the backend. I’m allowing all domains for simplicity (you can secure this later).

...(former code)...

app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)

...(former code)...

And use Mangum to adapt it to Lambda:

import datetime
from enum import Enum
from logging import getLogger

from aiobotocore.session import get_session
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.exceptions import HTTPException
from pydantic import BaseModel
from starlette import status
from mangum import Mangum

...(former code)...

def handler(event, context):
magnum = Mangum(app)
response = magnum(event, context)
return response

The full code is on Github: https://github.com/nalkhish/cheap-low-traffic-stack/blob/main/backend/src/cheap/main.py

To deploy this backend, we first need to wrap it with a Docker container. Let’s make a Dockerfile: https://github.com/nalkhish/cheap-low-traffic-stack/blob/main/backend/Dockerfile

Dockerfile

FROM public.ecr.aws/lambda/python:3.11 AS builder

# Add system install dependencies here

WORKDIR /app
COPY requirements.txt .
RUN pip3 install -r requirements.txt --target "/app" -U --no-cache-dir

FROM public.ecr.aws/lambda/python:3.11

# Copy from the builder stage
COPY --from=builder /app ${LAMBDA_TASK_ROOT}

# Copy function code
COPY ./src ${LAMBDA_TASK_ROOT}

CMD ["cheap.main.handler" ]

Finally, let’s deploy it following along our deploy script: https://github.com/nalkhish/cheap-low-traffic-stack/blob/main/backend/deploy.sh

Declare some common variables:

# Avoid pagination of AWS CLI output
export AWS_PAGER=""

dt=$(date '+%d-%m-%Y-%H-%M-%S')
DEPLOY_HISTORY_FILE="deploy_history.txt"
AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
LAMBDA_ROLE_NAME="cheap_demo_lambda"
AWS_REGION="us-east-1"
docker_image_name="cheap_backend"
ecr_repo_name="cheap_backend"
ecr_image=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$ecr_repo_name:$dt

Login to AWS’s ECR:

# Login to ECR
echo "Logging into ECR"
(aws ecr get-login-password \
--region $AWS_REGION | docker login \
--username AWS \
--password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com)

Create a repository in ECR:

# Create the ECR repository
aws ecr create-repository --repository-name $ecr_repo_name

Build the docker image and push it to the ECR repository

# Build docker image and push to ECR
docker build -t $docker_image_name .
docker tag $docker_image_name:latest $ecr_image
docker push $ecr_image

Create the Lambda

# Create the Lambda
function_key="cheap_backend"
(aws lambda create-function \
--function-name $function_key \
--code ImageUri=$ecr_image \
--package-type Image \
--role arn:aws:iam::${AWS_ACCOUNT_ID}:role/${LAMBDA_ROLE_NAME} \
--timeout 30 \
--memory-size 128 \
--architecture arm64)

Create a function url to the Lambda (not accessible until step below):

(aws lambda create-function-url-config \
--function-name $function_key \
--auth-type NONE)

Allow the Lambda function url to be accessed by anyone:

(aws lambda add-permission \
--function-name $function_key \
--action lambda:InvokeFunctionUrl \
--statement-id FunctionURLAllowPublicAccess \
--function-url-auth-type NONE \
--principal "*")

Now the backend should be accessible at the function url:

(aws lambda get-function-url-config \
--function-name $function_key \
--query FunctionUrl --output text)

If you need to update it, I made the deploy script robust enough to handle redeployment.

That’s all we need for AWS Lambda, so we completed 2/3! The only thing remaining is the AWS Amplify frontend.

AWS Amplify Frontend (https://aws.amazon.com/amplify/)

At about 50,000 requests/month it can cost $0.25/month for a Frontend using React.js/Angular served via AWS amplify (https://aws.amazon.com/amplify/pricing/). More if you use a server-side rendering (SSR) feature like dynamic rendering in Next.js. This is not to be confused with static rendering, which will mostly scale in terms of serve-out Amplify.

  • Scaling cost: 1 GB served out amplify = $0.15
  • SSR-only scaling cost: 14,000 GB-second compute ~= 4 GB-hour = $0.8
  • SSR-only scaling cost: 28,000 requests ~= $0.001
  • Fixed cost: 0.25 GB build artifacts stored ~= $0
  • Fixed cost: 10 build minutes = $0.1

I chose AWS Amplify, similarly to the other components, because it is serverless and has no minimum base cost. Unlike other components, you do not have to adapt your frontend to be hosted by AWS Amplify. If you can point it to the build command, it’ll build, serve and cache.

Frameworkwise - I chose NextJs and React because NextJs handles a lot of the frontend serving performance issues and React is the most popular frontend framework.

With that said, we’re ready to code. Let’s make:

  • 1 Landing page where we distract new users while warming up the Lambda.
  • 1 Page where we can create items and see the list.

Landing page (https://github.com/nalkhish/cheap-low-traffic-stack/blob/main/frontend/src/app/page.tsx):

import Link from "next/link";
import LambdaWarmer from "./LambdaWarmer";

export default function Home() {
return (
<main>
<LambdaWarmer />
<Link href="/items">Items</Link>
<h1>Home</h1>
<p>
Look here while the Lambda warms up or the items page might be slow!
</p>
</main>
);
}

And the Lambda Warmer (https://github.com/nalkhish/cheap-low-traffic-stack/blob/main/frontend/src/app/LambdaWarmer.tsx):

"use client";
import React from "react";
import { apiHost } from "@/constants";

export default function LambdaWarmer() {
React.useEffect(() => {
fetch(`${apiHost}`);
}, []);
return null;
}

The page where we create and add items (https://github.com/nalkhish/cheap-low-traffic-stack/blob/main/frontend/src/app/items/page.tsx):

"use client";
import { apiHost } from "@/constants";
import React from "react";
import Link from "next/link";

export default function Items() {
// Get items
const [items, setItems] = React.useState<
Record<string, { id: string; description: string }>
>({});
React.useEffect(() => {
fetch(`${apiHost}/items`)
.then((res) => res.json())
.then((json) => setItems(json));
}, []);

const [newItem, setNewItem] = React.useState("");
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
fetch(`${apiHost}/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
description: newItem,
id: new Date().toISOString(),
}),
})
.then((res) => res.json())
.then((json) => {
setItems((items) => ({ ...items, [json.id]: json }));
setNewItem("");
});
};

return (
<main>
<Link href="/">Home</Link>
<h1>Items</h1>
<h2>Current items</h2>
<ul>
{Object.values(items).map((item) => (
<li key={item.id}>{item.description}</li>
))}
</ul>
<h2>Add a new item</h2>
<form onSubmit={handleSubmit}>
<input
type="text"
value={newItem}
onChange={(e) => setNewItem(e.target.value)}
/>
<button type="submit">Add</button>
</form>
</main>
);
}

That’s the unique code we need (in addition to the supporting files https://github.com/nalkhish/cheap-low-traffic-stack/blob/main/frontend/). Now, we’re ready to deploy.

As mentioned in the permissions section, we will deploy it using the console because we need to authorize Amplify access to our repository. This sort of works out because you don’t have to set up the Github cli (gh) or, if you’re using a different repository host, translate from Github to your repository host.

  1. Go the app creation view: https://us-east-1.console.aws.amazon.com/amplify/home?region=us-east-1#/create
  2. Select your repository host. I’m using Github for this project.
AWS console view of creating an AWS Amplify app, step of choosing repository host options. Pictured are Github, Bitbucket, Gitlab, AWS CodeCommit, and Deploy without Git provider.
Figure 6. Repository host options on AWS Amplify console.

3. Follow along your repository host’s experience for authorizing AWS Amplify to read the repository we want deployed.

4. After authorizing, you need to go back to the app creation view https://us-east-1.console.aws.amazon.com/amplify/home?region=us-east-1#/create.

5. Now select the repository we authorized

UPDATE: for the next step (6), if you cloned the repository [https://github.com/nalkhish/cheap-low-traffic-stack] or copied the directory structure, you’ll need to select that the repository is a monorepo and that the root is frontend. It should detect that we’re using Next.js.

6. You will also have to select a branch. I selected the main, but you might want to create a dedicated branch for AWS Amplify because it automatically redeploys when your branch is updated. This should take you to the Build settings page, where it automatically detected your settings (figure 7).

AWS console view of creating an AWS Amplify app, step of choosing build settings. AWS Amplify correctly auto-detected the build settings. Additionally, pictured is (1) an app name of cheap-low-traffic-stack-frontend and (2) a radio selection allowing creation of an IAM role.
Figure 7. AWS Amplify console auto-detected my app’s build settings.

7. Click Next.

8. Save and Deploy.

The frontend should be deployed within 5 minutes. You should be able to access it if you click on the link at the bottom left (figure 8).

AWS console view after creating an AWS Amplify app. AWS Amplify went through the provisioning, building, and deployment steps. On the left of the card, pictured is a preview and a link.
Figure 8. AWS Amplify deployment is finished.

If you need to update the frontend deployment, make changes and push to the branch.

That was 3/3. We’re done.

Cleanup

Don’t forget to deactivate the user access key at the user credentials view: https://us-east-1.console.aws.amazon.com/iamv2/home?region=us-east-1#/users/details/user_1?section=security_credentials.

Caveat

One caveat is that this assumes no security from bots as provided for $8/month by AWS WAF (https://aws.amazon.com/waf/).

Conclusion

In this blog post, we discussed and deployed a serverless full frontend-backend-db stack for $0/month base.

For the database, we used AWS’s DynamoDB with the downside that we are restricted to using NoSQL.

For the backend, we used Python-FastAPI on AWS Lambda with (i) the potential of it being more expensive than a traditional EC2 at rate of 2,000 requests/hour and (ii) a long cold-start duration if the app becomes too large.

For the frontend, we used Typescript-NextJs-React frontend on AWS Amplify with seemingly no restrictions or disadvantages.

I realize this was a massive read, so a massive thank you for reading! If you want to see more software engineering ideas, follow my medium account.

--

--

Nassir Al-Khishman

My passion is optimizing Python backends and ML infrastructure. I am a software engineer at abahope.com