Back to All Projects
Full-Stack · Self-Hosted · DevOps · 2026

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.

Node.js Express PostgreSQL JWT Auth Markdown Background Jobs Docker Cloudflare Tunnel
Status
Live
Year
2026
Role
Solo Developer
Backend
Express · PostgreSQL
Hosting
Self-Hosted + Tunnel
01 Project Overview

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.

Where it runs: ThoughtLog is live right now at 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.
02 Architecture

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.

Request lifecycle
GET /api/postspublic — returns published posts only, newest first, with tags and reading time.
GET /api/posts/:slugpublic — returns one post and atomically bumps its view counter.
POST /api/postsrequireAuth — creates a post as draft, scheduled, or published.
PUT /api/posts/:idrequireAuth — 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.

03 The Query That Makes Scheduling Work

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.

01Atomic Scheduled-Publish Sweep
-- 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"
04 Engineering Decisions
JWT in an httpOnly cookie
The login token is a signed JWT stored in an 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.
Full-text search in the database, not in JS
Search runs on a generated Postgres 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.
An atomic view counter
Reading a post bumps its view count with a single 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.
No framework, no build step on the front-end
The entire reader, dashboard, and Markdown editor live in one vanilla-JS page — no React, no bundler, nothing to compile. It loads instantly, deploys by copying a file, and there’s zero build pipeline to break on a mini PC.
05 The Writing Experience

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.

Public reads, private writes. Anyone can read the blog with no account at all. Logging in is only for writing — the editor, the dashboard, and every mutating endpoint sit behind requireAuth. That single split is what lets the same app be both a public site and a private CMS.
06 Where It Actually Runs

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.

It’s live now at 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.
07 What It Demonstrates
REST API Design
A clean split of public reads and authenticated writes, with one middleware guard doing the gatekeeping — the shape of a real backend, not a toy.
Real Authentication
bcrypt password hashing, a signed JWT in an httpOnly cookie, and server-side route guards — the actual moving parts of login, not a hard-coded password.
PostgreSQL in Depth
A real schema with parameterized queries, a generated tsvector + GIN index for search, an atomic view counter, and array columns for tags.
Background Jobs & DevOps
An in-process scheduler publishing due posts every minute, plus the full self-hosting story: PM2, Docker, and a Cloudflare Tunnel keeping it live.
Tech Stack
Node.js Express PostgreSQL pg bcryptjs jsonwebtoken marked Vanilla JS PM2 Docker Cloudflare Tunnel
Most blogs run on someone else's platform. This one runs on hardware I own.
A full-stack CMS built from scratch — Express, PostgreSQL, JWT auth, Markdown editing, and a background scheduler — live at blog.rafiarsya.com, self-hosted on a mini PC through a Cloudflare Tunnel.
View on GitHub