Site operations
Running a personal site on Firebase Hosting and Cloud Build
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.
curl -sI https://aymanzahran.com/ads.txtshould saytext/plain, nottext/html.curl -s https://aymanzahran.com/ads.txtshould be the single google.com DIRECT line for this publisher.curl -sI https://aymanzahran.com/privacy-policy.htmlshould be 200, and the body should contain the policy, not the empty app shell.curl -s https://aymanzahran.com/robots.txtshould point the sitemap athttps://aymanzahran.com/sitemap.xml.curl -s https://aymanzahran.com/sitemap.xmlshould listhttps://aymanzahran.com/and the essay URLs.curl -s https://aymanzahran.com/ | grep adsbygoogleshould find the AdSense script on the homepage.
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.