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.