The Architecture You're Visiting: How This Site Runs on GCP

The page you are reading this on is the system I am about to describe.

The same codebase runs three separate live websites and a beta environment, each of them in two languages, Turkish and English. Below is how it is put together.

Why I built this system, and what it has cost at the end of eight years, I wrote up separately: GCP Cost: The 8-Year Bill for One Decision

One repository, four applications

A monorepo built on Angular 20: the public site, the admin panel, the cloud functions and the shared library the three of them use. The public site's pages are rendered on the server, so on its first request the browser receives the finished page; that is also what search engines and link previews see.

The admin panel deliberately is not: its addresses are rewritten to a static index.html and the panel opens as a plain application running in the browser. Since there is nothing a search engine needs to see, rendering its pages on the server would return nothing.

Its size today: roughly 37 thousand lines of application code — 14 thousand of which are tests — plus 10 thousand lines of agent tooling. 72 routes, 54 components, two languages, four environments.

The journey of a request

Rather than counting the parts one by one, following a single request says more. When you open the address you are on right now, this is what happens, in order:

  1. Your browser HTTPS GET
  2. DNS Cloud DNS
  3. Firebase Hosting CDN edge a static file ends here
  4. The rewrite rule rewrites
  5. The SSR function europe-west1
  6. Your browser, again hydration
  7. Subsequent data requests Firestore · App Check
  1. Your browser — you open the address and the request sets off.
  2. DNS — Cloud DNS resolves the domain; the browser learns where to connect.
  3. Firebase Hosting — the request lands here. If it is a static file (an image, a script, robots.txt) it is answered straight from the CDN and the job ends there.
  4. The rewrite rule — addresses such as articles, pages, tags and sitemap.xml catch on a list of rules and are routed to the SSR function.
  5. The SSR function — wakes up in europe-west1, reads the document from Firestore, runs Angular on the server and returns finished HTML.
  6. Your browser, again — receives the page ready-made and displays it; then Angular takes over and brings the application to life.
  7. Subsequent data requests — now go straight to Firestore, each one carrying an App Check token.

What the search engine and the link previews see is the output of step 5; what the visitor sees once they start browsing is step 7.

Content is in the database, not in the build

All the content sits in Firestore: articles, pages, the menu, the footer, the cookie notice, contact details. Correcting an article does not call for a deployment; publishing and writing are separate from each other. This article is itself a Firestore document.

On the Cloud Functions v2 side, running on Node.js 24, are the server-side rendering, the sitemaps, the structured data and a job queue triggered by writing a document. The functions run in the europe-west1 region.

The functions have two limits and both are deliberate: a request can run for at most 60 seconds, and the SSR side scales to at most two instances. The second one sets both the ceiling on spending and how far a fault can spread: a hanging request shares that concurrency with every page on the site. The same ceiling applies to legitimate traffic too: in a sudden spike, requests queue. At this scale it is a trade-off I accept.

On the same side sits a scheduled job as well: every Sunday night at 02:00 the whole of Firestore is exported automatically and written to a backup Storage bucket. Taking a backup by hand is possible too; it uses the same path.

The sites differ in configuration, not inside the code

What separates the sites is resolved neither by a theme check nor by a condition written inside a component. Every site has its own build configuration and its own stylesheet; if a feature is to be switched on for some sites only, it gets a named flag.

The benefit: someone reading a component does not have to guess what happens on which site. Adding a new site does not touch the shared code either — one environment file, one stylesheet, one deployment pipeline.

The result: eight application builds come out of a single repository — four environments times two Angular applications — and each of them produces two languages on top of that. They all use the same shared library and none of them carries a code path meant for another.

In the browser: an installable application

In all four environments the service worker is on and the site can be installed as a web application. The app shell is downloaded up front and cached, and assets enter the cache on first use. In practice: on repeat visits the page opens instantly, and the application stays standing on a weak network.

It has a limit too: the service worker does not cache the content. Article texts come from Firestore, and the service worker's caching rules do not cover data requests.

The Firestore SDK's own local cache is in play, though: for as long as the tab is open it holds the data it has downloaded, stays in sync with the server through listeners, and if the connection drops briefly, reads are answered from there. But that cache is in memory only by default; writing it to the browser database is a separate option that has to be switched on, and I have not switched it on. So refreshing the page resets the cache as well.

The upshot: this is not an offline archive. The shell opens offline, the content needs a connection. Enabling a persistent cache is possible, but there is no return on downloading a rarely-read archive onto every visitor's device.

The admin panel has a service worker of its own too — a separate application, so a separate set of caches. The public site's service worker deliberately leaves the panel's addresses out of scope; the two of them caching each other's pages is not something anyone wants.

Security: no server to patch

