← all posts

How I built this site — private S3, a serverless counter, and one bug that kept deleting my domain

This site is a résumé with a job: stay permanently live at a real domain, cost pennies, and be rebuildable from nothing but code. No servers, no console clicking, no long-lived credentials anywhere. This is how it's put together, and why I made the calls I did — including the mistake that took the domain offline on every deploy until I worked out what was happening.

The delivery path: a private bucket, not a public one

The obvious way to host a static site on AWS is an S3 bucket with static website hosting turned on. I didn't do that. The bucket here is fully private — public access blocked at every level — and the only thing allowed to read it is CloudFront, through Origin Access Control (OAC). The bucket policy grants read access to the CloudFront distribution and nothing else.

Why the extra step? A public website bucket serves over plain HTTP and exposes the origin directly. Putting CloudFront in front with a private origin gives me HTTPS with an ACM certificate, a CDN, and an origin that simply can't be hit directly. It's the pattern I'd use on real work, so it's the pattern I used here.

The counter: one atomic write, no read-before-write

The visitor number in the status bar is the one dynamic thing on the page. On load, the browser sends a single POST /count to an HTTP API on API Gateway, which invokes a Python Lambda. The Lambda runs one DynamoDB UpdateItem with ADD count 1 and returns the new value.

The detail that matters is that it's a single atomic operation. The tempting version is to read the current count, add one, and write it back — but that's a race. Two visitors landing at the same time both read N and both write N+1, and one visit silently vanishes. Pushing the increment server-side into one atomic ADD means the database does the maths, and no visit is ever lost no matter how many people arrive at once.

The Lambda role is least-privilege on purpose: it can write its own logs and run UpdateItem on exactly one table. Nothing else. If it were ever compromised, that's the whole blast radius.

All of it is Terraform — including the state backend

Every piece above is defined in Terraform with remote state in S3. That creates a chicken-and-egg problem people hit constantly: Terraform wants to store its state in an S3 bucket, but the bucket has to exist before the first init. I didn't want to create that by hand in the console, because then the foundation of an all-code project would be the one thing that wasn't code.

So there's a small separate bootstrap/ stack whose only job is to create the versioned, encrypted state bucket and the lock table. You run it once; after that the main stack uses that backend. It's a one-time step, but it keeps the promise that the whole thing is reproducible from source.

Deploys: OIDC, so there are no stored keys

Every push to main runs the tests, applies the infrastructure, syncs the site to S3, and invalidates the CloudFront cache. The part I care about most: GitHub Actions authenticates to AWS with OIDC role assumption, not stored access keys. There is no long-lived AWS secret sitting in the repo settings waiting to leak — the pipeline exchanges a short-lived GitHub token for temporary AWS credentials at run time.

What broke: the deploy that kept deleting my domain

Here's the one I didn't see coming. After wiring up the custom domain — Route 53, the ACM cert, the CloudFront alias — everything worked. Then on the next push, the site dropped off the custom domain. I'd re-apply, it'd come back, I'd push again, and it would vanish again.

The cause: the custom-domain values lived in a terraform.tfvars file, and .tfvars was in my .gitignore. Locally, Terraform read my values and built the domain. But in CI that file didn't exist, so Terraform fell back to the defaults in variables.tf — which were empty — and dutifully tore the domain configuration back down every single run. The pipeline was doing exactly what I'd told it to; I just hadn't told it the right thing.

The fix was to stop treating the domain as a secret local override and make the real custom-domain values the defaults in variables.tf, committed to the repo. CI and local now agree, and the pipeline stays green. The lesson I took from it: when a deploy is non-deterministic between local and CI, look first at what CI can't see — the gitignored file is invisible to the runner, and your defaults are what actually ship.

Where it stands

The result is a site that's genuinely live, fully serverless, entirely in code, tested, and deployed with no stored credentials — the kind of thing I'd want to point at and say "that's how I work," because it is. Next on the list is cleaner URLs via a small CloudFront function, and turning a couple more of my projects into write-ups like this one.

The code is on GitHub if you want to see any of the above in full.