This website runs on three server dependencies, zero frameworks, and one small Node process. Its words and photos live in a Directus instance that the public internet never talks to directly. This post is a tour of how the pieces fit — and, fittingly, the first article to travel through the pipeline it describes.
Why build it myself
I could have parked my writing on a platform and been done in an afternoon. I didn't want that. I wanted a corner of the internet that I understand end to end: every byte the browser downloads, every query that produces it, and every boundary that keeps the wrong thing out. Plus, I'm on summer holidays and I'm a bit bored, then why out creating a personal website (I've been wanting to do it for a long long time).
So I gave myself a set of constraints on purpose. No frontend framework. No build step. Server-rendered articles. A real CMS behind the scenes, because editing in a textarea over SSH gets old fast.
The shape of the system
The repository is an npm workspace with two deployable halves. The website is one app; the CMS is another. They share a repository and nothing else.
my-website/
apps/
web/ the deployable website
public/ plain HTML, CSS, JavaScript
src/server.js node:http server and API routes
src/directus.js server-side Directus client
src/blog-render.js article SSR: sanitize, TOC, RSS
directus/ the CMS, deployed as its own service
extensions/ a small protected content endpoint
schema/snapshot.yaml posts + photos, version controlled
Both sides deploy to Railway from the same repository, but they are separate services with separate domains and separate secrets. The public only ever sees one of them.
Plain HTML on purpose
The pages are hand-written HTML with a shared stylesheet. There is no hydration, no virtual DOM, and no client-side routing, because the content simply doesn't need them. View source is readable. Pages cache well. Nothing breaks when JavaScript doesn't load.
Interactivity is layered on with small, page-specific scripts: the terminal on the home page, a live GitHub repository list on the projects page, tag filters on the blog index, and the photo gallery. Each is a plain script tag, so the home page never downloads the gallery's code, and vice versa.
A tiny server that does more than serve files
The whole backend is one node:http server — no Express, no framework — plus exactly three dependencies: marked for Markdown, highlight.js for code blocks, and sanitize-html for safety. Static files are the boring half of its job. The interesting half is a handful of routes:
| Route | What it does |
|---|---|
/api/health | Liveness check for the web service. |
/api/cms-health | A fresh round-trip that proves the private Directus connection works. |
/api/content | Published post metadata and photos. Article bodies stay on the server. |
/api/assets/:id | Proxies CMS images, but only ones referenced by published content. |
/blog/:slug | Server-rendered article pages, like this one. |
/rss.xml | An RSS feed generated from the same published content. |
Two details are worth pointing out. Article bodies never reach the browser as data — /api/content returns metadata only, and the HTML you are reading right now was rendered on the server. And legacy /blog?post=slug URLs redirect to real article URLs, so every post gets one canonical address.
Content lives in Directus
I did some researches and found about about Directus, and it's been surprisingly good, especially with the ability to publish from anywhere without touching git or redeploying anything.
The schema is deliberately tiny and version-controlled. A posts collection — title, unique slug, excerpt, body, tags, optional cover, and a body_format switch between Markdown and HTML — and a photos collection with an image, alt text, caption, and taken date. Both carry the same three-state status: draft, published, archived.
- Draft written in Directus, invisible to the site
- Published one status change, no redeploy
- Live on the blog within about 30 seconds
This post itself is stored as HTML — body_format: html — which is what allows the figures in it. Markdown bodies travel through the exact same pipeline.
The rendering pipeline
When you open an article, this is what happens between the CMS and your screen:
- Fetch published posts from the protected endpoint, with a 30-second cache.
- Render the body — Markdown through marked, HTML taken as authored.
- Anchor every h2 and h3 with a stable ID.
- Sanitize against an allowlist; scripts, iframes, and inline styles never survive.
- Compose the page: table of contents, reading time, tags, prev/next, related posts.
- Serve with canonical and Open Graph metadata.
A few of these deserve a sentence each. Reading time is estimated at 200 words per minute. The table of contents is built from the headings after they get their IDs. Related posts are scored by shared tags, with recency as the tiebreaker. And the RSS feed is generated from the same in-memory content, so it can never disagree with the index.
The 30-second cache is the entire reason a freshly published post can take half a minute to appear — a trade I happily make to avoid hammering the CMS on every request.
Security boundaries I actually care about
A personal site is a small target, but small is no excuse for sloppy. These are the boundaries the design enforces:
- The Directus hostname and the content key exist only on the server. Nothing shipped to the browser can reach the CMS.
- A custom endpoint inside Directus returns only published records and only public fields — it is not the admin API with a filter taped on.
- Images are proxied through the website, and the allowlist is checked twice: the CMS extension and the website each verify that an asset belongs to published content before serving it.
- Article HTML is sanitized server-side, so a careless paste into the CMS cannot become an XSS.
- Draft and archived items are never serialized, so an unfinished post cannot leak through an API response.
Running it on Railway
I've been using railway for a long time, it's 100% my favourite hosting platform so far. Production is three pieces: the website service, the Directus service, and a dedicated PostgreSQL database, plus a persistent volume mounted at /directus/uploads so media survives redeploys. The website reaches Directus over Railway's private network — plain HTTP inside the private perimeter, never exposed — while I use a separate public admin domain to write.
The CMS image is pinned to Directus 12.1.1, and the schema is reapplied from the repository on every deploy, so the content model is reproducible even though the content itself lives in Postgres.
The terminal on the home page
The home page has a fake terminal, and it is the part of this site visitors ask about most. Under the hood it is a small state machine in a single file: a command parser, a screen buffer, and a renderer. Most commands can be displayed by entering help.
A few commands are hidden on purpose and absent from help. One of them replays a NixOS build failure.
Trade-offs
The honesty section. The in-memory cache means up to 30 seconds of staleness. The server is a single process with no horizontal scaling, which would worry me if this were a business and not a blog.
Use of AI
GPT-5.6 sol helped me a lot while I was building this site, especially with the rendering pipeline and the security side of it. Because I had almost zero clue about either area. I also used it to rephrase several parts in this blog post.