Security was one of my reasons for choosing this architecture in 2018. In practice it comes down to this:

  • Server upkeep is not mine. There is no operating system I patch, no web server I configure and no language version I update.
  • Content is data, and there are no plugins. I host no executable third-party code. HTML arriving from the content is filtered before it is placed on the page: inline styles, scripts, event handlers and inline SVG are dropped. So content is data; not an executable template.
  • The admin panel is a separate application and, the login page aside, every one of its routes sits behind authentication.
  • Database and file access are defined by security rules, not left to the application's good intentions.
  • App Check controls not only the forms but the read requests too.

What App Check actually does

The content collections' rules are open for reading — as they should be on a public site. But App Check is enforced on the Cloud Firestore product itself in all four projects.

These are two different layers. The rules file answers the question “who may read this document”; App Check answers “is this request really coming from my application”. The result: the content is public, but it cannot be read from an arbitrary client. A script without a valid attestation token never reaches the database at all.

Its limit needs stating too: App Check is not authentication, it is a declaration of origin. It stops a casual script at the door, not a determined attacker — a token can be extracted, and there is a debug token for development anyway. The real protection is on the write side: writing to the content collections depends on authentication and administrator rights. Reads being public is deliberate as well — this is a website.

There is a cost to it too: when a token goes stale, a running listener can suddenly fall silent. That is why there is a recovery mechanism on the client side, which refreshes the token and re-establishes a listener that has taken a permission error.

Deployment: eight workflows and one way back

There is no path other than the order developbetamain, and what enforces it is not a written rule. Every deployment workflow looks at which branch the merge that triggered it came from: beta goes live only from a merge coming out of develop, and the three live sites only from one coming out of beta. A merge opened from the wrong branch deploys nothing.

The real gate, though, sits earlier: pre-push. Before the code leaves for the remote, lint and all of the test suites run locally; if they do not pass, there is no push. No check on a server can act this early.

And here is the odd part: the same suite runs twice. Once at pre-push, locally, as a gate — if it does not pass, the code never leaves for the remote at all. And once in CI on every push that lands on develop, as a record. Both are the same lint and the same integrated test suite.

The reason: this repository has no branch protection, so a result in CI cannot technically block a merge. That is why I put the real gate locally; CI shows that the tests genuinely ran and that the result is not peculiar to my machine.

The steps of a production deployment are these:

  1. The PWA and the cloud functions are built
  2. They are deployed to Firebase
  3. Database migrations are triggered and waited on
  4. The Playwright suite runs against the live site
  5. If a migration or a test fails, rollback steps in — the previous release's bundle is already stored

The order is deliberate: deploy first, migrate second. So the new code stands live alongside the old data for a short while, until the migration finishes. That window is on the order of seconds, but it is not zero, and migrations have to be written accordingly.

The migrations themselves are versioned: they are numbered, they run in order, and the list of the applied ones is kept in a system document in the database, so every migration runs exactly once.

The condition on the rollback step is broader than it looks in that list: as long as the deployment itself succeeded, any subsequent step failing triggers the rollback — migrations included. A release that fails at the build stage, by contrast, has nothing to roll back, because no new version was published in the first place.

The remaining workflows deploy nothing: one inspects the structure on every pull request, one performs a code review, and one prepares in advance the dependency cache the other runs will use. None of the three blocks anything — the only things that block are pre-push and the branch check above. I have left it that way deliberately: a check that produces a warning and a check that stops a release are not the same thing, and which is which should be obvious.

Four test suites, four separate jobs

  • Public behaviour — everything a visitor who is not signed in sees.
  • The full suite behind Hosting — this one covers signing in to the admin panel and creating, updating and deleting content.
  • The live smoke test — this is the suite that decides a deployment's fate. Alongside the pages, it also verifies that sitemap.xml, llms.txt and robots.txt really are being served; that check was added after a fault, and the story is in the other article.
  • The SEO and WCAG 2.1 AA audit — deliberately does not stop a deployment, because its findings depend on content and are advisory.

The separation is intentional: an accessibility warning should not block a release, while a broken home page absolutely should.

The layer that came later: agent tooling

This part did not exist in 2024; it was added in 2026. I wrote six command-line tools working over Firestore, Storage and Auth for content, configuration, media, seed data, backup and auditing. AI coding agents use them, and they all sit behind a single guard layer — because a rules file is a guide, not a constraint.

Two rules keep this layer standing: every command has to state its target environment explicitly, with no default; and a write that would silently delete a field is refused. The second one was put in after an accident — a save had dropped an article's order number, and the article carried on working at its own URL while it quietly disappeared from the list pages.

In closing

You can test most of the claims on this page: view the source, and the whole page is inside the HTML that came from the server; open sitemap.xml, and the same function generates that too.

I cannot show you the code itself; the system has been closed source since 2024. But everything observable from the outside is right here in front of you.

The real cost line items, the test the architecture faced two years on and what one fault taught me are in a separate article: GCP Cost: The 8-Year Bill for One Decision

If you have a question about the architecture, you can write to me from the contact page.