Part 1: Choosing the Tech Stack & High-Level AWS Architecture
This four-part series breaks down the architecture and engineering behind RAVN, a full-stack, analytics-first hypertrophy app I built on AWS to solve the limitations of standard fitness trackers. As a metrics-driven lifter, I initially relied on HEVY to log my sessions, but its fundamentally static database approach lacked the live in-session feedback and deep post-workout analytics required to properly dial in reps, sets, and exercise selection. After spending too much time exporting raw CSVs into Gemini just to figure out if my programming was actually optimal and whether I had gaps in my routine, I realised there was a clear market gap for a premium, data-centric platform. Drawing directly on the architectural patterns and ecosystem pitfalls I learned completing my DVA-C02 certification, I engineered RAVN across a multitude of AWS services to deliver an asynchronous, highly responsive application that runs as leanly and efficiently as possible.
Languages and frameworks
Treating this as any typical full-stack project, I first needed to choose my UI and was already leaning towards a web app over a native client to have the widest device compatibility out of the box. Because of my disproportionate bias towards the backend, I wanted a simple UI framework with plenty of premade components which allows for rapid development. I decided to choose Next.js because of existing knowledge and because it is fast to develop with using its folder-based routing.
Due to the bias for the backend and recurring pattern of supply chain attacks caused by deep dependency trees in Node.js projects, I decided to keep the Next.js layer thin and allocate all business logic and heavy lifting to the server instead.
Choosing the backend language and framework required some deliberation; given my history, I was focused on Python, Java and C++; however, I was never satisfied with any of these languages. Python proved itself to be slow, mostly because of its interpreter; Java has a memory issue from its dependency on the JVM for cross-platform compatibility, but Java is definitely a speed improvement over Python. And finally, for C++, I loved C++ for its speed, but the language felt bloated, build times were lacklustre, and it has a non-existent package manager.
From my existing language choices, I decided I wanted to try something new, but this new language needed to have the raw speed of a compiled language and the conciseness of Python. Like any modern language, it should have a package manager, which led me to two options: Go and Rust. Between the two, Rust appeared to be more of a low-level systems language. It can be used for web servers; however, with the complexities it brings, it didn't present itself as the right option for me. I was then left with one choice, which was Go, the cloud-native language built as a concurrency-first language. At the end of the day, programming languages are tools, and I strategically decided on the right tool.
After choosing Go, I then went and started learning the language, and for this project specifically, my intuition led me to search for the best API framework. Searching online led to mixed recommendations, with one half being Gin and the other suggesting using the built-in net/http. Coming from other languages, using an API framework seemed mandatory, but in hindsight, using a framework at all was unnecessary with Go, as Gin was mostly just a wrapper over the built-in net/http framework anyway, and following my prior principles, importing pointless libraries just invites supply chain risks.
Because of my preference for server-side engineering, choosing UI frameworks just came down to experience for faster development. I had experience with TailwindCSS and DaisyUI because of how simple they are to build clean interfaces without having to tinker with complex JavaScript.
What would we do without memory? The next logical component to my core services would be a caching service and a database. I had decided to go with Postgres for the database, opting for a tried-and-tested database which I've had previous experience with. I needed a cache for reduced server load and faster responses to repeat requests, and I went with the battle-tested Redis cache.
The architecture at this point revolved around everything existing as containers on the single EC2 instance, which was simple; however, it caused the issue of making the system stateful. The Postgres and Redis cache memory were coupled to the one system, which was a detriment to horizontal scaling. Ideally, I would have instead used AWS RDS and ElastiCache, but since one of this project's aims revolved around leanness, RDS and ElastiCache simply cost too much.
Alternatives exist such as Redis Cloud and Aiven, which are relatively cheap; however, my challenge was to keep this project as close to AWS as possible, and so for the time being, I kept with the constraint of the single EC2 instance.
AWS Architecture
With the objective of sticking with AWS services, I had a couple of options on how to architect the core compute. The first and most tempting way would be serverless, with its massive free tier and pay-as-you-go model. This makes running apps initially cheaper; however, as applications scale, so does the cost and migrating an app off serverless back to a traditional model is time-consuming. For this reason, I steered clear of Lambda and DynamoDB due to my preference for a consistent pricing model. For solo developers, an untamed, inconsistent pricing model works against us. High traffic can bankrupt people, unlike with larger companies. However, as seen later on, Lambda does have its benefits, and its sweet spot is seen when used in bounded settings like internal tools or for fixed call counts.
Another common architectural pattern is the Application Load Balancer (ALB) paired with an Auto Scaling Group (ASG). This is where the ASG handles the horizontal scaling, allowing the scaling of an application to a max and min capacity, with the availability of using spot instances, which provide discounted compute. The ALB routes incoming traffic to these separate instances and can direct traffic based on specific rules. Furthermore, with the combination of AMIs and UserData startup scripts, you can have the scaling of an application fully automated.
Considering the steep cost of the ALB, for a solo developer, it is hard to justify and goes against the core principles of this project entirely. It simply means it's overkill to use during the early stages of the project and so had to be skipped.
My personal preferred method, from the teachings of DVA-C02, would be to go for Fargate. It's an AWS serverless service which manages containers for you, essentially Kubernetes without the mental tax of monitoring and updating your EC2 instances.
Similar to the ALB + ASG solution, Fargate isn't exactly the cheapest option in the playbook and, for an early-stage project, a cost that wasn't necessary.
The simplest and by far the cheapest option would unsurprisingly be a single EC2 instance with Docker installed. Booting up containers with a clean Docker Compose file. Another major reason why this was the cheapest option was the generous AWS free trial for Graviton processors, which meant there was a free trial running for t4g.small; however, this didn't cover IP and EBS costs.
End of Part 1: Next up in Part 2: Async Ingestion, Cognito & Asset Optimization →
Comments ()