A Theory Board for Book Fans
I wanted to build something I actually cared about. Not another crud app. So I built Theory Trove, an anonymous fan theory board for the Maasverse.
Visit theorytrove.comIntro
I am a stay at home mom, an AWS Community Builder, a self taught cloud engineer, and a book nerd. Not in that order on any given day.
I have always loved reading but it was ACOTAR that turned me into a collector. I picked it up, finished the series, moved on to Throne of Glass, and by the time I was done with that Crescent City was coming out. At some point between reading and rereading and buying special editions I realized I had become a collector. And I have no regrets about it.
I have been learning AWS for a while now and I have built projects that looked good for my resume and helped me with my job search. They were good projects. But I wanted to build something I actually cared about. Something for a fandom I love. Not another crud app.
With ACOTAR books 6 and 7 coming out it felt like the perfect time to build something that actually matters to me. So I built Theory Trove, an anonymous fan theory board for the Maasverse. A place where SJM fans can share their theories freely without negativity or arguing. Just theories, just the fandom, just good vibes.
This is the story of how I built it.
What is Theory Trove
Theory Trove is a web app built for the SJM fandom. Anyone can visit theorytrove.com and submit an anonymous theory about any book in the Maasverse, whether that is ACOTAR, Throne of Glass, Crescent City, or the crossover connections between all three.
Theories are limited to 280 characters. You can add tags like character names, book titles, or lore concepts so other fans can filter and browse by what they care about. You can also add an optional book and chapter reference if you have receipts to back up your theory.
There are no accounts. No usernames. No replies. Just theories.
Each theory can receive one of six Maasverse themed reactions: a star, a flame for Fireheart, a black heart for the mate bond, a moon for the Night Court, a sword for Throne of Glass, and a mask for the Dread Trove. You can only react once per theory per device so no spam.
There is a rules page that makes it clear: if you disagree with a theory, keep scrolling. This is not a debate space.
There is also a contact form if you need to reach me, and a Ko-fi link if you want to help keep the site running.
The Tech Stack
Before I get into the how, here is what I built this with.
Frontend: React served by Nginx running in a Docker container on ECS Fargate. I chose React because I wanted a dynamic UI. Nginx handles serving the static build in production.
Backend: Python and Flask. Also containerized and running on ECS Fargate. Flask handles all the API routes, DynamoDB calls, and SNS publishing.
Database: Amazon DynamoDB. Two tables, one for theories and one for tracking reactions by IP address. I used DynamoDB Local during development so I could test without touching real AWS or spending money.
Containers: Docker and Docker Compose for local development. Both containers run as non root users, which is a security best practice that most beginner projects skip. The frontend uses a multi stage Docker build so only the production Nginx image gets deployed, not the Node build environment.
Container Registry: Amazon ECR to store the Docker images.
Orchestration: Amazon ECS Fargate. No servers to manage. Fargate handles running the containers. I chose this over EKS because the Kubernetes control plane alone costs roughly $72 per month and I cannot afford that for a fan site.
Networking: A custom VPC with public and private subnets. The ALB sits in the public subnets. The ECS tasks sit in the private subnets. A NAT Gateway allows the containers to pull images from ECR and reach AWS services without being directly exposed to the internet.
Load Balancer: An Application Load Balancer routes traffic. Frontend requests go to port 8080. API requests matching the path /api/* route to the Flask backend on port 4567.
DNS and SSL: Route 53 for the domain theorytrove.com and AWS Certificate Manager for the SSL certificate so the site runs on HTTPS.
Notifications: Amazon SNS sends contact form submissions to my inbox. The email address is never exposed publicly.
Secrets: AWS Secrets Manager stores the admin key used for the moderation delete endpoint.
Observability: Amazon CloudWatch for container logs.
Local Development
Before anything went to AWS I needed to get everything running locally. I used Docker and Docker Compose to spin up three containers: the Flask backend, the React frontend, and DynamoDB Local.
DynamoDB Local is an official AWS tool that runs DynamoDB on your machine so you can develop without touching real AWS and without spending money. It runs in its own container and stores data in a local volume so it persists between restarts during development. I created a bin/create-tables.sh script to create both DynamoDB tables locally so I did not have to type long AWS CLI commands every time I needed a fresh environment.
One thing I want to highlight because most beginner tutorials skip it: both containers run as non root users. By default Docker containers run as root which is a security risk. I configured a non root user in both Dockerfiles. For the backend this was straightforward. For the frontend running Nginx as a non root user required a custom nginx.conf that points all temp directories to /tmp since non root users cannot write to the default Nginx cache directories. I also had to change the port from 80 to 8080 because non root users cannot bind to ports below 1024. It took some debugging but it was worth it.
The frontend also uses a multi stage Docker build. The first stage uses Node to install dependencies and build the React app into static files. The second stage copies only those static files into a clean Nginx image. This keeps the production image small and means Node never ships to production.
I created a .dockerignore file for both services. Without it Docker was trying to copy the entire node_modules folder into the build context which was over 200MB. That is a mistake you only make once. I also kept a .gitignore at the root to make sure AWS policy files, task definitions, and anything with sensitive information never made it to GitHub.
When I added the six Maasverse reactions and updated the schema I had to use --force-recreate to make sure Docker was not using a cached image with the old reaction format. Without it the frontend was still rendering the old single reaction count and the page was crashing.
You might wonder why I used Docker Compose locally instead of Kubernetes. Honestly I thought about it. But if I had used Kubernetes locally everyone would have asked why I did not deploy to EKS. And I would have had to explain that the EKS control plane costs $72 per month and I cannot afford that for a fan site. The whole decision came down to one question: how can I save money?
The Frontend
The frontend is a React app served by Nginx. I wanted it to feel like the Maasverse, not like a generic tech project. So I made real design decisions.
For the font I chose IM Fell English from Google Fonts. It has that old printing press feel, like something you would find in a grimoire or an ancient library, which I love. It fits the world Sarah J. Maas has built perfectly.
For the color palette I looked at every SJM book cover across all three series including the newly revealed ACOTAR books 6 and 7 covers. The result is a dark theme built around jewel tones: sapphire for ACOTAR, emerald for Throne of Glass, deep crimson for Crescent City, violet for the Valkyrie Cycle, and gold for crossover theories. Each theory card gets a colored border and label based on its tags. Dark background, warm cream text.
Between each section of the page I added faint SVG dividers. A deer silhouette, a mountain icon, and a compass star. Each one is transparent enough to feel atmospheric without competing with the content.
The theory cards display the theory text in italics, the tags as colored outlined badges, the book reference, the date, and the six reaction buttons. Each reaction button grays out after you click it so you cannot spam it. The grayed out state persists across page refreshes using localStorage on the frontend, and the actual rate limiting happens on the backend using DynamoDB so it cannot be cheated by clearing your browser storage.
The filter bar at the top lets you browse by series. Click ACOTAR and you only see theories tagged with ACOTAR. Click ALL and everything comes back.
There is also a Rules page and a Contact page, both accessible from the nav. The footer on every page has a link to the Ko-fi page for anyone who wants to help keep the site running.
The Backend
The backend is a Python Flask app running in a Docker container on ECS Fargate. It handles five API routes.
GET /api/health is a simple health check endpoint. The ALB pings this to confirm the container is alive before routing traffic to it.
GET /api/theories queries DynamoDB for all theories newest first and returns them as JSON to the frontend.
POST /api/theories receives a theory submission, validates that the text is not empty and is under 280 characters, and writes it to DynamoDB with a UUID, timestamp, tags, reference, and a reactions map initialized to zero for all six reaction types.
POST /api/theories/<theory_id>/react handles reactions. This was the most interesting route to build. When someone clicks a reaction the backend checks a second DynamoDB table called theory-trove-reactions. The table stores a composite key of the user's IP address and the reaction type. If that combination already exists it returns a 409 and the reaction is not counted. If it does not exist it writes the record and increments the reaction count on the theory. This means each IP address can only give each reaction type once per theory. You cannot spam the star button and you cannot cheat it by clearing your browser because the check happens server side.
POST /api/contact receives a name, subject, and message from the contact form and publishes it to an SNS topic which delivers it to my inbox. The email address is never exposed publicly.
DELETE /api/admin/theories/<theory_id> is the moderation endpoint. If someone submits something mean or inappropriate I can delete it by sending a DELETE request with a secret admin key in the request header. The key is stored in AWS Secrets Manager, not hardcoded anywhere. ECS pulls it from Secrets Manager at runtime via the task definition. This keeps the key out of my code and out of GitHub.
CloudWatch logging is wired up so every request gets logged. If something breaks in production I can go to CloudWatch and see exactly what happened.
Deploying to AWS
This was the most involved part of the project and honestly the most satisfying when it finally worked.
The first thing I did was push both Docker images to Amazon ECR. I built and tagged each image locally and pushed them to their respective repositories. ECR stores them so ECS can pull them at runtime.
I created a custom VPC instead of using the default. The VPC has two public subnets and two private subnets across two availability zones. The ALB sits in the public subnets. The ECS tasks run in the private subnets. A NAT Gateway sits in the public subnet and allows the containers to reach ECR and AWS services without being directly exposed to the internet. An Internet Gateway handles traffic in and out of the VPC.
For security groups I followed least privilege. The ALB security group accepts traffic from anyone on ports 80 and 443. The ECS security group only accepts traffic from the ALB security group on ports 4567 and 8080. Nobody can reach the containers directly.
The ECS task definition defines both containers, their images, their ports, their environment variables, their log configuration pointing to CloudWatch, and the secret pulled from Secrets Manager for the admin key. The task role gives the containers permission to read and write to DynamoDB and publish to SNS. The execution role gives ECS permission to pull images from ECR and write logs to CloudWatch.
The ALB has two listeners. Port 80 redirects to HTTPS. Port 443 forwards to the frontend target group by default and routes anything matching /api/* to the backend target group. This means the frontend and backend share one domain and one load balancer with no CORS issues.
For the domain I registered theorytrove.com through Route 53. I requested an SSL certificate through AWS Certificate Manager using DNS validation. Route 53 made the DNS validation easy since both the domain and the hosted zone are in the same AWS account. Once the certificate was issued I added the HTTPS listener to the ALB with the certificate attached.
Then I typed theorytrove.com into my browser and it loaded. Pretty nice moment.
What I Learned
Running Nginx as a non root user was something I had already worked through in a previous project but it was a good reminder of why it matters. The custom nginx.conf pointing temp directories to /tmp and running on port 8080 instead of 80 are not optional when you care about security. Every project I do reinforces these habits.
The ACM certificate validation took longer than I expected. I added the CNAME records to Route 53 and then waited almost two hours for the certificate to go from pending to issued. It turned out I was missing the validation record for the www subdomain. Once I added that it validated much faster. Always check that both the root domain and the www subdomain have their validation records.
DynamoDB schema changes are not forgiving in local development. When I changed the reactions field from a single number to a map of six reaction types the old test theories in DynamoDB Local caused the frontend to crash. React tried to render an object as text and the whole page went black. I had to wipe the local DynamoDB data and start fresh. In production you would handle this with a data migration but for local development starting fresh was the right call.
The NAT Gateway is expensive. At roughly $32 per month it is the biggest cost driver in this project. It is necessary because the ECS tasks are in private subnets and need a way to reach ECR and AWS services without being on the public internet. I knew this going in and I made the decision deliberately. Private subnets with a NAT Gateway is the right architecture even if it costs more.
AWS Secrets Manager was easier to use than I expected. Storing the admin key there instead of hardcoding it took maybe ten extra minutes and it means the key never touches my code or GitHub. Worth it every time.
What is Next
Theory Trove is up but it is not done.
The first thing I want to add is Cloudflare Turnstile for bot protection on the submission form. Right now I have IP rate limiting on the backend for reactions but the theory submission form has no bot protection. Honestly I forgot to add it in the first place. Turnstile is free, invisible to users, and does not require solving a puzzle. It is on the list.
Next is CodePipeline. Right now deploying a change means building the Docker image locally, pushing to ECR, and updating the ECS service manually. I want a proper CI/CD pipeline where pushing to GitHub triggers a build and deployment automatically. CodePipeline, CodeBuild, and CodeDeploy will handle that.
I also want to add a fan art section for licensed artists. SJM has a licensing program and a lot of artists in this fandom have licenses for their work. I want to give them a space to share it on Theory Trove. This will require S3 for image storage, a more robust moderation workflow, and some additional DynamoDB schema work.
And eventually I want to migrate the infrastructure to Terraform. Right now everything was provisioned manually through the AWS CLI. Terraform will make it reproducible, version controlled, and easier to tear down and rebuild.
If you made it this far thank you for reading. If you are an SJM fan go submit a theory at theorytrove.com. And if you are a cloud engineer just getting started I hope this showed you that you do not have to build boring projects to learn AWS.
Unofficial fan site. All characters and lore belong to Sarah J. Maas and Bloomsbury Publishing. No affiliation or endorsement implied.
Want to see the code? Check out the GitHub repository or connect with me on LinkedIn.