Deploying a full-stack app on Azure with Terraform and GitHub Actions

saltypoolwater.com is a public, production comedy site running on Azure. Every resource behind it is defined in Terraform and deployed by a pipeline. This is how it was built, the decisions I made, and the things that broke.
Visit the live site View the code
Azure Static Web Apps Azure Functions Cosmos DB Azure DNS Terraform GitHub Actions React
The saltypoolwater.com feed showing complaints with hidden punchlines and vote counts

The feed: one complaint per user per day, punchlines hidden until tapped.

Why I built it

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.

Architecture

LayerWhat it runs on
Front endReact SPA on Azure Static Web Apps, served from Azure's edge
APIAzure Functions (HTTP triggers), managed by the Static Web App
DataCosmos DB (NoSQL) — complaints, votes, and profiles containers on shared throughput
DNSAzure DNS zone with apex A record, www CNAME, and a TXT verification record
IdentityStatic Web Apps built-in auth (GitHub and Microsoft sign-in)
InfrastructureTerraform — every resource above, including the custom domain bindings
DeliveryGitHub Actions: push to main builds and deploys the front end and API

Decisions and trade-offs

Static Web Apps instead of App Service

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.

Cosmos DB instead of Azure SQL

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).

Terraform instead of the portal or Bicep

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.

What went wrong

A credential in a file destined for GitHub

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.

Reconciling a portal-created resource with Terraform

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.

Terraform and the deployment pipeline fighting over the same resource

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.

Cold starts on the API

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.

Plan limits I should have read first

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.

A query Cosmos wouldn't run

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.

What I'd do differently

A note on tooling

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.

Visit the live site View the code