Ayman Zahran

Site operations

Running a personal site on Firebase Hosting and Cloud Build

Ayman Zahran · 28 September 2026 · about 9 minutes

This website is deliberately small. It is a Vite and React portfolio, one main component, no database, no login, and no server I have to patch on a Sunday. Firebase Hosting serves the built files. Cloud Build deploys them when the monorepo changes. That is the whole production system for aymanzahran.com. The interesting part is not the framework. It is the set of mistakes a static host makes easy, and the ones I decided not to paper over.

The site lives in a pnpm monorepo next to another app, outbox, in a single Firebase project. They share a project ID and they do not share a product. Hosting targets, collection prefixes, and path-filtered build triggers are what keep a personal page from becoming a deploy of someone else’s scheduler. If you only remember one thing about running two products in one Google project, remember that isolation is a naming and a trigger problem, not a diagram.

What actually gets deployed

pnpm build:aymanzahran typechecks, then Vite writes apps/aymanzahran/dist. Firebase Hosting’s public directory for both the dev target and the prod target is that dist folder. The predeploy hook runs the build again, so a manual firebase deploy does not upload a stale folder you forgot to rebuild. I still prefer the branch path over a laptop deploy. The laptop is how you get a one-off that nobody can reproduce.

Vite copies public/ into the root of dist without processing it. That is why robots.txt, sitemap.xml, ads.txt, the CV PDF, and these essays are ordinary files. The React app does not route to them. They are documents. A crawler that does not execute JavaScript can read them, which is the point of putting the long writing here instead of only inside the portfolio bundle.

The rewrite is a fallback, not a router

Both Hosting targets rewrite ** to /index.html. That is the usual single-page-app setting, and it is correct for a hash-anchor portfolio that has no client router. It is also a trap. Firebase serves a real file when the file exists, and only then applies the rewrite. Any path you forgot to create — a privacy page, a terms page, ads.txt — comes back as HTTP 200 with the homepage shell. The status code looks healthy. The body is the wrong document. I have been on the receiving end of that class of bug: reviewers and ad systems fetch a URL, get HTML, and conclude the file is missing or dishonest.

The fix is not to delete the rewrite. The portfolio still needs a fallback. The fix is to put real files in public/ for every URL that must not be the app shell, and to add explicit redirects for extensionless aliases that are not real files (/privacy, /terms). A directory that already has index.html, such as /blog/, should be left alone. An explicit redirect from /blog to /blog/ also matches the slashed path and returns a 301 to itself. Redirects run before static files. If you are debugging a “soft 404” on Firebase Hosting, compare checksums. If /ads.txt and / return the same etag, you do not have an ads.txt file. You have a rewrite.

One YAML, two sites, and a canonical host

Dev and production use the same Cloud Build config, cloudbuild/aymanzahran.yaml. The substitution _HOSTING_TARGET selects hosting:aymanzahran-dev or hosting:aymanzahran. Triggers are path-filtered, so a change in the other app does not redeploy this site. That is the right amount of machinery for a personal page. A second YAML would only drift.

The custom domain is aymanzahran.com. The default Hosting hostname aymanzahran.web.app still serves the same build. Those are different origins to a crawler, to Search Console, and to an advertising review. The sitemap, robots.txt, and the canonical link on the homepage therefore name the .com host and nowhere else. I do not want the default Firebase hostname to become the URL an index treats as the site. If you attach a custom domain, update the files that advertise URLs in the same change. Leaving web.app in the sitemap is how you quietly publish two sites.

Build-time configuration, and what I will not invent

Analytics and Search Console verification are injected by a Vite plugin when, and only when, the build is given a real value. VITE_GA_ID has to look like a GA4 measurement ID. VITE_GSC_VERIFICATION has to be the token Search Console issued. Cloud Build passes them through as substitutions _VITE_GA_ID and _VITE_GSC_VERIFICATION. Both default to empty. An empty value means the HTML stays clean. I will not commit a placeholder measurement ID to make a tag “look present”. A fake ID is a broken page that pretends to be measured.

AdSense is different only because the publisher ID is already public. It appears in ads.txt and it is the ID the ad system expects on this property: ca-pub-7062962300161480. The same plugin pattern injects the standard site script into the homepage <head> when VITE_ADSENSE_CLIENT is set. Production builds fall back to that known publisher if the variable is absent, so a local production build and the Cloud Build path agree. The dev server does not inject the script unless you set the variable yourself. I do not load the tag on an empty screen. The homepage is a real page, and these essays are real HTML.

Secrets do not belong in this bundle. There is no API key in the portfolio. Anything that must stay secret lives in Secret Manager for the other app, not in a VITE_ variable. A VITE_ variable is a public string. Treating it as a secret is how keys end up in a built JavaScript file and then in a person’s browser cache.

What I check after a deploy

I do not trust the Firebase console’s “release successful” line as proof the documents are right. I fetch them.

If any of those fail, the site is not ready to ask an advertising or search review for another look. Asking earlier wastes the review and teaches the next deploy nothing. The same restraint applies to analytics: turn it on when the measurement ID is real, mention it in the privacy policy, and do not invent the ID to satisfy a checklist.

The rest of the writing on this site is about the work I do for other people: Everything as Code, platforms across many clusters, and agents on freelance marketplaces. This page is the operator’s note for the site you are reading. If you want the same kind of boring, reviewable delivery for a system that is larger than a portfolio, email ah.zahran@outlook.com.