8Examples / work
Case study · A photographer’s albums

A link, not an account.

A private photo-sharing site for an artist’s ongoing trips: albums for friends and family, opened with a shareable link instead of a login, event-sourced end to end, and running for about nothing a month.

The Albums

The audience is family. The gatekeeper was Facebook.

The client is an avid photographer and artist whose trips don’t stop; every year adds albums, and every album has an audience: friends and family scattered across time zones, some of whom live on Facebook and some of whom will never make an account anywhere, on principle. Sharing on a social network gets the order of obligations backwards. It asks the audience to sign up for something so the artist can show them something.

The alternatives all had the same two flaws: a public gallery is open to the whole internet, which these photos are not for, and the hosted photo services charge rent forever for what is, at heart, a personal archive. The brief was almost a riddle: private but with no logins, on the internet but not public, and costing as close to nothing as the internet allows.

The First Move

We wrote down who holds what. The admin holds a password; everyone else holds a link.

The 8Examples habit: express the thing as behaviors over time before building it. Walking through the scenarios, the access model reduced to one sentence: a guest is a link, not a person. There is exactly one password in the whole system, the admin’s. Everyone else holds an opaque key baked into a URL, minted by the admin, labeled with who it was given to, revocable one at a time. Nobody’s grandmother resets a password, because nobody’s grandmother has one.

Here is the timeline of one trip, from empty album to revoked link:

SCREENSCOMMANDSEVENTS(the ledger)READ MODELStime01 · new albumyear, name, locationCreateAlbumalbum_created02 · uploadforty at a time,straight from the tripUploadPhotophoto_uploadedwidth, height, size03 · tell the storytext on the album,text on each photoUpdateTextalbum_text_updatedphoto_text_updated04 · mint a linklabeled: “for mom”CreateAccessKeyaccess_key_createdexpiry optionalAccessKeysaunt taps the linkno login, no accountAlbumsa fold over the ledgerkey checked first05 · revokeone guest,not all of themRevokeAccessKeyaccess_key_revoked
screencommandeventread model
Read it left to right; that axis is time. Gold notes are events: facts, past tense, never edited. The guest's column has no command in it; visiting appends nothing to the ledger. The guest only reads a fold of what the admin's actions already made true, and only after the AccessKeys read model vouches for their link.

The revocation slice is why keys are labeled. When a link marked “for mom” escapes into a forwarded email, the admin revokes that one key and mints a fresh one; the hiking club’s link never notices. Access control, per relationship, with no user table anywhere.

The Shape

One SQLite file, twenty-four kinds of facts.

The backend is the same discipline we’ve used at every scale: CQRS with an event-sourced ledger. One append-only table, events, holding a sequence number, a type, a version, and a JSON payload. Twenty-four event types cover the whole domain: albums created and renamed and re-yeared, photos uploaded and moved and rotated, videos, groups, access keys minted and revoked. Commands validate and append; they never update a row, because there are no rows to update.

Queries replay the ledger into an in-memory read model on demand, every album, every photo, every key, folded fresh from history. At family scale that costs nothing measurable, and it means the answer on screen is never stale, never migrated, never out of sync with the facts. There is no cache to invalidate because there is no cache.

24
event types
0
guest accounts, ever
1
append-only table
1 wk
guest session cookie
≈$0
a month to run
The Front Door

The link becomes a cookie, then vanishes from the address bar.

The guest experience had to survive its actual audience: links forwarded through email chains, opened on old iPads, pasted into group chats. So the key does its job once, at the threshold. The middleware sees ?key= on the way in, seals the key inside an encrypted, httpOnly cookie good for a week, and redirects to the same page with a clean URL. What’s left in the address bar, in the browser history, in the screenshot someone sends around, is just /albums.

aunt’s phonethe app(next.js middleware + routes)events.db(append-only)GET /albums?key=k4f9…the link from the family group chatseal the key in a cookie · encrypted, httpOnly, one week302 → /albumsthe key is stripped from the address barGET /albumsthe cookie rides alongSELECT * FROM events ORDER BY sequence_numberevery fact so far, oldest firstfold → albums, groups, access keysguard: key real? unrevoked? unexpired?the albums, all of thembrowse freely while the cookie livesa revoked or expired key fails the guard and lands on access-denied · the cookie confers nothing by itself
The key is validated against the ledger on every request, not just the first; the cookie only remembers which key you carried. Revoke the key and the next click fails, no matter how fresh the cookie is.

Once inside, the guest browses everything: years, groups of albums, the albums themselves, full-screen photos resized by sharp to display resolution so a phone on hotel wifi isn’t downloading forty originals. One link, then the whole world; that was the requirement, stated by the client in the very first conversation, and it’s the sentence the middleware implements.

Nothing Is Rented

The whole thing runs on one small box, for about nothing.

The first instinct, written into the original brief, was cloud object storage; that’s where photos go, everyone knows that. Then we did the arithmetic instead of following the instinct. A family audience, display-resolution images, a few trips a year: the entire archive fits comfortably on the disk of the smallest VPS money rents, with SQLite sitting next to the photos it describes. No object store, no egress meter, no managed database waiting to graduate into a line item.

one small vps · docker composethe entire production footprintcaddyhttps by itself,certificates renew alonenext.js appcommands · queries · sharpapp-data volumeevents.db + the photoswatchtowerpolls the registryevery five minutessees a new image, swaps thecontainer, nobody is awake for itfriends & familya link in the group chat443ghcr.iopush to main builds herethe monthly bill is the box and the domain · there is nothing else to pay for
The entire production footprint. Caddy terminates HTTPS and renews its own certificates; the app and its data volume are the system; Watchtower watches the registry and swaps in new builds on its own. Pushing to main is the deploy pipeline.

The operational bar was set by the client being one person with better things to do. Certificates renew themselves. Deploys happen because a push to main built an image and Watchtower noticed within five minutes. The monthly bill is the box and the domain name, which is what close to zero means when you stop renting software and start owning a very small amount of it.

The Payoff

Rotating a photo is a fact. So is changing your mind.

Event sourcing sounds like ceremony for a family photo site until you watch the site get used. A photo comes in sideways: photo_rotated is appended and the original file is never touched. An album turns out to belong to last year: album_year_changed. The trip gets renamed, the photos get reordered, one shot moves to a different album, and every one of those is a fact in the ledger rather than a destructive edit. Nothing the admin does while curating can lose anything.

And when something looks odd, debugging is reading: the ledger says what happened, in order, with timestamps. The next feature, browsing by location is the likely one, is a new fold over facts that were being recorded all along. The events don’t change; the questions asked of them do.

The Point

Software for people you love deserves the same rigor.

This is the smallest system in these case studies and it was designed with the same discipline as the largest: behaviors first, a timeline everyone could point at, then code that types the model in. The riddle from the brief, private but loginless, online but not public, running but not costing, wasn’t solved by any single clever trick. It dissolved the moment the thing was expressed concretely: the admin holds a password, everyone else holds a link, and the trips keep coming.