How I built this website for free on Firebase
This site costs nothing to run. No server, no database I manage, and it still answers questions with Gemini. Seven diagrams showing how it fits inside a free tier, and what that limit forced me to do better.
I design architectures for a living and then hand them to someone else to run. This one I had to run myself, on a budget of zero, which turned out to be a more honest exercise than most design reviews.
The constraint is Firebase Hosting’s Spark plan: 10 GB stored, and 360 MB of transfer per day. It isn’t throttled. Cross it and the site stops serving until the quota resets at midnight Pacific. There are no Cloud Functions on Spark, no App Hosting, nothing server-side at all. Every decision below falls out of that.
What a page view costs
Start with the bill, because it decides everything else.
bytes served for a normal page view versus after pressing Command-K
Those are measured, not estimated: gzipped bytes out of dist/ for the home page as it stands today.
The ratio is the interesting part. The Firebase SDK is 555 KB uncompressed. Import it at the top of the page, the way most tutorials do, and every visitor who never touches the assistant pays for it anyway — the daily ceiling falls from about 13,500 visitors to about 1,600. So Firebase sits behind a dynamic import() that runs the first time someone presses ⌘K, and not otherwise. The 4.5 KB that does load on every page is the command palette skeleton and a telemetry ping.
Fonts come from Google’s CDN rather than my origin, so they never touch the budget. Images are the other line item that matters, and Astro converts every image referenced by a relative path to WebP at build time. The screenshots in the agent post went from roughly 900 KB of PNG each to about 40 KB.
The assistant has no backend
The ⌘K box answers questions about this site using Gemini. There is no API of mine anywhere in that path, because Spark doesn’t give me one to build.
the request path for the ask feature, from browser through App Check to Gemini
Which raises the obvious question. If the browser calls Google directly, what stops someone lifting the config out of my JavaScript and spending my quota?
App Check. The client has to present a reCAPTCHA Enterprise attestation before the AI Logic endpoint will talk to it. The Firebase config being public is fine — it’s an address, not a credential, and treating it as a secret is the mistake that leads people to build a proxy they didn’t need. Enforcement becomes mandatory on 2 November 2026 in any case.
The model list is the part I got wrong first. Google’s AI Logic documentation listed a model that the pricing page classifies as paid-tier only. Call it on a free-tier key and you get a 500 that looks exactly like an infrastructure fault, with nothing in it to suggest billing. That cost me two hours. The list is now free-tier models only, in fallback order, with the reason written in a comment above it so I can’t repeat the mistake in six months.
Retrieval without a vector database
The assistant is grounded in the site’s own content. Which means retrieval, and retrieval usually means embeddings, a vector store, and something running to serve them. I have none of those.
I also have fifteen documents.
how the site index is built and how documents are selected for the prompt
Keyword scoring over fifteen documents beats cosine similarity over fifteen documents, because the failure mode of a small corpus is not “semantically near but lexically distant”. It’s “the user asked about SAP and there is exactly one document about SAP”. Title and tag hits outweigh body hits, the CV profile gets a standing bonus because it’s nearly always useful context, and anything scoring zero is dropped rather than padded in.
If nothing scores, the assistant says there’s nothing on the site about that, and never calls the model at all. That’s the cheapest possible answer to an off-topic question, and it’s more honest than letting a model improvise.
The whole retrieval layer is about forty lines. It will stop being adequate somewhere north of a hundred posts, and at that point the right move is a build-time embedding index shipped as a static file, not a server. I’ll write that post when I get there.
What happens when it fails
Most of the code in the assistant isn’t the happy path.
the failure ladder from rate check through model fallback to the message the visitor sees
An earlier version of this printed the raw error into the panel. It was useful to me for about a day and it was leaking the shape of my configuration to everyone else, which is the sort of thing that reads as harmless right up until someone assembles four harmless things. Now the visitor gets one sentence saying the assistant couldn’t answer and the rest of the site still works. The detail goes to the log, and to a __ask.diag object I can inspect from my own console.
The rate limit is worth being precise about, because it’s easy to mistake for a control. It’s five questions a minute and thirty a session, enforced in the browser, which means anyone determined can bypass it by reloading. It isn’t there to stop an attacker. It’s there so an impatient visitor holding down Enter doesn’t burn a shared free-tier quota, and so the limit is legible instead of silent.
Where the privacy boundary actually is
The site records that a question was asked. It does not record the question.
That was a choice rather than a legal requirement. The EU AI Act’s Article 50 governs disclosure, not logging; it’s GDPR that would govern the logging. But if the only thing stopping a promise from being broken is that I made it, the promise isn’t worth much. So the constraint lives somewhere I can’t quietly undo it on a Friday.
Firestore security rules as the enforcement boundary for what a client may write
hasOnly() is an allowlist, and the interesting thing about an allowlist is who it stops. Not other people — they can’t write anything useful anyway. It stops a version of me in six months who adds a field in a hurry and forgets what the /ai page promises.
The rest follows the same rule. Country comes from the browser’s time zone rather than an IP lookup or a cookie, which is coarse and entirely good enough for “which countries read this”. Retention is a TTL policy on a field in the document rather than a cleanup job, because there’s nothing to run a cleanup job on. Reads are restricted to one verified Google account, and everything is append-only, including for me:
match /events/{eventId} {
allow create: if validEvent();
allow read: if isOwner();
allow update, delete: if false; // append-only, even for the owner
}
There is no consent banner because there is nothing on your device to consent to. No cookies, no localStorage, no sessionStorage. Do Not Track and Global Privacy Control are both honoured, which almost nothing does, because honouring them costs me nothing I care about.
Two dates in the calendar
Two deadlines shaped this more than any technical constraint did.
2 August 2026. The EU AI Act’s Article 50 transparency obligations. If a system interacts with people, they have to be told they’re dealing with an AI, and told before the interaction rather than in a footnote afterwards. So the notice sits inside the ⌘K panel, above the input, visible before anything is typed. Not a link to a policy page. Not a toast after the first answer.
2 November 2026. App Check enforcement becomes mandatory for the Gemini Developer API. That one is Google’s deadline rather than Brussels’, and it’s the reason the reCAPTCHA Enterprise integration exists now instead of being a note in a backlog.
Worth separating two things people run together: the AI Act is about disclosure and risk classification, GDPR is about personal data. The decision not to log questions is a GDPR-shaped decision, and the AI Act did not require it. I made it anyway. Getting the reasoning right matters more than the outcome here, because next time the outcome might depend on it.
Twenty kilobytes of world
The career map on the home page is drawn from Natural Earth’s 1:110m country outlines. The obvious way to put a map on a page is a tile server or a mapping library, and both were out — tiles are somebody’s bandwidth and a library is a hundred kilobytes before it draws anything.
So the map is computed once, at authoring time, and baked into the HTML as path data.
the pipeline that turns Natural Earth country outlines into 20 kilobytes of inline SVG path data
Plain Douglas–Peucker has a trap when you feed it a closed ring: the first and last point are the same, every perpendicular distance measures against a zero-length line, and the entire country collapses to two points. The fix is to split each ring at the vertex farthest from its start and simplify the two halves as open lines. I found this the way everyone finds it, by rendering a map with two hundred and seven invisible countries on it.
The routes are great circles, sampled at 48 points and pushed through the same projection as the coastlines, so they bend the way the map says rather than the way a quadratic Bézier guesses. Nobody will notice. That’s usually the point.
From a markdown file to a live page
The last diagram is the boring one, and it’s the one that decides whether I keep writing at all.
publishing pipeline from a markdown file to the live site and to Medium
The direction of that last arrow is the whole point. Importing into Medium from my own URL makes Medium set its canonical link back to this domain, so search engines treat my copy as the original. Writing on Medium first and pasting here does the reverse, and quietly donates the traffic to somebody else’s platform.
ingest exists because the failure mode I actually needed to design around wasn’t technical. It was me, on a Tuesday, with a finished draft and no appetite for moving image files into the right directory. Astro only optimises images referenced by a relative path from the markdown file. Put one in public/ and link it absolutely and it ships as a full-size PNG, which on a 360 MB day is the difference between a post you can leave up and a post that takes the site down until midnight.
What the constraint was worth
Three things I would not have done with a credit card attached.
The assistant loads nothing until you ask for it, because 555 KB per visitor was unaffordable. That’s also just better engineering, and on a paid plan I would have shipped the lazy version to a backlog and forgotten it.
The privacy rules live in Firestore rather than in a policy page, because rules were the only enforcement mechanism available without a server. That is a stronger guarantee than the one I would have written into a backend I control, and it took less code.
Every image is WebP, because one unoptimised screenshot on a post that did well could take the site down until midnight Pacific. On a paid plan it would have been a rounding error on a bill nobody reads.
I’ve spent a lot of years telling customers that constraints produce better designs than budgets do. It is a slightly different experience being on the receiving end of one.