Skip to content
Write-up

This Website

A walk through the site you're on: how a page gets rendered, how accounts and email work, and the infrastructure and release process behind it. With the parts that went wrong.

Ionut-Alexandru Popescu · · 9 min read

This is the site of a one-person software studio, so it has two jobs. It has to say what I do and how to reach me, which any static page could manage. It's also the place where I get to build things the way I'd want them built if nobody were rushing me: the games and tools under Projects, accounts, a contact inbox, and everything that keeps them running.

This page is a tour of that second part: how a page reaches you, how accounts and email work, where the data lives, and how a change gets from my editor to production. It's written for people who build software, but I've tried to keep it readable: what each piece does, why it's there, and where I changed my mind. Nothing here is exotic. Most of it is deliberately boring technology, put together carefully.

The biggest single thing on the site, the Z80 emulator, has a write-up of its own: Inside the Z80 Emulator. Everything below is the site around it.

The shape of it

When you open a page, here's roughly what your request goes through:

browser ── HTTPS ──▶ Google's global load balancer
                     (static IP, managed certificate, Gateway API)
                              │
                              ▼
                GKE Autopilot ─ one pod, two containers:
                ├─ website: Rust (axum + Leptos), renders the page
                └─ Cloud SQL Auth Proxy: the database, on localhost
                              │
                ┌──────────────────┼──────────────────────┐
                ▼                  ▼                      ▼
                PostgreSQL 18      the C compiler          Google APIs
                (Cloud SQL,        (Cloud Run, scales      (Gmail, Secret Manager,
                private IP)        to zero, internal)      reCAPTCHA)

There are two services. The website is a single Rust binary that serves every page, the API the pages call, and the static files. The z80compiler turns C into Z80 machine code for the emulator; it's the only part that isn't Rust inside, and it's covered in the emulator's write-up. Everything else is managed Google Cloud: a load balancer in front, a small PostgreSQL behind, and a handful of APIs.

Rust all the way down

The site is written in Rust with Leptos, served by axum. Leptos runs here in islands mode, and that one choice shapes most of the front end.

In islands mode the server renders every page to plain HTML, and that's all most of the page ever is. Only the parts that need to react to you are islands: components compiled to WebAssembly that wake up in the browser and take over their little patch of the page. The mobile menu is one. Each form is one. The Z80 emulator is a big one. The rest of the text you're reading never ships any code at all.

The result is that pages work and read fine before any WebAssembly arrives, search engines get real HTML, and the shared bundle stays around 100 KB compressed. Forms talk to the server through Leptos server functions: ordinary async Rust functions that run on the server, which the browser calls over HTTP as if they were local. The same types travel on both sides, so a field renamed on one side is a compile error on the other, not a bug in production.

Styling is Tailwind CSS, mostly through a small set of component classes. The light and dark themes are just CSS variables, and they follow your system setting.

Fast on purpose

A few small decisions do most of the work of keeping the site quick:

Accounts, without the usual shortcuts

Accounts are where it's easiest to cut corners, so I tried to cut none. Some of the decisions:

Microsoft sign-in had one subtlety worth sharing. Microsoft doesn't always vouch for the email on a work account, because an organisation's admin can set any address. So the site only trusts it when Microsoft says the domain is verified. Otherwise it emails a code to that address before creating anything.

Email, through the front door

The site sends its emails (sign-up codes, password resets, "we got your message") through the Gmail API as aliases of a Google Workspace user, not over SMTP. There's no key file anywhere: the site's own Google identity signs a short-lived token that's only allowed to send mail, and trades it for access. SPF, DKIM and DMARC are set in DNS, so the mail actually arrives.

The contact form and the contact inbox are one system. Messages from the form, the emails people send to the contact address, and my replies from Gmail are all pulled into conversations on the site's admin page every minute, with read-only access to that one mailbox. The site only fetches and stores what was sent to the contact address.

The database, and who may touch it

Data lives in PostgreSQL 18 on Cloud SQL: the smallest tier, with daily backups and point-in-time recovery. The site queries it with Diesel. Nobody signs in with a password, not even me; every sign-in is a Google identity, and each role can do one job:

The tests that touch the database run against a real PostgreSQL, a fresh container per test, set up with the same roles as production. One of them checks the privilege model itself: the site's user can read and write rows, and every attempt to change the schema is refused.

Infrastructure as code, with a few locks

Everything in Google Cloud is described in Terraform, split into three parts with separate state:

The cluster is GKE Autopilot: Google runs the machines, and I pay for what the pods ask for. The nodes are private: no public IPs, and they reach Google's APIs over Google's own network. The control plane is only reachable through an endpoint that checks your Google identity on every request. DNS is signed with DNSSEC.

CI has no stored keys either. GitHub Actions proves which repository and branch it's running from, and Google trusts that proof only for the master branch, so a pull request can never touch production.

Shipping a change

Every change lands on a dev branch through a pull request, checked by CI: formatting, Clippy with warnings as errors, and the test suites, for just the parts of the repository it touches. A docs-only change runs nothing.

From a pull request to production Three stages. Every pull request into dev runs only the CI checks for the parts it touches: ci-web, ci-z80compiler, ci-crates and ci-infra. Promote to master re-checks dev, works out each service's next version, pushes the bump, tags and fast-forward in one push, and publishes GitHub Releases. Deploy web picks the release tag, then applies terraform/web and builds the image in parallel; migrations run after Terraform; the rollout waits for both, and rolls back if the new pod never gets ready. CI authenticates without keys, through GitHub's OIDC token and Workload Identity Federation, trusted from master only. 1 · EVERY PULL REQUEST INTO DEV CI runs only the checks for what the change touches: ci-web ci-z80compiler ci-crates ci-infra fmt · clippy -D warnings · tests · terraform validate · kubeconform Promote to master: one click 2 · PROMOTE TO MASTER re-check dev as a whole next versions per service one push bump · tag · ff GitHub Releases Deploy web: one click, from the release tag 3 · DEPLOY WEB pick the release web-vX.Y.Z in parallel terraform/web plan → apply that plan stops on any delete or replace image cargo-chef → distroless reused if built from this commit migrations as the migrator rollout ready for 60 s, or roll back No keys anywhere: GitHub's OIDC token → Workload Identity, trusted from master only
The whole path, drawn from the workflow files. Promoting and deploying are each one click; everything inside the boxes is automatic.

Releasing is a button. Promote to master re-checks dev as a whole, works out a new version for each service that changed, tags it, and fast-forwards master, all in one push that either fully happens or doesn't. Deploying is a second button. For the site, it:

  1. applies the site's Terraform, while building the image: a small distroless container with just the binary and the static files, running as a non-root user;
  2. runs pending database migrations, as the migrator;
  3. rolls the new image out next to the old one, which keeps serving until the new pod has been ready for a full minute. If it never gets there, the deploy fails and rolls back on its own.

A deploy always ships a tagged release, never whatever happens to be on a branch, so I always know exactly what's running.

Things that went wrong

It would be dishonest to pretend all of this worked the first time. A few recent ones:

What I'd change next

The honest list. The site runs as a single pod, which is fine for its traffic but means a node upgrade is a short blip, so two replicas are the next step. I'd like proper tracing across the site and the compiler, rather than reading logs side by side. And the blog is still "coming soon", which, I realise, is a strange thing to admit at the bottom of a blog post.

If you're building something and any of this sounds like the way you'd want it built, get in touch.

All projects