writing — june 2026 · 6 min

A visitor counter, taken too seriously

At the bottom of this site there are two numbers: how many people have visited, and how many times the Prius easter egg has been driven. The numbers don't really matter. What I want to write about is everything I put behind them, and the one thing I left out.

01The toy

It's just a counter, and I should be upfront that the numbers barely mean anything. The visit count is fuzzy on purpose. It counts once per browser session, with no cookies and no analytics, so really it's an integer that goes up. If I only wanted the number, I could have done it with one line of serverless storage in an afternoon.

So the number wasn't the goal. I wanted to take the smallest backend I could think of and build a proper, production-style setup around it. A counter is small enough that none of the cloud parts get tangled up in the app itself, which is exactly why it's a good thing to practice on. It's a stripped-down version of the Cloud Resume Challenge: skip the resume, keep the counter, and take it much further than a counter needs.

02The shape of the system

A request goes through four steps.

  • browserthe page on ammarhassan.dev
  • api gatewayhttp api, cors locked to the site
  • lambdapython, reads or increments atomically
  • dynamodbtwo rows: visits, prius
fig. 01 — one request, four steps. the increment is atomic

The browser calls an API Gateway endpoint. That runs a small Python function on Lambda. The function reads or updates a number in DynamoDB and sends it back. There are two counters, stored as two rows in one table: one called visits, one called prius. The prius one is wired into the existing easter egg, so driving it bumps the count.

A few details I cared about. The increment is a single atomic DynamoDB ADDthat happens inside the database, not a read-then-write in the function. CORS only allows this site. The function's permissions are as narrow as I could make them: it can read and update one table and write its own logs, and that's it. It runs in the Mumbai region because that's closest to me in Lahore, and the billing is pay-per-request, which at this traffic is basically free.

03The choices

I didn't host the site on AWS. The original Cloud Resume Challenge puts the whole resume on S3, CloudFront, and Route 53. I skipped all of that on purpose. This site isn't a static page, it's a Next.js app, and Vercel already runs it well. I didn't want to rebuild a CDN and caching by hand just to say I used more AWS services. So I only moved the part that actually needed a backend, the counter, and left everything else where it was. Picking the smaller scope was the first real decision, and I still think it was the right call.

There are no AWS keys saved in GitHub. The simple way to let GitHub deploy to AWS is to store an access key as a secret in the repo. I didn't want a long-lived key sitting there that would keep working if it ever leaked. So instead GitHub and AWS check each other on every run. GitHub hands over a short-lived token that proves the request is coming from this exact repo, AWS matches it against a rule that only trusts that repo, and gives back credentials that expire after an hour. Nothing secret is saved anywhere. This is called OIDC. It was more work to set up, and it's the part I'm happiest I didn't cut.

  • git pushto main
  • github actionsruns the tests first
  • oidc handshakeshort-lived token ⇄ temporary aws credentials
  • terraform applyfrom shared state in s3
  • awsinfrastructure updated
fig. 02 — every deploy logs in with no stored keys

The pipeline is the real project. All of the infrastructure is written in Terraform: the table, the function, the API, the permissions, even the trust setup that lets GitHub log in. I can tear the whole thing down with one command and bring it back exactly the same. The Terraform state lives in S3 so my machine and GitHub use the same copy. Pushing to main runs the tests and then applies the changes. Opening a pull request runs the tests and shows a plan of what would change, without changing anything. The counter has been done for weeks. The pipeline around it is the part I'd actually show someone.

The writes are atomic, because that's a real bug. If two people hit the bottom at the same moment, a naive read-add-write would have both of them read the same number and both write back the same value, and you'd lose a visit. The DynamoDB ADDdoes the whole increment in one step inside the database, so that can't happen. It's more than a counter needs. But a counter is a cheap place to build the habit, before it matters somewhere it does.

04Limits

It only counts, and the limits are on purpose. Visits are per browser session, not per person, so clearing your storage or opening another browser counts you again. I'm fine with that. Counting real people means cookies and consent banners, and this number isn't worth that. There's no login, because the only thing you can do is add one to two fixed keys, and the function refuses anything else. You could probably inflate the number if you really wanted to. At this scale I don't mind.

05Closing

This is sort of the opposite of the last thing I wrote. In the bookkeeping piece I argued against over-building, because the data was small enough that a vector database would have been pointless. Here I wrapped a counter in remote state, federated login, and infrastructure-as-code, which is far more than the number is worth.

To me it's the same idea, though: use about as much machinery as the job actually calls for. The bookkeeping system has to be trusted with money, so the effort went into safety checks. This counter just had to show I can build and run a small cloud service properly from start to finish, so the effort went into the setup, and the counter is there to give it something to do. Most of the skill is telling those two apart.