Live

Passarim

A private photo archive over S3 Glacier — signed-cookie edge auth, on-device face clustering, and about $0.90 a month at rest.

Roughly 34,000 photos and videos, already sitting in S3 Glacier Flexible Retrieval as a backup. The question was whether a backup could become something the family actually browses without turning a storage bill into a hosting bill.

It can, but only if the architecture takes the constraint seriously: the Glacier bucket cannot be served. Everything else follows from that.

Derivatives are the product

Nothing user-facing ever touches a Glacier object. A worker produces small hot derivatives — grid thumbnails and display-sized images — and those are what the gallery serves. Restoring an original is a deliberate, explicit operation, not something a page view can trigger by accident.

That single rule is most of the ~$0.90 a month. The expensive tier holds everything and is read almost never; the cheap tier holds a fraction of the bytes and answers every request.

Auth at the edge, not in the app

Sign-in issues short-lived CloudFront signed cookies. Every media path — the index manifests, the thumbnails, the video — requires them at the edge. A request without cookies is refused before it reaches an origin.

Two things it deliberately does not use:

  • No Cognito. The sign-in Lambda does the check itself. A managed user pool would have added a service, a migration path and a bill for a user base that fits in a config file.
  • No API Gateway. The API is a Lambda behind the same distribution, so there is one CDN, one certificate and one set of security headers rather than two front doors with two configurations to keep in step.

The origins are private. The bucket is readable only by the distribution’s origin access control, so there is no public URL to find.

Faces, without shipping anything anywhere

Face detection and clustering run in the account, over the hot display derivative and never the Glacier originals — detection, then embedding, then nearest-centroid clustering. No third-party vision API, no images leaving the account, and no per-image inference cost at browse time.

The pipeline is the shipping authority

Deployment runs on GitLab CI, and no AWS key is stored there — the deploy job assumes a role through OIDC, pinned to this project and to main, and scoped to the frontend. Jobs are gated on the paths they actually cover, because CI bills wall-clock against a monthly budget and a docs-only push used to run eighteen jobs that could not have failed.

The part worth keeping came out of an incident: a green pipeline that had shipped nothing. Every job passed, and production was still serving the old bundle. The lesson is that a job graph is not evidence — it says the steps ran, not that anything changed.

So the verify stage now asserts reality instead:

  • The live site is this commit. A credential-free request to the public URL checks that the deployed bundle matches the commit that built it.
  • The deployed function is the canonical artifact. It stays red until an infrastructure apply is actually approved, rather than going green because the job was skipped.

Skew between the frontend and the function is still possible. It is simply no longer quiet — it shows up as a failing verify job rather than as a green pipeline and a stale page. The pipeline’s own shape is asserted by scripts/assert-ci-graph.py, so changing the deploy contract means changing the contract first, on purpose.

Infrastructure apply stays manual. A human approves every change to it.

What it actually costs

About $0.90 a month at rest. The architecture is boring on purpose: storage-class discipline, derivatives, cookies checked at the edge, and one Lambda. Most of the interesting decisions were things not built.

All work