The blog has moved from Ghost to its own engine in Go

This blog has been restarted from scratch. It used to run on Ghost, and at some point I realised I was spending more effort on keeping the platform alive than on writing. Ghost stopped being "a simple blogging engine" long ago: it is Node.js, MySQL, newsletters, paid subscriptions, memberships, integrations — a whole publishing machine, of which I needed a tenth at most. And what actually got in the way, its makers were in no hurry to fix.
Now the blog runs on its own engine. Small, written in Go, packed into a Docker container. Below is how it works, what went in, what was deliberately left out, and why.
What it had to do
The requirements fit on one line: posts in Markdown, images on the left, on the right or centred, scaled down automatically on upload, and RSS. That's it.
What it never needed: SEO add-ons, memberships and paid subscriptions, newsletters, its own comments. Ghost has all of these, all of them bring code, a database and updates along — and I used none of them.
Numbers
The blog runs on a Raspberry Pi at my home. Measured on the day of the move:
| Memory | |
|---|---|
| Ghost: Node.js | 189 MB |
| Ghost: MySQL | 531 MB |
| The new blog | 15 MB |
A page is rendered in 3–7 milliseconds. Removing Ghost with its images, backups and build cache freed about 13 GB on the Pi.
One binary
The whole blog is one Go program. Templates, styles and scripts are built into it with go:embed, so not a single file has to sit next to it on the server. It has two external dependencies: goldmark for Markdown and golang.org/x/image for images. Everything else is Go's standard library.
Left out: web frameworks, ORMs, front-end bundlers. For a blog with one author, net/http out of the box is enough, and every extra dependency is one more thing that will break on some update.
No database
Posts, pages, settings and image records live in a single file, blog.json. It is held in memory in full and written atomically on every change: first to a temporary file, then renamed. Even if the power goes out in the middle of a write, the disk holds either the old version or the new one, never a fragment.
Left out: MySQL, PostgreSQL and even SQLite. The texts of a personal blog are megabytes, not gigabytes. A database is a separate process (in Ghost's case, the hungriest one), schema migrations and a backup procedure of its own. A JSON file can be read by eye, copied together with the rest of the data and restored with a single tar command.
Markdown
Posts are written in Markdown: CommonMark plus GitHub-style tables, strikethrough, task lists and bare-URL links. On top of that come a few extensions I was missing:
- an image with a
#leftor#rightfragment has the text flow around it; with no fragment or with#centerit spans the text column; on phones floats turn into full-width images; - the caption under an image comes from its
title; - highlighting with double equals signs, footnotes, and
[TOC]— a table of contents of the headings, like the one at the top of this post; - a YouTube link on its own line becomes a player in privacy-enhanced mode.
The editor has a toolbar for the whole dialect and a preview rendered by the server itself — exactly as the post will look on the site.
Raw HTML in posts passes through as is. Usually that is a hole, but only one person writes here — me — so this is a deliberate choice, not an oversight.
Left out: a visual WYSIWYG editor like Ghost's. It is convenient right up to the moment you need to work out why a paragraph is formatted not the way you wanted. Markdown is plain text that can always be read and fixed by hand.
Images
Every uploaded image goes through one pipeline: it is decoded, turned upright from its EXIF orientation (phone photos no longer lie on their side), scaled to at most 2000 pixels wide, and saved with 600, 1000 and 1400 pixel copies — the browser picks the right one through srcset. Re-encoding also strips the metadata: the camera model and GPS coordinates no longer travel to the internet along with the photo.
PNGs keep their transparency, GIFs are kept as they are, animation included (re-encoding would lose frames or colours), SVGs are stored unchanged, and WebP is saved as JPEG.
Left out: ImageMagick and libvips. They are faster and can do more, but they bring C libraries along, which means building with CGO and a fat image. In pure Go the binary is built statically, and the image stays small.
Two languages at one address
A post can exist in Russian and in English. In Ghost these used to be two separate posts, with an -ru suffix in the address. Now it is one post with a translation and one URL, and the language is chosen like this: an explicit ?lang= in the link, then the RU/EN switch (remembered in a cookie), then the browser's language, then the blog's default language. Without a translation, the original is shown with a note saying so.
The old -ru addresses have not gone anywhere: they redirect to the new post with ?lang=ru. There are three RSS feeds: the default one, a Russian one and an English one.
Comments live elsewhere
The blog has no comments of its own and won't have any: moderation, spam and storing other people's personal data are not what I want to deal with for a personal blog. Instead, under every post there are links to my profiles on Bluesky, Mastodon and X — that is where to discuss it.
The blog announces new posts on Bluesky by itself, with a link card: cover, title and the start of the text. It is turned on with a checkbox in the editor, works for scheduled posts too, and never sends the same post twice.
Signing in to the admin panel
A user name, a password and a one-time code from an authenticator app (TOTP). Each code is accepted only once. Any change on the Security page signs every other browser out of the admin panel. Sign-in attempts are rate-limited.
The password is stored as a PBKDF2-SHA256 hash from Go's standard library, and the session is an HMAC-signed cookie, with no session store on the server.
Docker
The image is built in two stages: in the first, Go compiles a static binary; in the second, it is copied into a clean Alpine, where the only extra is time zone data. The container runs as an unprivileged user, and all data sits in one volume: blog.json, the sign-in settings, the session key and the images.
The image is built straight from the GitHub repository, so an update on the server is one command:
docker compose build --no-cache && docker compose up -d
Before that, every code change is checked on GitHub: formatting, go vet, tests with the race detector and a trial build for ARM64 — the Raspberry Pi's architecture.
Backups and a standby server
Every night a script packs the data volume together with the container's settings into an archive and keeps the ten newest. The blog keeps running meanwhile: files are written atomically, so they are safe to read on the fly.
On top of that, a server in Amsterdam holds a copy of the blog, which compares itself with the one at home every ten minutes and gets fresh data when they differ. It has no admin panel at all — it runs with the admin interface switched off, so that nobody edits by accident a copy that the next sync would overwrite anyway. If something happens to the Raspberry Pi, it is meant to take the blog over.
Moving from Ghost
For the move, the repository has a converter for Ghost's JSON export and an importer that runs all the old images through the same pipeline. Post addresses stayed the same, and old image links like /content/images/… redirect to the new copies — so outside links to this blog did not break.
Ghost itself has been removed completely, together with its database and all its backups. There is no way back, and none is needed.
Source code
The blog's code is open: github.com/WhyElEss/go-blog
