This site you're reading right now, steili.com, runs on software I built called osshp. It stands for Open-Source Self-Hostable Platform, which is a mouthful, but it's exactly what it says: a portfolio, a topic-tagged blog, and photo galleries, administered through a single-admin console, that you run yourself. One install, one site, one admin. No hosted SaaS in the middle, no vendor deciding what happens to your content, no monthly bill for the privilege of owning your own words.
It just went public as v0.2.0. The [code is on GitHub] (https://github.com/brandons-zz/osshp), it's licensed AGPL-3.0, and there's a showcase site at osshp.com. Here's how it actually got built.
Starting with design, not code
The first thing I did was not write a database schema. I spent real time up front on three things: a design language, a theme rendering contract, and a module contract. That ordering matters more than it sounds like it should.
The theme contract is a promise: any theme that implements it can render the whole site, and swapping themes never requires touching application code. The module contract is the same idea applied to features: Blog, Pages, and Photos are all built against one public module interface, which means "I want a blog but not a photo gallery" is a first-class supported choice, not a hack. Getting these two contracts right before building the first real feature meant I never had to go back and retrofit modularity onto code that assumed it was the only code that would ever run. Contracts-first is slower on day one and faster for the following ninety days.
The stack, and why it's boring on purpose
- Next.js 15 (App Router) + React 19, on Bun. Server-rendered, no client-side framework gymnastics for content that's fundamentally documents.
- PostgreSQL 17 as the single source of truth, via the
postgres.jsclient. Idempotent auto-migrations run on every boot; there's no separate migration-runner step to forget. - Garage (a self-hosted, AGPL-3.0, S3-compatible object store) for media, spoken to through the
minioclient library. That client is storage-provider-agnostic by design: swapping Garage for real AWS S3 later requires zero application code changes. - Caddy 2 as the reverse proxy, handling automatic HTTPS. On a real domain it gets you Let's Encrypt for free; for local dev it stands up an internal CA. Either way, you never hand-roll a certbot cron job.
- Cloudflare Tunnel (via
cloudflared) as a second deployment mode, for anyone hosting from home behind CGNAT or a dynamic IP with no port-forwarding required.
None of this is exotic. That's the point: a self-hosted platform that only works if you're an infrastructure expert has failed at being self-hostable. Boring, well-documented, widely-run components mean the person running this on a Raspberry Pi in their closet has the same debugging resources as someone running it on a cloud VPS.
Security as a load-bearing wall, not a coat of paint
A few decisions here shaped the entire codebase downstream, not just the auth module:
Passkeys first. Authentication is passkey-primary via SimpleWebAuthn, with layered recovery underneath it: password + TOTP (via otplib) as a second factor, one-time recovery codes, and a local CLI break-glass path for total lockout. No email or SMS dependency sits anywhere in the recovery chain, because email/SMS recovery is itself an attack surface.
A strict, per-request-nonce Content Security Policy. Every response carries a CSP built around a nonce, not 'unsafe-inline'. This sounds like a checkbox until you realize what it actually forbids: no inline style="..." attributes, no inline <script> blocks without the nonce, nothing loaded from a third-party origin unless it's explicitly allow-listed. This single decision quietly vetoed a dependency mid-build (more on that below), and it's the direct reason external images in blog posts get imported locally instead of hotlinked (I'll get to that too).
Markdown never touches the page unsanitized. Post content runs through unified/remark/rehype, and the HTML that comes out the other end passes through rehype-sanitize before it's ever rendered. That's a real security boundary, not a formatting convenience: it's the thing standing between "the admin wrote a blog post" and "the admin's blog post can run arbitrary script in a visitor's browser."
Default-deny everywhere. Routes are inaccessible unless explicitly marked public. Boot-time strength floors on the session secret and the encryption key mean a weak secret makes the app fail closed: it serves 500s rather than run in a state it considers insecure. That tradeoff (loud failure over silent weakness) shows up repeatedly through the codebase.
The dependency that got fired mid-build
Early on, the Photos module's lightbox used GLightbox, a solid MIT-licensed library. It worked, until the strict CSP started rejecting its style-src-attr, because GLightbox positions and animates images by writing directly to an element's style attribute, which is exactly what a nonce-based CSP with no 'unsafe-inline' is designed to catch. Rather than punch a hole in the CSP to accommodate one dependency, I replaced it with a small first-party, zero-dependency vanilla-JS lightbox that carries all its visual state through CSS classes and element properties instead of inline styles. It's CSP-strict by construction, and it's one less third-party dependency to track for CVEs and breaking changes.
That's a pattern worth naming: when a security constraint and a dependency disagree, the constraint should usually win, and often the fix is smaller than the workaround would have been.
Own your components, don't import someone else's design system
The admin console and public site UI sit on top of Radix UI, a headless accessibility primitive. Radix gives you correct keyboard handling, focus management, and ARIA wiring for things like dialogs, dropdowns, and tabs, but it renders no visual opinion of its own. Every visible component (buttons, cards, the nav, the photo grid) is osshp's own, hand-built on top of that primitive. WCAG 2.1 AA is a build requirement, not a nice-to-have, for the core, both shipped reference themes, and every one of those owned components.
The upside of that split: I get correct, tested accessibility behavior for free from Radix, and I get full control over what the site actually looks like, with no fighting a component library's opinions about spacing or color to make a design language actually feel like something.
The content pipeline
Markdown in, sanitized HTML out, with Shiki doing server-side syntax highlighting for code blocks, running synchronously so the pipeline never has to go async just to highlight a snippet, and emitting class-based output (no inline styles) so it doesn't fight the CSP either. The admin editor is TipTap, configured deliberately as a single-block Markdown source editor rather than a WYSIWYG, which means what you type is what gets saved, with no lossy JSON-to-Markdown round-trip eating your formatting.
Media: the part everyone underestimates
Photos are resized and stripped of EXIF/GPS metadata on upload via sharp, so a photo taken on a phone with location services on doesn't quietly leak where you were standing when you published it. HEIC files from iPhones get converted via heic-convert before they ever hit the pipeline, because "the platform doesn't support the file format my phone produces by default" is not an acceptable answer for a personal photo site in 2026. The upload UI itself runs on Uppy.
Getting to v0.1: release mechanics as a real deliverable
I didn't consider this done at "the code runs." Documentation, licensing, and backup/restore were treated as release-blocking, not as polish:
- Full-instance encrypted backup and restore, using
age(authenticated encryption: tampering fails closed rather than silently succeeding). This actually got hardened after the first version shipped: an earlier custom AES-256-CBC-plus-HMAC scheme was replaced withageoutright once a review turned up that a derived key could leak through process arguments. Swapping to a well-audited primitive closed that class of problem entirely instead of patching around it. - Lossless content export/import as portable Markdown with YAML frontmatter, so nothing an operator writes is ever trapped inside the app.
- AGPL-3.0 licensing, with every third-party dependency's license tracked and attributed: attribution as a release-blocking checklist item, not an afterthought nobody gets back to.
That shipped as v0.1.0.
Then I actually used it, and that's when the real bugs showed up
Here's the part I'd underline if I could only underline one thing: shipping v0.1 wasn't the finish line, it was the point where I moved steili.com onto osshp and started running my own site on it. That's when a big batch of real bugs surfaced: the kind that no amount of writing tests in isolation would have caught, because they only exist at the intersection of "real content" and "a real person publishing it under time pressure":
- Large file uploads that failed silently.
- HEIC photos from an actual iPhone that needed the conversion path exercised for real, not just in a unit test with a sample file.
- EXIF orientation data that wasn't being applied, so rotated photos displayed sideways.
- The need for actual photo galleries, not just a flat photo stream.
- A proper media manager, once I had enough images to need one.
- Cloudflare Tunnel mode, because my own hosting situation is exactly the home/dynamic-IP case that mode exists for.
None of that punch list would have been on a spec document. It came from dogfooding. If you build something meant for real use and you don't put it under real use before calling it done, you're deferring the discovery of your worst bugs to your first real users instead of finding them yourself.
v0.2.0: analytics, portability, and closing an SSRF hole
The second release added three things worth naming:
A first-party analytics module. Server-side pageview capture, no client-side script, no cookies, honoring Do Not Track and Global Privacy Control, storing no PII: visitor identity gets reduced to a daily-rotating salted hash that's never persisted, with a 90-day retention window. It's toggleable like every other module, and disabling it doesn't delete history, it just stops future capture; turn it back on and your data is still there. This exists specifically so an operator gets basic visibility into their own site without adopting any of the third-party-analytics tradeoffs (cross-site tracking, cookie banners, someone else's server logging your visitors) that osshp exists to avoid.
SSRF-safe external image import. Remember that strict CSP blocking third-party origins? It means an external image URL pasted into a blog post can't just be hotlinked: the CSP would block it, and even if it didn't, hotlinking means your visitor's browser is making a request to some other server every time they read your post, and that server now knows your visitor was there. So instead, osshp fetches the image itself, through a hardened boundary that blocks every private and link-local IP range (loopback, RFC1918, link-local including the cloud metadata address, and their IPv6 equivalents, checked by parsing to actual address bytes, not by pattern-matching the hostname string, and checked on every redirect hop, not just the first request), stores it in the media library, rewrites the post to point at the local copy, and records the original source as an attribution caption. The image survives the original host disappearing, the CSP stays intact, and your visitors' traffic never touches a third party.
Gallery membership now round-trips through export/import. A gap I'd left in v0.1 (which pictures belong to which gallery, in what order, with what captions) is now part of the same lossless content-portability story as posts and pages.
Where it stands
osshp is public now: AGPL-3.0, on GitHub, running this actual site. It's not a finished product in the sense that nothing changes anymore; it's a platform I intend to keep running my own site on, which is exactly why I expect to keep finding things to fix. If you're curious what "own your platform end to end" looks like in practice, the code is the answer. And if you want to build something like this yourself, I wrote a second post about exactly that.
