ThoughtLog
A full-stack personal blog with a CMS I built from scratch — no WordPress, no Ghost, no static-site generator. Write in Markdown with live preview, save drafts, schedule posts to publish themselves, and browse by tag. Reading is public; writing sits behind real authentication. It runs on a mini PC under my desk and reaches the internet through a Cloudflare Tunnel at blog.rafiarsya.com.
ThoughtLog is a personal blog with its own CMS, built end to end without an off-the-shelf platform. Write in Markdown with live preview beside the editor, then publish immediately, save a draft, or schedule it and let the server publish on its own. Readers get a tag cloud, full-text search, related posts, and reading-time estimates. Everything behind the writing — the editor, the dashboard, the scheduler — is locked behind a login.
I wanted somewhere to write up the engineering decisions behind my other projects without learning someone else’s admin panel. Building the CMS myself meant designing what a blog platform hides: a REST API with a clean public/authenticated split, real authentication, a post-status state machine, and a background job that publishes what’s due every minute.
blog.rafiarsya.com. It runs on a Linux mini PC at home, kept alive with PM2, and exposed to the internet through a Cloudflare Tunnel — no inbound ports opened, no public IP, no cloud bill. The same self-hosting setup I use across my projects.
A single Express app splits routes in two: public reads anyone can hit, and authenticated writes behind middleware. A reader never touches a route that can change data; a writer authenticates once and gets the editor, dashboard, and every mutating endpoint.
GET /api/posts → public — returns published posts only, newest first, with tags and reading time.GET /api/posts/:slug → public — returns one post and atomically bumps its view counter.POST /api/posts → requireAuth — creates a post as draft, scheduled, or published.PUT /api/posts/:id → requireAuth — edits content or flips status and re-publishes.
All SQL lives in exactly one file — server/db.js — so the rest of the app never writes a query inline. Authentication helpers (bcrypt hashing, JWT signing, the requireAuth guard) live in auth.js, and the timed-publishing job lives in scheduler.js. Each file has one job, which is the whole point.
Scheduled publishing is really one careful query on a timer. A scheduled post sits with status = 'scheduled' and a publish_at timestamp, hidden from readers. Once a minute the scheduler flips anything whose time has passed — in a single statement, so two ticks can never publish the same post twice.
-- runs every minute, and once at startup
UPDATE posts
SET status = 'published',
created_at = NOW()
WHERE status = 'scheduled'
AND publish_at <= NOW()
RETURNING id, title, slug;The trick is doing the find and the flip as one statement. A naive version reads the due posts, then loops and updates each one — but between the read and the write a second scheduler tick (or a server restart) can grab the same rows and publish them again. Here the WHERE filter and the SET happen inside a single atomic UPDATE: Postgres locks the matching rows, flips them, and RETURNING hands back exactly what changed so I can log it. I reset created_at to NOW() so the post appears at the top of the feed at its real publish moment, not the moment it was drafted. The identical query runs once at startup, so anything that came due while the server was off gets caught immediately.
# A post scheduled for 09:00, server checks at 09:00:30
before: { id:42, title:"Self-hosting on a mini PC",
status:"scheduled",
publish_at:"2026-06-27T09:00:00Z",
created_at:"2026-06-25T22:10:00Z" } # hidden from readers
# scheduler tick runs the UPDATE above at 09:00:30
after: { id:42, title:"Self-hosting on a mini PC",
status:"published", # now public
publish_at:"2026-06-27T09:00:00Z",
created_at:"2026-06-27T09:00:30Z" } # bumped to publish time
# RETURNING -> log: "published #42 — Self-hosting on a mini PC"httpOnly cookie, not in localStorage. That keeps it out of reach of any stray script on the page, so a content-injection bug can’t walk off with the session. The browser attaches it automatically; the requireAuth middleware verifies the signature on every write.tsvector column with a GIN index, so a query is matched by the database instead of looping over every post in Node. It stays fast as the post count grows and ranks results by relevance for free.UPDATE ... SET views = views + 1 rather than read-then-write, so concurrent readers never clobber each other’s increments and no view is silently lost.The part I cared most about is the one only I see: writing. The editor renders Markdown live beside the text as I type, using marked on the client, so there’s no save-refresh-check loop. Each post carries tags, an optional cover image, and a status.
Status is a small state machine — draft, scheduled, or published — and the dashboard counts each bucket plus total views. Drafts and scheduled posts stay invisible to readers; only the scheduler or an explicit publish makes a post public. Reading time comes from word count, related posts from shared tags.
requireAuth. That single split is what lets the same app be both a public site and a private CMS.
ThoughtLog needs Node and PostgreSQL, and that’s it. It runs on a Linux mini PC sitting on my desk — the same machine that hosts my other projects — kept alive across reboots and crashes with PM2.
Instead of opening a router port or paying for a VPS, the site reaches the internet through a Cloudflare Tunnel. Cloudflare holds the public endpoint and the tunnel dials out from the mini PC — no inbound port, no exposed public IP. TLS terminates at Cloudflare’s edge.
blog.rafiarsya.com — a real full-stack app with a real database, running on hardware I own for effectively zero monthly cost. Self-hosting was a deliberate choice: it’s the cheapest way to run a stateful Node + Postgres app full-time, and it’s the DevOps muscle I actually want to build.
blog.rafiarsya.com, self-hosted on a mini PC through a Cloudflare Tunnel.