Rebuilding My Ghost Blog After the Database Was Gone

My Ghost container ran outside the hosting panel, so the panel backup had none of it. How I rebuilt the blog from Markdown sources and what I verified.

Share
Rebuilding My Ghost Blog After the Database Was Gone

My blog runs on Ghost in a Docker container. The server it lived on was a hosting-panel box, and when that server went away I had a full panel backup — verified, byte-identical to the source, every domain in it. The vhosts moved over to a new box, and the blog answered with a 503.

The reverse-proxy config had come along with the panel backup. It still pointed at 127.0.0.1:2368. Nothing was listening there, because the Ghost container had never been part of the backup. And neither had its database.

TL;DR: Anything that runs outside the panel isn't in the panel backup. The blog docroot in the backup was 181 bytes — an empty directory. The Docker extension's backup was just the extension itself. The Ghost SQLite DB was gone. I rebuilt the blog from my local Markdown files (which had all the metadata in frontmatter) with my existing deploy script, seeded the tags with their old IDs so the script kept working unchanged, then hardened and verified the SEO basics instead of assuming Ghost gets them right.

Why the backup was empty

Hosting panels back up what they manage: domains, mailboxes, databases they created, files under the docroots they know. My Ghost container was started by hand with docker run, its content lived in a bind mount under /var/lib/ghost/content, and its database was an SQLite file in there. From the panel's point of view, the blog was a subdomain with an empty docroot and a proxy rule.

So the backup contained exactly that:

sites/blog.example.com/backup_pd_*.tzst          181 bytes   (empty docroot)
backup_ext_dist_docker_*.tzst                    2.2 MB      (the Docker extension, not containers)

The backup as a whole was big — several GB — and verified against the source. It just never contained the one thing I'd assumed was in it. That's the lesson I keep relearning: a backup you haven't restored is not a backup.

What I still had

Luckily I write every post as Markdown with frontmatter before it goes anywhere:

---
title: 'Some Post Title'
slug: some-post-title
tags: [linux, docker]
publishedAt: 2026-07-03 10:00:00
metaTitle: 'Shorter SEO Title'
metaDesc: 'The meta description.'
---

And I had my deploy script: Markdown + frontmatter in, rendered HTML written straight into Ghost's SQLite DB, container restart, post live. That's the pipeline I use for every post anyway. The source of truth for the content was never the database. It was a folder of .md files. That's what saved this.

What I didn't have: the theme. Only my handful of overrides survived. So the design had to be rebuilt too — that's a separate post on the Source theme and code injection.

The new container

# docker-compose.yml
services:
  ghost:
    image: ghost:6
    container_name: ghost
    restart: unless-stopped
    ports:
      - "127.0.0.1:2368:2368"
    environment:
      url: https://blog.example.com
      database__client: sqlite3
      database__connection__filename: /var/lib/ghost/content/data/ghost.db
    volumes:
      - /var/lib/ghost/content:/var/lib/ghost/content

Deliberate choices:

  • Bound to 127.0.0.1 only. The web server in front does TLS and proxies to it. Ghost itself is never reachable from outside.
  • Same content path as before, /var/lib/ghost/content, because that's what my deploy script expects. Not changing the path meant not touching the script.
  • SQLite. For a single-author blog it's plenty, and the whole site is one file plus images. Easy to back up — if you actually back it up.
  • restart: unless-stopped, and then a real reboot to prove the container comes back. One of the acceptance checks, see below.

The proxy rule on the panel side didn't change at all: requests to the blog vhost get forwarded to http://127.0.0.1:2368/. If you're building that by hand, the nginx reverse proxy post covers it.

Rebuilding the content

Order mattered:

  1. Create the owner user in Ghost's setup screen. The new user has a new ID, so the deploy script's AUTHOR_ID constant had to change.
  2. Seed the tags with the exact IDs from the deploy script. My script maps tag slugs to hard-coded Ghost tag IDs. A fresh Ghost would generate new random IDs and every post would have been inserted with dangling tag references. So I inserted the tags myself with the old IDs before deploying anything.
  3. Deploy every post from Markdown. The script sets published_at from the frontmatter, so every post kept its original date — no "everything published today" mess.
  4. Feature images via the Admin API.
  5. Bump GHOST_VERSION in the script to the new version.

