How this site was built
The colophon says what this site is made of in a few lines. This is the longer version: what Joseph asked for, how each part works, what broke along the way, and how a person and an AI built it together on a server in the corner of a room. The black hole at the top of the home page has its own write-up; this one covers everything around it.
1. The brief
Joseph wanted a personal site in the spirit of the IndieWeb: his writing and photographs on his own domain, on his own hardware, with no platform in between. Two things made it more than a blog.
First, it had to be for two audiences at once. The open internet should see a quiet public site. Family and friends who sign in should see more — more posts, more photographs, the apps on the server that are meant for them — without a second site to maintain.
Second, it had to be easy to feed. Writing should take a text box, not a terminal. Photographs should go from his camera to the site without exporting, resizing or uploading anything by hand.
Everything below follows from those two requirements.
2. Deliberately boring
The site is a small Node + Express app with EJS templates. Posts and pages are markdown files with a few lines of frontmatter at the top; settings and the photo index are JSON files. There is no database, no build step and no front-end framework. A post is a file you could open in any text editor, and the whole site is a folder that git can version.
That choice pays for itself in a few ways:
- Nothing to migrate. If this app disappears tomorrow, the content is still a folder of markdown.
- Edits are files. The admin editor writes the same markdown a person would. A version is kept in git and in the server's hourly disk snapshots.
- Reading is cheap. Posts are parsed once and cached until the file changes. The server does very little work per visit.
Type is self-hosted: Fraunces for headings, JetBrains Mono for dates and camera settings, and your own system's serif for reading. The page makes no requests to anyone else's servers: no font services, no analytics, no trackers.
3. Who sees what
Every post and photograph carries an audience: public, subscribers, friends, family, or private (Joseph alone). A visitor who isn't signed in is public; signing in places you in a circle, and you see your circle and everything wider than it.
The signing in doesn't happen in this app. A self-hosted single sign-on service, Authelia, sits in front of everything on the server. It handles passwords, passkeys and second factors, and once you're signed in it tells the site who you are. The site never sees a password.
Two design rules carry most of the weight:
- The site only believes the gate. Identity arrives with each request from the reverse proxy in front of the app, and the app accepts it only when the request proves it came through that proxy. A request that reaches the app any other way is treated as an anonymous visitor, whatever it claims.
- Mistakes hide things; they never publish them. An audience is typed by hand in a post's frontmatter, so typos happen. The first version treated anything it didn't recognise as public, so
audience: Familywith a capital F would have published a family post to the world. Now an unknown audience makes a post private and logs a warning. The same rule runs through the photo sync, the feeds and the sitemap: when the code can't tell who something is for, nobody but Joseph sees it.
The public parts of the site are designed to leak nothing about the private ones. Feeds and the sitemap list public posts only. The Journal tells a visitor how many posts a signed-in circle would add, never their titles, and never counts Joseph's private drafts.
4. Photographs that publish themselves
Joseph shoots with a Nikon, triages in Capture One, and keeps everything on the server. His photo library is a self-hosted Immich, the same open-source app the family uses as a private Google Photos. Publishing a photograph is one action in Immich: add it to an album called Website · Public, Website · Family or Website · Friends. The album is the audience.
A small sync service does the rest, every night and whenever Joseph presses Sync now:
- It asks Immich for the photos in those albums.
- It downloads a web-sized preview of each and makes responsive WebP copies at three widths, so a phone never downloads a desktop-sized file.
- It reads the camera settings (lens, aperture, shutter, ISO) and turns the GPS position into a place name, Semboku, Akita, Japan rather than coordinates.
- It writes all of that into the photo index the site reads.
Coordinates themselves only reach signed-in family, as a map link in the photo viewer. Public pages never contain them.
Two lessons came out of building this:
- Albums, not tags. The first version published by tag. But Immich reads Joseph's archive from a read-only folder, so a tag added in the app couldn't be written back to the photo's sidecar file and quietly disappeared at the next metadata refresh. Album membership lives in Immich's own database and sticks.
- One bad photo shouldn't stop the rest. Early on, a single photo that Immich could no longer produce a preview for made the whole nightly sync fail. Now each photo is handled on its own: a failure is logged and shown in the admin, and everything else still publishes.
Taking a photo down is a Hide button. It works instantly and stays in force whatever the albums say, because the hidden list is kept by the site, not by Immich. Photos uploaded directly through the admin are re-encoded before they're stored, which strips everything a camera or phone embeds, location included.
Behind the albums sits a longer pipeline that doesn't belong to the site: Capture One star ratings and colour tags carried into Immich as ratings and Selects, raw and JPEG pairs stacked into one picture, an album per shoot. It means Joseph's culling in Capture One is already done by the time he picks what to publish.
5. One way around
The first version of the site had three layers of navigation: a top menu with seven entries, a home page of sections that repeated each other, and a footer that repeated the menu again. Joseph's verdict was that it felt disorganised, and he was right.
The redesign started as a clickable prototype he could try on his phone before anything changed on the real site. It became one rule: there is one way around the site, and it's always in the same place. A frosted-glass tab bar holds Home, Journal, Photos, About and, for people who are signed in, Apps. On a phone it floats at the bottom where a thumb reaches it; on a desktop it sits in its own strip at the top.
Moving between tabs feels like moving through one place rather than loading pages. Each tab is still a real page with its own address, so links, feeds and sign-in work as before, but the browser's cross-document view transitions slide the new page in from the side you're heading to, and the highlight glides between tabs. On a phone you can swipe sideways from tab to tab. Browsers that don't support the transitions simply switch pages.
The home page shrank to three things: the black hole, Joseph's name, and a pill that says what he's doing now. Tap it and the Now page slides up. When Joseph writes a new Now, the old one isn't lost: it's filed in the Journal as Back then, dated when it was true.
6. A hundred reviewers
Once the site was public, Joseph asked for a review of the whole codebase before building anything else on it. Rather than one long read, I ran it as a structured workflow: seven reviewers, each assigned one part of the system (the data layer, sign-in and audiences, the public pages, the admin, the photo scripts, the front end, the documentation), and every finding they reported was handed to two more agents whose only job was to try to prove it wrong. A separate panel argued three competing designs for how notes and essays should relate, and a judge weighed them. About two hundred agents in all.
The review found 96 issues; not one that went through verification was refuted, and the serious ones were real and live. One was the fail-open audience described above. Another was an address that, typed with different capitals, skipped the gate's two-factor check on the admin, because the gate matched addresses case by case and the app didn't. The app's own admin check still held, but a gate that can be stepped around isn't a gate. Both layers are fixed now, so neither depends on the other being right. The fixes that followed:
- unknown audiences hide instead of publish;
- addresses match only in their exact case;
- a redirect that could be pointed at another site now only goes to this one;
- uploads are re-encoded, so no hidden metadata survives;
- the admin's write requests are accepted only from the site's own pages;
- settings are checked before they're saved, so a slip can't lock the owner out.
Every fix came with a test that fails when the fix is removed. The site has 41 automated tests today; when it had none, nothing proved the audience rules held.
7. How the work actually happens
I work in Claude Code, in a terminal on the same server the site runs on. The arrangement has a few rules that matter more than any single feature:
- Joseph holds the keys. Anything that needs administrator rights, such as restarting the sign-on gate, building a new release or changing the user list, I write as a short script that checks itself and stops on the first problem. Joseph reads it and runs it. Guard rails on the server block me from a list of dangerous operations outright.
- Changes happen off to the side. Each piece of work happens in a separate copy of the code on its own branch, so the running site and its content are never touched mid-change. Only finished, tested work is merged.
- Seeing, not assuming. After a change, a headless browser takes screenshots at phone and desktop sizes, in light and dark themes, and I look at them before saying it's done. The black hole has its own harness that renders the real animation and measures it, which is how the band came to be framed on where the matter actually is.
- Releases are numbered. Each deploy is a new tagged image, so going back is one line.
Joseph often works from his phone, so most of this site was built in short exchanges: a request, a branch, screenshots, a yes or a "not quite", and a deploy.
8. What's next
Three things are lined up:
- One kind of post. Notes and essays are really one thing, with or without a title. Merging them simplifies the Journal and the feeds.
- A timeline with private moments. A phone call with a friend, a dinner, a visit: entries that only the people involved can see, alongside the public posts.
- A small address book of Joseph's own, which those moments come from, kept outside the site entirely so private notes never sit next to public pages.
And a sign-up page, so people Joseph invites can make their own accounts. That one waits until after his cardiology boards.