What I Learned Building a Budget Serverless App on AWS
This project started out of pure curiosity. I wanted to know what serverless development actually feels like, beyond the theory and honestly, a lot of the initial idea came from studying for the AWS Solutions Architect Associate exam, where AWS SAM kept coming up as the go-to way to build and test serverless apps locally. I figured the best way to actually understand it was to build something small and real, with cost as the main constraint the whole way through.
The app itself is a simple CRUD system with personal note-taking and wiki capabilities. The note-taking piece has one extra feature which is it can notify you via SMS and app notification. This is what pulled real-time infrastructure into the picture later on.
Here's what I ended up with, why I made the choices I did, and where things got genuinely difficult.

Why serverless, why not EC2
The first and most obvious choice was AWS Lambda. The appeal was simple. Instead of paying a fixed price whether or not anyone's using the app ,which is exactly what you get with EC2, Lambda bills you based on actual usage. For a personal project with unpredictable, low traffic, that's the ideal way to pay for compute. You're not renting a server that sits idle most of the day.
To make that Lambda function callable from a frontend, I paired it with API Gateway. And for the frontend itself, I went with a single-page application deployed to S3 rather than hosting it on a server because a static SPA is cheap to host, and the only real cost you're looking at is egress, since S3 itself comfortably sits within the AWS free tier for something this small.
For authentication, I used Cognito. It's generous on the free tier, and it comes with built-in MFA support (authenticator app based), which meant the auth layer of the app was solid without me having to build or manage any of that myself.
The database decision and where it got hard
For the database, I went with DynamoDB, mainly because its free tier is also very generous, and because I didn't actually need what a relational database like MySQL solves which is joining and aggregating across related tables. My app is a simple CRUD system, so I didn't need that relational complexity. Using something like RDS would've meant paying a fixed price whether I used it or not, which goes against the entire point of this project.
This is honestly the part that gave me the most trouble. I was used to SQL, so switching to DynamoDB was a real shock. In SQL, you can join tables on the fly, filter with a WHERE clause, and add an index to make it fast. It's flexible and forgiving. DynamoDB doesn't work like that at all. You essentially have two ways to query efficiently: a global secondary index or a local secondary index, and the primary key of that index is what actually determines what you can filter on.
That means if you want to filter on two different things like sorting a list chronologically and filtering it by category, there's a good chance you need two separate global indexes for those two access patterns. To replicate something as simple as "order by newest first, but only show items in this category," you often need to create a composite sort key ahead of time, for example, combining a timestamp and a category into a single column, because DynamoDB doesn't let you order by one column and filter by another on the fly the way SQL does.
It's a real trade-off. SQL gives you flexibility at query time while DynamoDB asks you to think hard about your access patterns before you write a single line of application code. Once that clicked, it stopped feeling like a limitation and started feeling like a different (and honestly quite fast) way of designing data but that shift in thinking was the steepest part of the learning curve on this whole project.
Real-time notifications
To power that note-taking notification feature, I added the WebSocket API Gateway paired with SNS, which handles both the SMS and in-app notification side. It's cheap enough that it barely moved the needle on cost, and it added a real-time feel to the app without running a persistent server just to hold connections open.
Locking it down — is it worth the cost?
Security is the one part of this stack that's genuinely optional and depends entirely on how seriously you take the app. But if you actually intend to use what you built, it's worth the small extra spend.
Here's what I set up:
CloudFront in front of everything, with WAF attached to it for filtering malicious traffic
Origin Access Control (OAC) on the S3 bucket, so it's only reachable through CloudFront and never directly.
On the API Gateway side, a rotating secret header that CloudFront attaches to every request it forwards. If a request hits API Gateway without that secret (meaning it skipped CloudFront entirely), it gets rejected with a 401. This closes the gap where someone finds your raw API Gateway URL and hits it directly, bypassing CloudFront, WAF, and everything else you've set up at the edge.
For a project like this, I think that's genuinely enough unless you're storing highly sensitive data or running something mission-critical, in which case you'd want to go further.
One more small thing worth mentioning if you're using a custom domain, ACM certificates are free, but the domain itself isn't. And if you're using CloudFront specifically, remember that your ACM certificate has to be requested in us-east-1, regardless of where the rest of your stack lives. This catches almost everyone the first time. DNS validation is the way to go over email validation too, since it's fully automatable and doesn't depend on an inbox existing at the right address.
Where the money actually goes
At the end of all this, my total monthly cost sits at roughly $7–10, and almost all of it comes from WAF which is the one piece of this stack that doesn't scale down to zero the way Lambda, DynamoDB, and S3 do. Everything else is essentially usage-based and stays close to free at this scale. WAF is a flat monthly fee just for existing, which is why it stands out on the bill even though it's the smallest, simplest-looking piece of the architecture.
The takeaway
Going in, I expected the hard part to be Lambda or API Gateway which is the "serverless" pieces everyone talks about. It wasn't. The real learning curve was DynamoDB, and specifically unlearning SQL instincts and designing around access patterns from the start. Everything else cost control, security, real-time features fell into place once I stopped fighting the NoSQL mental model and started designing for it.
If you're building something for yourself and cost is the real constraint, this stack gets you remarkably close to free with the one honest exception being WAF, which is a small, worthwhile price for not leaving the whole thing wide open.