Step 2 as SQL, simplified:

INSERT INTO tags (id, name, slug, visibility, created_at, updated_at)
VALUES ('<id-from-TAG_IDS>', 'linux', 'linux', 'public',
        datetime('now'), datetime('now'));
-- ...one row per tag, IDs copied from the script's TAG_IDS map

Then a loop:

for f in posts/*.md; do node deploy-post.js "$f"; done

The script writes to posts, posts_authors, posts_tags and posts_meta — metaTitle/metaDesc from the frontmatter go to meta_title/meta_description there — then restarts the container so Ghost drops its caches.

Gotcha: writing to SQLite behind Ghost's back

Writing directly into the database works, but Ghost caches things, so the container restart after each insert isn't optional. It also means the deploy script is tied to Ghost's schema. Moving to a new Ghost version means checking that the tables and columns the script writes still exist — the script has a --dry-run mode, and it's worth looking at the schema before trusting it. If you'd rather not live with that, the Admin API is the supported route; I kept SQLite because the script already worked and the restore was not the moment to rewrite it.

SEO hardening

A fresh Ghost has a bunch of features on by default that a plain text blog doesn't need, and each one loads JavaScript on every page. I turned off:

  • Members / Portal (the signup popup)
  • Newsletter
  • Tips & donations
  • Comments
  • Search (Sodo search)

All in the admin settings. My rough estimate was around 150 KB less per page view, but the number matters less than the principle: no third-party-ish widgets on a blog that has no members.

Gotcha: verify, don't assume

Ghost generates sitemaps, robots.txt, canonical tags, JSON-LD and Open Graph tags itself. "Generates" is not "verified". My acceptance list:

BASE=https://blog.example.com

# every post URL returns 200
for s in $(grep -h '^slug:' posts/*.md | awk '{print $2}'); do
  code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/$s/")
  [ "$code" = 200 ] || echo "FAIL $code $s"
done

# every slug is in the sitemap
curl -s "$BASE/sitemap-posts.xml" > /tmp/sm.xml
for s in $(grep -h '^slug:' posts/*.md | awk '{print $2}'); do
  grep -q "/$s/" /tmp/sm.xml || echo "NOT IN SITEMAP $s"
done

# robots and sitemap index reachable
curl -sI "$BASE/robots.txt"  | head -1
curl -sI "$BASE/sitemap.xml" | head -1

# no portal / members / search scripts in the HTML
curl -s "$BASE/" | grep -iE 'portal|members|sodo-search' || echo "clean"

# canonical present on a post
curl -s "$BASE/some-post-title/" | grep -o '<link rel="canonical"[^>]*>'

Plus: reboot the host and check the container is back without me touching it.

The point of writing it as commands is that I can rerun the whole list after every Ghost upgrade in seconds.

The lessons

The content source of truth lives outside the database. For me that was luck — I just happened to write Markdown first. Make it the rule: the DB is a cache of the Markdown folder, not the other way round. Put that folder in version control and back it up on its own.

Things outside the panel need their own backup. For Ghost that's the content directory: the SQLite file and the images. A scheduled copy of /var/lib/ghost/content to somewhere offsite covers it. For SQLite, use sqlite3 ghost.db ".backup /path/ghost-backup.db" rather than copying the file while Ghost is writing to it.

Check backups per service, not per server. "The panel backup is 6 GB" tells you nothing about whether your blog is in it. Open it, look for each thing you'd miss.

If you're running Docker containers next to a hosting panel, assume none of them are in the panel's backup until you've seen them there. The panel doesn't know they exist. If you're starting with Docker, installing it on Ubuntu is the easy part; the backup plan is the part people skip.


Related posts