The feed: one complaint per user per day, punchlines hidden until tapped.
After earning AZ-104 and AZ-305, I wanted something the exams don't give you: a real workload I owned end to end, running in production, with infrastructure defined as code and deployed through a pipeline instead of clicked together in the portal. I wanted it small enough to finish, but with enough moving parts — compute, data, DNS, identity, CI/CD — to touch the things I'd actually be responsible for in a cloud role.
The app is a comedy site: an open-mic night for petty grievances. Every user gets one complaint per day, written as a setup and a punchline. Punchlines stay hidden in the feed until tapped, and readers vote on whether the joke landed. Users have profiles with a headline, a bio, a custom emblem, and an activity history. The premise is light on purpose. The infrastructure underneath it is the point.
| Layer | What it runs on |
|---|---|
| Front end | React SPA on Azure Static Web Apps, served from Azure's edge |
| API | Azure Functions (HTTP triggers), managed by the Static Web App |
| Data | Cosmos DB (NoSQL) — complaints, votes, and profiles containers on shared throughput |
| DNS | Azure DNS zone with apex A record, www CNAME, and a TXT verification record |
| Identity | Static Web Apps built-in auth (GitHub and Microsoft sign-in) |
| Infrastructure | Terraform — every resource above, including the custom domain bindings |
| Delivery | GitHub Actions: push to main builds and deploys the front end and API |
The front end is static and the API is a handful of HTTP endpoints, so an App Service plan would have meant paying for idle compute around the clock. Static Web Apps bundles hosting, the Functions API, managed SSL, and a deployment workflow into a single resource, and the Free tier costs nothing.
The data is simple documents with no relational joins — a complaint, a vote, a profile. I wanted experience with a NoSQL service I didn't have to run a server for. Partition key design was the part that actually required thought, and I got it wrong once (see below).
I chose Terraform for portability across providers and because plan forces you to read what you're about
to change before you change it. Building a real project turned out to be far better preparation than reading about
state files.
Early on I wrote the service principal's client secret directly into a .tf file. It worked, and that was
the problem — that file was headed for a repository, and bots scan public repos for exposed credentials within
minutes. I treated the secret as burned rather than hoping: rotated it in Entra ID, deleted the old one, and moved
all four values into environment variables (ARM_CLIENT_ID, ARM_CLIENT_SECRET,
ARM_TENANT_ID, ARM_SUBSCRIPTION_ID), which the azurerm provider reads automatically when the
provider block doesn't supply them.
The principle I took from it: configuration describes what to build and gets committed. Credentials describe who's asking and get injected from outside. That separation is why the same code runs on a laptop and in CI without edits.
When I added user profiles, the feature needed a new Cosmos container. The Azure CLI was failing against one of my
tenants at the time, so I created the container in the portal to unblock the deploy. That left my infrastructure code
untrue: the container existed in Azure, but nothing in my .tf files described it. A rebuild from scratch
would have produced a site that failed on every profile request.
The fix was to write the matching resource block and use terraform import to bring the existing container
under management, then confirm with terraform plan that code and reality agreed with no drift. This is
exactly the situation you inherit at any organization where the portal came before the code, and it taught me more
about how state actually works than a clean apply ever did.
Every plan wanted to rewrite the Static Web App's repository_url and
repository_branch, because GitHub Actions sets those at deploy time and Terraform wanted them back at the
values in my config. Perpetual drift on a resource nobody was really changing. The fix was a
lifecycle { ignore_changes = [...] } block, which is really a decision about ownership: Terraform owns
the resource, the pipeline owns those two fields, and the config now says so explicitly.
The managed Functions behind a Static Web App run on consumption-style compute, so after a quiet period the first request has to spin up the function host — the feed would occasionally take several seconds to appear. Fixing it properly means moving to the Standard tier and bringing your own Functions app on a plan with pre-warmed instances, which doesn't make sense for a hobby project's budget. I accepted it and documented it as a known limitation. That's the same trade-off conversation I'd expect to have with a stakeholder about any low-traffic workload: the fix costs money, the symptom costs a few seconds, and someone has to decide which matters more.
I wanted users to create their own accounts rather than sign in with GitHub or Microsoft. Self-service signup requires custom authentication through an external identity provider, and custom auth is a Standard-tier feature — on the Free plan you get the pre-configured providers and nothing else.
Deployments work differently here too. Static Web Apps has no swap-based deployment slots like App Service; instead you get preview environments, which spin up automatically for each pull request and can also be configured as stable named environments. The Free plan caps you at three, and custom domains don't work with them at all — so there's no way to test a domain binding anywhere but production. My release process became "test on a PR preview, merge, watch production deploy." That's fine for one person, and it made me think harder about what a real release process needs. Both were lessons in reading a service's plan limits before designing around them.
The profile activity feed returned 500s in production. The cause was an ORDER BY across a JOIN, which
Cosmos won't execute without a composite index. Rather than add an index for one small query, I dropped the sort from
the query and did it in JavaScript after retrieval — at this data volume it costs nothing and it removed the
dependency entirely.
curl and status
codes.I used AI coding assistants to accelerate the React front end and parts of the Functions code. The Azure architecture, Terraform configuration, DNS setup, and CI/CD pipeline are my own work, and every resource in this project is something I can explain and rebuild from scratch.