osshp is an open-source, self-hostable platform for running your own personal website: a portfolio, a topic-tagged blog, and a photo gallery, all administered through a single-admin console. It's AGPL-3.0, it runs as a Docker Compose stack, and the copy running this site is the same code anyone can clone and run themselves. This post walks through how it got from a first working release to where it is now, and — more importantly — why the releases went in the order they did.
v0.1.0: prove the thing works
The first tagged release wasn't trying to be clever. It was trying to be real: a self-hostable personal website that a single person could stand up, put their own content into, and keep running without a team behind them.
Under the hood that meant a Docker Compose stack (app, database, object storage, reverse proxy) that goes from git clone to a live HTTPS site with docker compose up and no code changes. Postgres for content and settings, S3-compatible storage for media, Caddy handling TLS automatically. Three toggleable content modules — Blog, Pages, Photos — each with the features you'd expect from a real personal site: Markdown posts with live preview and syntax highlighting, tags, drafts, scheduled publishing, an RSS feed, per-post SEO metadata, a dependency-free lightbox gallery, an EXIF/GPS-stripping upload pipeline so photos don't leak location data.
The part that mattered most, though, was the admin console's security posture from day one. A single admin account means there's no security team watching for anomalies and no second pair of eyes on a bad decision — so the console was built passkey-first (WebAuthn), with layered recovery underneath it: a password and TOTP as a second factor, one-time recovery codes, and a local-only command-line "break glass" path for the worst case, total lockout. Routes were default-deny (nothing is public unless explicitly marked so), sessions were signed with idle timeouts, every mutating request needed a CSRF token, and the app would rather serve a 500 than boot with a weak session secret. Full-site backup and restore shipped in the same release, encrypted end-to-end, because a personal site you can't restore isn't really yours.
v0.1.0 was the release that had to answer one question: does this actually work as a real, running site? It went live and got used as a real site before anything else was built on top of it.
v0.2.0: give the operator their own data back
With a working site running for real, the next question was what a self-hoster loses by not using a big SaaS platform — and the honest answer was: basic visibility into who's reading, and portability of your own media.
v0.2.0 added first-party, self-hosted pageview analytics: server-side capture, no client-side script, no cookies, honoring Do Not Track and Global Privacy Control, no personally identifying data retained (visitor identification uses a salt that rotates daily and is never stored), 90-day retention, and — like every other module — off by default so nobody inherits tracking they didn't ask for. The point wasn't to replace a full analytics suite; it was to give an operator basic "is anyone reading this" visibility without shipping their visitors' data to a third party.
Alongside that, gallery membership (which photos belong to a gallery, in what order, with what captions and cover image) now survives an export and re-import, so a site's photo organization isn't something you'd have to rebuild by hand after a migration.
The rest of the release was a hardening pass driven by having a real deployment behind a real tunnel: isolating the Cloudflare Tunnel connector on its own network, asserting a Docker Compose version floor, and fixing the trusted-proxy hop count so rate limiting and analytics correctly see each visitor's real IP instead of the tunnel's. A few smaller fixes closed real gaps: an unauthenticated caller could previously interfere with an in-flight admin sign-in ceremony; the container now runs as a non-root user; a disabled content module no longer leaks its content onto public listings or feeds.
v0.3.0: make the front door harder to walk through twice
By v0.3.0 the focus shifted from adding capability to hardening the single point every self-hoster depends on: the admin account itself.
The headline change was step-up re-authentication. Before this release, once you were signed in, you were signed in — changing your password, enrolling a new TOTP device, regenerating recovery codes, or registering a new passkey used the same ambient session as everything else. Now, any of those credential-changing actions requires a fresh, single-use re-authentication grant, tied to the specific factor being changed, minted right before the action. The reasoning is simple: the actions that matter most — the ones that could hand an attacker permanent control if a session were ever hijacked — shouldn't ride on a session that was established possibly hours earlier for something as mundane as reading a draft post.
The second major piece was making the login rate limiter actually durable. It had tracked failed-attempt windows in process memory, which meant a restart quietly reset the clock for an attacker, and it wouldn't have worked correctly if the app ever ran as more than one instance. v0.3.0 moved that state into Postgres, with the check-and-increment done as a single atomic database operation, closing a race condition where the previous read-then-write approach could have let two near-simultaneous requests both slip through the same rate-limit ceiling.
This release also closed a leftover redirect-validation gap (URLs beginning with // or /\ were being treated as safe, same-site paths when they're not) and put a real typecheck gate into the release process, so a broken build can't quietly ride along inside next build's own type-checking.
None of this is exciting from the outside. That's the point — it's the kind of hardening that only matters when something has already gone wrong, and by then it's too late to add it.
v0.4.0: give the operator eyes and a kill switch
Step-up re-authentication makes it harder to change something important. v0.4.0 is about the next problem: if something does go wrong, does the admin even know, and can they do anything about it fast?
The Security Center (a new /admin/security page) answers the "do I know" half. It lists every active session with the device and IP information captured when that session was created, so the admin can look at the list and ask "do I recognize all of these?" It shows a paginated feed of security-relevant events, now backed by a durable, bounded audit log in Postgres instead of an in-memory view that would vanish on restart — so there's an actual record to check, not just whatever happened to still be in memory. And it shows how many recovery codes are left, without ever showing the codes themselves, so the admin knows when it's time to regenerate them before they run out.
The "can I do something" half is the revoke button: one action ends every session except the current one, immediately evicting anyone else who might be in. Because that's exactly the kind of action you don't want a compromised session to be able to undo or repeat, it's gated behind the same step-up re-authentication introduced in v0.3.0 — you have to prove you're really the admin, right now, before you can revoke everyone.
The other half of v0.4.0 is security notifications: an opt-in outbound alert, sent via a plain webhook (with an optional signature so the receiving end can verify it's genuine) or a Pushover push notification, whenever something security-relevant happens — a new passkey registered, a credential changed, a recovery code used, an account locked out, a session revoked. These are dispatched from the same single choke point that writes the audit log, so there's no code path that can cause one of these events without the other seeing it too. And critically, the notification destination is set only through a deploy-time environment variable, never through the admin UI — because if an attacker did get into the account, the last thing you'd want is for them to be able to quietly turn off the alarm that would tell you they're there.
Put together: visibility (what happened, who's logged in), control (revoke everything, right now), and awareness (get told when something notable happens) — the three things a self-hoster needs most, because there's no one else watching.
v0.4.1: making sure the alarm itself is trustworthy
Shipping an alerting feature creates a new question: can the alert itself be trusted, and does it actually reach you in every deployment mode? v0.4.1, a quick patch release that followed the day after, answers both.
On trustworthiness: a security notification includes the source IP of the event, but that value originates from request headers that, in a misconfigured deployment, could be spoofed by whoever is making the request. v0.4.1 validates that the field is actually IP-shaped before including it, and omits it entirely rather than echoing back arbitrary text if it isn't. The redaction logic that strips secrets before a notification or audit entry is written out was also made to recurse into arrays, not just plain objects, closing a theoretical gap. And documentation was added warning against pointing the notification webhook at the same instance's own authentication endpoints — a self-referential loop that lockout behavior bounds but that's better avoided outright.
On reach: two real deployment bugs meant the whole notification feature could silently do nothing. The four notification-related environment variables (webhook URL and secret, Pushover token and user key) weren't on the app container's allowed-variables list, so setting them in .env had no effect at all — notifications stayed off with no error. Separately, in Cloudflare Tunnel mode, the trusted-proxy hop count and a couple of related settings weren't being passed into the container either, which meant every visitor behind the tunnel — legitimate or not — was being attributed to the same single IP address, breaking both per-client rate limiting and per-client analytics for that whole deployment mode.
Both are the unglamorous kind of bug: not a design flaw, just plumbing that didn't reach the container it needed to reach. They mattered because a security feature that's silently disabled is worse than no feature at all — it gives false confidence that something is being watched when it isn't.
The throughline
Read end to end, the arc isn't a list of features bolted on in whatever order occurred to someone. It's a sequence with a reason behind each step: first prove the site works as a real, deployable thing (v0.1). Then give the operator back the basic visibility and portability a self-hosted alternative should provide (v0.2). Then make the credential-changing actions on a single-admin account resist a hijacked session (v0.3). Then give that admin actual visibility into their own account's security state and a fast way to shut down a problem (v0.4). And then make sure the new alarm system is itself trustworthy and actually reaches the admin in every deployment mode it's meant to support (v0.4.1).
None of this replaces having a real security team. It's the next best thing available to someone running their own site alone: a platform that assumes it has to defend itself, tells you when something's wrong, and gives you one button to pull if it is.
osshp is open source under AGPL-3.0. The full changelog, install instructions, and source are in the project repository.
