> ## Content Index
> Fetch the complete content index at: https://blog.viveknet.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Self-Hosting a Blog with Ghost: Migrate Your Wiki or Start Fresh
- URL: https://blog.viveknet.com/self-hosting-a-blog-with-ghost-on-a-vps/
- Published: 2026-10-04T07:10:44.000Z
- Updated: 2026-10-04T07:24:48.000Z
- Author: Vivek Chandran
- Tags: Docker

## Introduction

For a couple of years my technical notes lived in Wikidocs, a small file based documentation wiki. It did the job, but as the collection grew into something I wanted other people to read, its limits became obvious. There was no RSS feed, the generated sitemap pointed at `127.0.0.1`, pages had no social preview tags, search engines had very little to work with, and the design looked like documentation rather than a blog.

I replaced it with Ghost, an open source publishing platform, running in Docker on a small VPS and published to the internet through Pangolin. This guide is the complete record of that move. It covers the architecture, the installation, the hardening, the theme work, the migration of articles and screenshots, backups and monitoring, and the mistakes I made along the way, so that you can build the same thing without repeating them.

By the end of this guide you will have:

- Ghost 6 with its MySQL database running in hardened containers on a VPS
- A reverse proxy layer that adds security headers, rate limits and locks the admin area to your own IP address
- The site published over HTTPS through Pangolin and Cloudflare, with no inbound ports opened beyond the ones Pangolin already uses
- A modern dark theme, working code highlighting, a copy button, a table of contents and a share menu, all without trusting third party scripts
- Either your first posts, written and published, or a tested script that migrates an existing set of markdown articles (I used a Wikidocs style wiki) into Ghost
- Daily backups, health monitoring and a clear maintenance routine
- An honest view of what self hosting costs you, so you can decide whether it is right for you

Every hostname, address and credential below is a placeholder. Replace `blog.example.com`, `203.0.113.10` (the VPS public address) and `198.51.100.7` (your home address) with your own values.

## Which path are you on?

Readers arrive here from two different places, so the guide is organised to serve both.

| Your situation                                                           | What to follow                                                                                                                                                                      |
| ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Starting fresh** with no existing blog                                 | Steps 1 to 6 build the platform. Then follow **Step 7A** to write your first content, and carry on with Steps 8 to 10\. Skip Step 7B.                                               |
| **Migrating** from Wikidocs, another wiki, WordPress, Substack or Medium | Steps 1 to 6 build the platform. Then follow **Step 7B** to bring your content across, and carry on with Steps 8 to 10\. Step 7A is only needed for cleaning up the sample content. |

Steps 1 to 6 and 8 to 10 are identical for both. Whatever you do, build and harden the platform first and import or write content second. That way you are never moving content onto a server that is not ready, and a mistake in the setup does not cost you any articles.

## Why Ghost, and why self host it

I looked at the usual options before choosing. This is how they compare for a technical blog.

| Option                              | Strengths                                                                                                                             | Weaknesses                                                                        |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Ghost on your own VPS               | Full control, no monthly platform fee, fast, excellent built in SEO, clean editor, your content stays in files and a database you own | You run everything: updates, backups, security, email                             |
| Ghost(Pro) managed hosting          | Zero maintenance, email and newsletters just work                                                                                     | Monthly cost that grows with audience, less control over the server               |
| WordPress                           | Enormous plugin ecosystem                                                                                                             | Heavier, a large attack surface, constant plugin and theme patching               |
| Substack or Medium                  | No setup at all                                                                                                                       | You do not own the platform, limited design control, the audience belongs to them |
| Static site generator (Hugo, Astro) | Tiny attack surface, very fast                                                                                                        | No web editor, so every article is a file and a rebuild                           |

I chose Ghost because I wanted a proper writing interface in the browser, a modern look, and search engine friendliness out of the box, but I also wanted the whole thing to live on infrastructure I already run and understand.

### Advantages of self hosting

- **Cost.** A small VPS that already hosts other services costs nothing extra. Ghost and its database together use roughly 800 MB of memory.
- **Control.** You decide the theme, the headers, the URLs, the redirects and the retention of your data.
- **Ownership.** Your articles are rows in a database and files on disk. Moving elsewhere later is an export, not a negotiation.
- **Learning.** You end up understanding reverse proxies, container hardening, backups and DNS, which is useful well beyond a blog.
- **Privacy.** No third party tracking you did not choose to add.

### Disadvantages of self hosting

- **You are the operations team.** Security updates, container updates, backups and monitoring are your job. If the server is down at 2 AM, nobody else fixes it.
- **Email is genuinely hard.** Sign in links and notifications need a mail path that reaches inboxes rather than spam folders. This is the single most annoying part of self hosting Ghost, and I explain how I handled it below.
- **Bulk newsletters need an extra service.** Ghost sends newsletters through Mailgun, so a self hosted blog that wants a newsletter needs that account too. I turned member signups off because I did not need them yet.
- **Single point of failure.** One VPS means one failure domain. Backups are not optional.
- **Time.** The setup in this guide took me a few evenings, plus polish. A managed service takes minutes.

If you only want to write, Ghost(Pro) or a hosted platform is the sensible choice. If you also want to learn and keep control, read on.

## Architecture

```
  Visitor
     |  HTTPS
     v
  Cloudflare (DNS, TLS to the visitor, caching of static files)
     |  HTTPS to your VPS public address
     v
  VPS (Ubuntu, Docker)
     |
     +-- Pangolin stack: Gerbil + Traefik   (terminates TLS, routes by hostname)
              |  HTTP over the Docker network named "pangolin"
              v
     +-- nginx proxy container   (security headers, admin lock, rate limits)
              |  HTTP over the private "ghost-internal" network
              v
     +-- Ghost container         (the blog)
              |
              v
     +-- MySQL container         (database, never exposed)

```

Three containers make up the blog, and each has one job. Ghost serves the site. MySQL stores the content. A small nginx container sits in front of Ghost purely to add protections that Ghost cannot add itself. Pangolin, which I covered in a previous guide on this site, handles the public edge: it terminates TLS and routes the hostname to the right container.

## Prerequisites

- A VPS with a public IPv4 address. I use a RackNerd KVM VPS. The entry level plans are inexpensive, and the one thing to check before you buy is the plan memory. Ghost with MySQL is comfortable with 2 GB. My provider offers no snapshot feature, which is why the backups later in this guide matter so much.
- Ubuntu Server (I use 26.04, but 24.04 behaves the same for everything here), Docker CE and the Docker Compose plugin.
- A domain whose DNS is hosted on Cloudflare, with a hostname such as `blog.example.com`.
- Pangolin already running on the VPS. The Pangolin guide on this site covers installing and hardening it.
- An email path for sending sign in codes. Any SMTP relay you are allowed to use will do.
- Basic comfort with SSH and the Linux command line.

## Step 1: directory layout and permissions

Keep everything for the blog under one folder, with a dedicated owner for data and a restrictive mode.

```bash
sudo mkdir -p /srv/docker/ghost/{content,mysql,nginx}
cd /srv/docker/ghost

# secrets: random, readable only by root
sudo sh -c 'umask 077; {
  echo "MYSQL_ROOT_PASSWORD=$(openssl rand -hex 24)"
  echo "MYSQL_PASSWORD=$(openssl rand -hex 24)"
} > /srv/docker/ghost/.env'

# The containers run as these numeric users, so the data folders must belong to them
sudo chown 1000:1000 /srv/docker/ghost/content     # Ghost runs as uid 1000 (user "node")
sudo chown 999:999   /srv/docker/ghost/mysql       # MySQL runs as uid 999
sudo chmod 750 /srv/docker/ghost/content /srv/docker/ghost/mysql

```

Never put passwords in the compose file itself. The `.env` file is read by Compose and stays out of version control and out of container inspection output in your notes.

## Step 2: the Docker Compose stack

Create `/srv/docker/ghost/compose.yaml`. Replace the image references with digests you have verified (explained below the file).

```yaml
name: ghost-blog

services:
  db:
    image: mysql:8.4            # pin by digest in production, see below
    container_name: ghost-db
    restart: unless-stopped
    user: "999:999"
    environment:
      TZ: Australia/Brisbane
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: ghost
      MYSQL_USER: ghost
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
    volumes:
      - /srv/docker/ghost/mysql:/var/lib/mysql
    healthcheck:
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD --silent"]
      interval: 15s
      timeout: 5s
      retries: 10
      start_period: 40s
    security_opt: [no-new-privileges:true]
    cap_drop: [ALL]
    mem_limit: 768m
    pids_limit: 200
    logging:
      driver: json-file
      options: { max-size: "10m", max-file: "3" }
    networks: [internal]

  ghost:
    image: ghost:6-alpine       # pin by digest in production
    container_name: ghost
    user: "1000:1000"
    restart: unless-stopped
    depends_on:
      db: { condition: service_healthy }
    environment:
      TZ: Australia/Brisbane
      NODE_ENV: production
      url: "https://blog.example.com"
      database__client: mysql
      database__connection__host: db
      database__connection__user: ghost
      database__connection__password: ${MYSQL_PASSWORD}
      database__connection__database: ghost
      # outgoing mail, see Step 5
      mail__transport: SMTP
      mail__from: "Your Blog <blog@example.com>"
      mail__options__host: smtp.example.com
      mail__options__port: "587"
    ports:
      - "127.0.0.1:2368:2368"   # loopback only, for diagnostics
    volumes:
      - /srv/docker/ghost/content:/var/lib/ghost/content
    healthcheck:
      test: ["CMD-SHELL", "wget -q --spider --header 'X-Forwarded-Proto: https' http://127.0.0.1:2368/ || exit 1"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 60s
    security_opt: [no-new-privileges:true]
    cap_drop: [ALL]
    mem_limit: 768m
    pids_limit: 200
    logging:
      driver: json-file
      options: { max-size: "10m", max-file: "3" }
    networks:
      internal:
        aliases: [ghost]

  proxy:
    image: nginxinc/nginx-unprivileged:stable-alpine
    container_name: ghost-proxy
    restart: unless-stopped
    depends_on:
      ghost: { condition: service_healthy }
    volumes:
      - /srv/docker/ghost/nginx:/etc/nginx/conf.d:ro      # mount the DIRECTORY, not a single file
    healthcheck:
      test: ["CMD-SHELL", "wget -q --spider --header 'X-Forwarded-Proto: https' http://127.0.0.1:2368/ || exit 1"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 15s
    security_opt: [no-new-privileges:true]
    cap_drop: [ALL]
    read_only: true
    tmpfs: [/tmp]
    mem_limit: 64m
    pids_limit: 50
    logging:
      driver: json-file
      options: { max-size: "10m", max-file: "3" }
    networks:
      internal:
      pangolin:
        ipv4_address: 172.18.0.15     # a free address in Pangolin's network, outside its dynamic range

networks:
  internal:
    name: ghost-internal
  pangolin:
    external: true
    name: pangolin                    # the name of the Docker network your Pangolin stack uses

```

Start it later, once the proxy configuration exists. A few choices in this file deserve explanation.

**Why these hardening options.** Every container drops all Linux capabilities, cannot gain new privileges, has a memory and process limit so a runaway process cannot take the host down, and rotates its logs. The proxy container additionally has a read only filesystem. None of this costs anything when an image runs fine without the extra privileges, and it limits what an attacker could do if one container were ever compromised.

**Why Ghost runs as user 1000 directly.** My first attempt left Ghost to start as root and drop to the `node` user itself, which is the image default. Combined with `cap_drop: [ALL]` it crash looped with `find: /var/lib/ghost/content: Permission denied`. The image entrypoint tries to change ownership of the content folder as root, and root without capabilities cannot read a folder owned by another user. Setting `user: "1000:1000"` and pre owning the folder skips that logic entirely and is also the more secure arrangement.

**Why the healthcheck sends a header.** Ghost is configured with an `https` URL. When it receives a plain `http` request it replies with a redirect to the https address, and a healthcheck that follows the redirect would fail. The `X-Forwarded-Proto: https` header makes Ghost treat the request as if it had already arrived over TLS.

**Why mount the nginx directory.** A bind mount of a single file keeps pointing at the original file inode. If you later replace that file (with `mv`, `install` or `sed -i`, all of which create a new file), the container keeps running with the old configuration and a reload does nothing. Mounting the folder avoids the problem entirely. I lost a surprising amount of time to this.

**Pin images by digest.** Tags such as `6-alpine` move. After pulling, record the exact content you tested.

```bash
docker pull ghost:6-alpine
docker image inspect ghost:6-alpine --format '{{index .RepoDigests 0}}'
# paste the result (name@sha256:...) into compose.yaml, optionally keeping the tag as a comment

```

Repeat for `mysql:8.4` and the nginx image. When you want to update, you pull, read the release notes, change the digest and redeploy. Updates become a decision instead of a surprise.

## Step 3: the nginx proxy configuration

This small proxy does three things Ghost cannot do for itself: it adds security headers, it rate limits the endpoints that send email, and it blocks the admin area for everyone except you.

First generate a list of Cloudflare's network ranges. The admin lock needs it so that it only trusts the visitor address when the request really came through Cloudflare.

```bash
cd /srv/docker/ghost/nginx
{
  echo '# Cloudflare edge ranges (regenerate if Cloudflare publishes changes)'
  echo 'geo $xff_edge $cf_edge {'
  echo '    default 0;'
  curl -fsS https://www.cloudflare.com/ips-v4 | sed 's/$/ 1;/;s/^/    /'
  curl -fsS https://www.cloudflare.com/ips-v6 | sed 's/$/ 1;/;s/^/    /'
  echo '}'
} | sudo tee cloudflare-geo.inc >/dev/null

```

Now create `/srv/docker/ghost/nginx/default.conf`.

```nginx
# Real client address as appended by Cloudflare, and the peer Traefik saw (a Cloudflare edge address).
# X-Forwarded-For arrives as "client, edge".
map $http_x_forwarded_for $xff_client { default ""; "~(?<c>[0-9a-fA-F.:]+)\s*,\s*[0-9a-fA-F.:]+$" $c; }
map $http_x_forwarded_for $xff_edge   { default ""; "~[0-9a-fA-F.:]+\s*,\s*(?<e>[0-9a-fA-F.:]+)$" $e; }
include /etc/nginx/conf.d/cloudflare-geo.inc;

# Trust the "client" value only if the request really came through Cloudflare
map $xff_client $is_home { default 0; 198.51.100.7 1; }          # YOUR home or office address
map "$cf_edge$is_home" $admin_ok { "11" 1; default 0; }
map $uri $is_admin_path { default 0; ~^/ghost/api/content 0; ~^/ghost(/|$) 1; }
map "$is_admin_path$admin_ok" $deny_admin { "10" 1; default 0; }

# Rate limits: key is the real client address
map "$cf_edge:$xff_client" $rl_key { "~^1:(?<ip>.+)$" $ip; default $remote_addr; }
limit_req_zone $rl_key zone=ghost_auth:10m rate=6r/m;
limit_req_zone $rl_key zone=ghost_comments:10m rate=30r/m;
limit_req_status 429;

server {
    listen 2368;
    server_name _;
    client_max_body_size 50m;
    server_tokens off;

    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;
    add_header Content-Security-Policy "frame-ancestors 'self'; base-uri 'self'; object-src 'none'; form-action 'self'" always;
    add_header Cross-Origin-Opener-Policy "same-origin" always;

    # Admin area: only your address, only via Cloudflare. The public Content API stays open.
    if ($deny_admin) { return 403 "Forbidden\n"; }

    location = /members/api/send-magic-link/ {      # every request here sends an email
        limit_req zone=ghost_auth burst=4 nodelay;
        include /etc/nginx/conf.d/proxy-common.inc;
    }
    location ^~ /members/api/comments/ {
        limit_req zone=ghost_comments burst=20 nodelay;
        include /etc/nginx/conf.d/proxy-common.inc;
    }
    location / {
        include /etc/nginx/conf.d/proxy-common.inc;
    }
}

```

And the shared proxy settings in `/srv/docker/ghost/nginx/proxy-common.inc` (the `.inc` extension keeps nginx from loading it as a standalone config).

```nginx
proxy_pass http://ghost:2368;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_hide_header X-Powered-By;
proxy_read_timeout 60s;

```

### Why the admin lock is built this way

The obvious approach, allowing only your IP address, has a trap when a site sits behind Cloudflare. The address nginx sees is Cloudflare's, and the real visitor address only exists in the `X-Forwarded-For` header. A client can send that header too, so a naive check could be fooled by someone connecting straight to your server and claiming to be you.

The configuration above avoids that. Cloudflare appends the real client address to the header, and Traefik then appends the address of the peer it actually talked to. So the last two entries are the client and the edge. The rule only accepts the client value when the edge value falls inside Cloudflare's published ranges. A direct to server request has a peer that is not a Cloudflare address, so a forged header is ignored. I tested exactly that case, and a request that claims to come from my address but does not come through Cloudflare gets a 403.

### Always test the config before you apply it

My first version of this file had an invalid `if` condition. nginx does not allow two variables to be joined inside an `if`, which is why the file uses a combined `map` instead. Because the first version was applied directly, the proxy crash looped and the site returned 502 for about two minutes. Test every change in a throwaway container before touching the live one. The files must be readable by the container user.

```bash
sudo chmod 755 /srv/docker/ghost/nginx && sudo chmod 644 /srv/docker/ghost/nginx/*
docker run --rm --network ghost-internal \
  -v /srv/docker/ghost/nginx:/etc/nginx/conf.d:ro \
  nginxinc/nginx-unprivileged:stable-alpine nginx -t

```

The network flag matters because nginx resolves the name `ghost` while testing, and that name only exists on the Ghost network. That network is created the first time the stack starts, so bring up just the database and Ghost before you run the test.

```bash
cd /srv/docker/ghost
docker compose up -d db ghost       # creates the ghost-internal network

```

## Step 4: bring it up and publish it through Pangolin

With the proxy configuration tested, start the whole stack.

```bash
cd /srv/docker/ghost
docker compose config -q && docker compose up -d
docker compose ps          # all three should become healthy

```

Once the three containers are healthy, test locally.

```bash
curl -sI http://127.0.0.1:2368/ -H 'X-Forwarded-Proto: https' | head -3

```

### Create the DNS record

In Cloudflare, add a record for `blog.example.com` pointing at your VPS public address (`203.0.113.10`), with the orange cloud proxy turned on. This is the setting that gives you Cloudflare TLS, caching and DDoS absorption.

### Create the Pangolin resource

In the Pangolin dashboard create a new HTTP resource.

- **Domain:** `blog.example.com`
- **Target:** the proxy container address on the Pangolin network, `172.18.0.15`, port `2368`, method `http`
- **Authentication:** switch Pangolin's own login off for this resource. A public blog must be readable by anyone.

#### The mistake I made on this step

Docker shows several addresses for a container with more than one network, and the dashboard I use lists only one of them. I first copied the address of the Ghost container on its private network and the host port, which produced a resource that Traefik could never reach. The symptom was 504 errors after exactly 30 seconds. The target must be an address on the network that Traefik is attached to (the Pangolin network), and the port must be the port the container listens on inside, not a port published on the host. If you see 504 errors from a new resource, check this first.

## Step 5: first login and email

Open `https://blog.example.com/ghost/` and create your administrator account yourself. Do this before enabling the admin lock, or add your address to the lock first, because the lock returns 403 to everyone else, including a brand new setup page.

Ghost 6 asks for an emailed verification code when you sign in from a new device, so outgoing mail has to work before you can log in. You have two sensible choices.

- **A relay you already have.** If you operate an SMTP relay that accepts your server, point Ghost at it, as in the compose file above. I used a Microsoft 365 connector that authorises my VPS by IP address, so no password is needed. For authenticated relays add `mail__options__auth__user` and `mail__options__auth__pass`.
- **A dedicated sending service.** Brevo, Resend and Mailjet all have free tiers and standard SMTP. This is the better long term choice because the mail comes from a domain you verify with SPF, DKIM and DMARC records, and it keeps blog mail separate from your own mailbox.

Whichever you use, test that the relay accepts outside recipients without actually sending anything. This quick probe speaks SMTP up to the recipient check and then hangs up.

```bash
# run inside any container that has Node, for example Ghost, with the relay host in $RELAY
docker exec -i ghost node - <<'EOF'
const net = require("net");
const host = process.env.mail__options__host;
const s = net.connect(25, host); s.setEncoding("utf8"); let step = 0;
s.on("data", d => { const l = d.trim().split("\r\n").pop(); console.log("<", l.slice(0, 80));
  if (step==0 && /^220/.test(l)) { s.write("EHLO test\r\n"); step=1; }
  else if (step==1 && /^250 /.test(l)) { s.write("MAIL FROM:<blog@example.com>\r\n"); step=2; }
  else if (step==2 && /^250/.test(l)) { s.write("RCPT TO:<someone@gmail.com>\r\n"); step=3; }
  else if (step==3) { s.write("RSET\r\nQUIT\r\n"); s.end(); } });
EOF

```

A `250 Recipient OK` for an outside address means the relay will deliver your sign in mail. A `550` or `relay denied` means you need a different relay.

## Step 6: configure the site

### Basic settings

In Ghost Admin open Settings and set the title, description, timezone and the accent colour. In the navigation settings add your section links. For a read only blog, turn member signups off, because every signup sends an email through your relay.

A note for people who prefer to script this. Ghost's custom integration keys, which are the normal way to call the Admin API, can create and edit posts and pages but are refused (HTTP 403) when they try to edit settings or run the bulk content import. Those two jobs need an interactive staff login. That is why the migration later in this guide creates posts one by one through the API. It is also why a few of my settings were changed directly in the database. If you are following along, simply use the Settings screens instead.

### A theme you control

Ghost ships with the Source and Casper themes, but in a Docker install they live inside the image, so you cannot add files to them. Make a copy that you own.

```bash
docker cp ghost:/var/lib/ghost/current/content/themes/source /tmp/source-theme
sudo cp -r /tmp/source-theme /srv/docker/ghost/content/themes/blogs
sudo chown -R 1000:1000 /srv/docker/ghost/content/themes/blogs
docker restart ghost

```

In Settings, Design, change the theme and activate `blogs`. Then use the theme's own options (Settings, Design, Customize) for the big visual changes before writing any CSS. I set a dark background colour (`#0d1117`), the logo on the left, a Landing header with a headline, a grid feed and images in the feed. Source recomputes its text colours from the background, so a dark background works properly.

### Custom CSS

Put your own styles in the theme so they travel with it.

```bash
sudo mkdir -p /srv/docker/ghost/content/themes/blogs/assets/css
sudo nano /srv/docker/ghost/content/themes/blogs/assets/css/custom.css
sudo chown -R 1000:1000 /srv/docker/ghost/content/themes/blogs/assets

```

Then link it in Settings, Advanced, Code injection, in the site header box. Always add a version number.

```html
<link rel="stylesheet" href="/assets/css/custom.css?v=1">

```

The version number is not decoration. Cloudflare caches static files by their full address, so if you edit the file and keep the same address you will keep seeing the old version. Increase the number every time you change the file.

Two lessons that cost me time while writing the CSS:

- **Source sets the root font size to 10 pixels** (`html { font-size: 62.5% }`). So `1.8rem` means 18 pixels. My first version used values such as `1.08rem`, which is about 11 pixels, and the whole site looked tiny. Write sizes in this scale or use `px`.
- **Do not override `--container-width`.** The theme's grid calculations for centring the article column depend on it. I overrode it to widen the page and the article jumped to the left edge. Widening only the article with `--content-width` works fine.

A short selection of the styles that give the site its look follows. They are plain CSS with no external resources.

```css
:root { --accent: #4CAF50; --card: #151a22; --border: #262d38; --muted: #8b949e; }

body {
  background:
    radial-gradient(900px 420px at 12% -8%, rgba(76,175,80,.16), transparent 60%),
    radial-gradient(800px 380px at 96% 2%, rgba(56,139,253,.11), transparent 55%),
    #0d1117 !important;
  background-attachment: fixed !important;
}

/* glass navigation bar */
.gh-navigation { position: sticky; top: 0; z-index: 50; background: rgba(13,17,23,.72) !important;
  backdrop-filter: saturate(160%) blur(12px); border-bottom: 1px solid var(--border); }

/* cards */
.gh-card { background: var(--card); border: 1px solid var(--border) !important; border-radius: 14px;
  transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease; }
.gh-card:hover { transform: translateY(-3px); border-color: rgba(76,175,80,.6) !important;
  box-shadow: 0 14px 34px rgba(0,0,0,.4); }

/* wider, roomier, justified articles */
html:root { --content-width: 920px; }
.gh-content { font-size: 1.8rem; line-height: 1.85; }
.gh-content > * + * { margin-top: clamp(22px, 2vw, 32px); }
.gh-content > p, .gh-content > ul > li, .gh-content > ol > li {
  text-align: justify; text-justify: inter-word; hyphens: auto; }
.gh-content pre, .gh-content code, .gh-content h2, .gh-content h3 { text-align: left; hyphens: none; }

/* code blocks and tables */
.gh-content pre { position: relative; background: #0b0f14 !important; border: 1px solid var(--border);
  border-radius: 12px; padding: 0 !important; }
.gh-content pre code { display: block; padding: 1.1em 1.3em !important; overflow-x: auto; font-size: .88em; line-height: 1.65; }
.gh-content table { width: 100%; border-collapse: separate; border-spacing: 0; border: 1px solid var(--border);
  border-radius: 12px; overflow: hidden; }

```

### Code highlighting without trusting a third party

Technical articles need syntax highlighting. The common way is to load highlight.js from a CDN, but that means your visitors run someone else's script on every page view. I download it once, verify it, and serve it from my own site. The same method applies to any script you ever add.

```bash
cd /tmp && mkdir hljs && cd hljs
V=11.9.0; B=https://cdnjs.cloudflare.com/ajax/libs/highlight.js/$V
for f in highlight.min.js styles/github-dark.min.css languages/powershell.min.js languages/dockerfile.min.js; do
  curl -fsSLO "$B/$f"
done

# 1. Compare each file with the hash cdnjs publishes for it
curl -fsS "https://api.cdnjs.com/libraries/highlight.js/$V?fields=sri" -o sri.json
for f in highlight.min.js github-dark.min.css powershell.min.js dockerfile.min.js; do
  echo "$f  sha512-$(openssl dgst -sha512 -binary $f | openssl base64 -A)"
done
grep -o '"[a-z/.-]*min\.\(js\|css\)":"sha512-[^"]*"' sri.json    # compare the two lists by eye

# 2. Look for anything that talks to the network or reads storage
grep -o -E 'XMLHttpRequest|fetch\(|sendBeacon|WebSocket|document\.cookie|eval\(|new Function|importScripts' *.js | sort | uniq -c

```

All four hashes matched. The second check produced no network calls. The only hits for words such as `localStorage` were keyword lists inside the language grammars, which is expected for a highlighter. Install the files into the theme and load them.

```bash
sudo mkdir -p /srv/docker/ghost/content/themes/blogs/assets/hljs
sudo cp highlight.min.js github-dark.min.css powershell.min.js dockerfile.min.js \
        /srv/docker/ghost/content/themes/blogs/assets/hljs/
sudo chown -R 1000:1000 /srv/docker/ghost/content/themes/blogs/assets

```

In Code injection, add to the header box

```html
<link rel="stylesheet" href="/assets/hljs/github-dark.min.css">

```

and to the footer box

```html
<script src="/assets/hljs/highlight.min.js"></script>
<script src="/assets/hljs/powershell.min.js"></script>
<script src="/assets/hljs/dockerfile.min.js"></script>
<script>document.querySelectorAll("pre code").forEach(function(b){hljs.highlightElement(b);});</script>

```

### A copy button, a table of contents and a share menu

The old wiki had a copy button on code blocks and a table of contents, and readers expect both. Ghost has neither, so I wrote a small script of about 90 lines with no dependencies and no network access. Save it as `assets/js/blog.js` in the theme and add `<script src="/assets/js/blog.js?v=1"></script>` after the other scripts in the footer injection.

The copy button first.

```javascript
(function () {
  function copyText(text, done) {
    if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text).then(done, fallback); }
    else { fallback(); }
    function fallback() {
      var t = document.createElement("textarea");
      t.value = text; t.setAttribute("readonly", ""); t.style.position = "fixed"; t.style.opacity = "0";
      document.body.appendChild(t); t.select();
      try { document.execCommand("copy"); done(); } catch (e) {}
      document.body.removeChild(t);
    }
  }
  document.querySelectorAll(".gh-content pre").forEach(function (pre) {
    var b = document.createElement("button");
    b.type = "button"; b.className = "vn-copy"; b.textContent = "Copy";
    b.addEventListener("click", function () {
      var code = pre.querySelector("code");
      copyText((code || pre).innerText.replace(/\n$/, ""), function () {
        b.textContent = "Copied!"; b.classList.add("is-done");
        setTimeout(function () { b.textContent = "Copy"; b.classList.remove("is-done"); }, 1600);
      });
    });
    pre.appendChild(b);
  });
})();

```

The table of contents, built from the article headings. Ghost already gives every heading an anchor id.

```javascript
(function () {
  var c = document.querySelector(".gh-content"); if (!c) return;
  var hs = [].slice.call(c.querySelectorAll("h2[id], h3[id]")); if (hs.length < 4) return;
  var nav = document.createElement("nav"); nav.className = "vn-toc";
  var d = document.createElement("details"); d.open = true;
  var s = document.createElement("summary"); s.textContent = "Contents"; d.appendChild(s);
  var ul = document.createElement("ul");
  hs.forEach(function (h) {
    var li = document.createElement("li"); if (h.tagName === "H3") li.className = "is-sub";
    var a = document.createElement("a"); a.href = "#" + h.id; a.textContent = h.textContent.trim();
    li.appendChild(a); ul.appendChild(li);
  });
  d.appendChild(ul); nav.appendChild(d); c.insertBefore(nav, c.firstChild);
})();

```

Style them in your CSS file.

```css
.vn-copy { position: absolute; top: 10px; right: 10px; padding: 5px 12px; font: 600 12px system-ui, sans-serif;
  color: #c9d1d9; background: rgba(110,118,129,.25); border: 1px solid rgba(110,118,129,.4);
  border-radius: 8px; cursor: pointer; opacity: 0; transition: opacity .15s; }
.gh-content pre:hover .vn-copy { opacity: 1; }
@media (hover: none) { .vn-copy { opacity: 1; } }

.vn-toc { background: var(--card); border: 1px solid var(--border); border-left: 3px solid var(--accent);
  border-radius: 12px; padding: 18px 24px; }
.vn-toc ul { list-style: none; margin: 14px 0 0; padding: 0; columns: 2; column-gap: 40px; }
.vn-toc li.is-sub { padding-left: 16px; opacity: .88; }
.vn-toc a { color: #c9d1d9 !important; text-decoration: none !important; }

```

The Share button in the Source theme deserves a mention, because it fails silently in this setup. In Ghost 6 it is just a link to `#/share`, and the sharing dialog is provided by Ghost's members "Portal" script. When member signups are off, Portal is never loaded and the button does nothing. I replaced it with my own small menu (LinkedIn, X, Facebook, email and copy link, using plain links) that attaches to the existing button and opens the phone's native share sheet on mobile.

## Step 7A: starting fresh, write your first content

If you have no existing blog, this step is short, and it is the pleasant one. A new Ghost install contains sample content that you should remove before anyone sees the site.

1. **Remove the sample content.** In Ghost Admin open Posts and delete the default "Coming soon" post. Open Tags and delete the sample tag that came with it.
2. **Rewrite the About page.** Ghost creates a placeholder About page. Replace it with two or three honest paragraphs about who you are and what the blog covers. A blog with a real About page feels trustworthy.
3. **Decide your categories before you write.** Ghost uses tags for topics, and the first tag on a post is its primary tag. Pick a small set of five or six broad topics (mine are Docker, Azure, Kubernetes, PowerShell and AI), create them under Tags, and add them to the navigation. Fewer, broader tags make a tidier site than a different tag on every post.
4. **Write the first post.** Click New post. Add a title, write in the editor (type `/` to insert code blocks, images, tables and other cards), then open the post settings to set the tag, a cover image and a short excerpt. Publish when you are ready, or schedule it.
5. **Use a draft first.** Ghost's preview shows exactly how the post will look on the live theme, including the code highlighting and the table of contents from Step 6.

Three habits make a new blog easier to run later.

- **Write the excerpt only if it differs from your opening paragraph.** The theme prints a custom excerpt under the title, so repeating the first paragraph shows it twice. Leave the field empty and Ghost will generate card summaries on its own.
- **Give every post a cover image.** Cards with pictures are far more inviting, and social networks need an image for link previews. Step 8 shows how to produce consistent covers cheaply.
- **Keep screenshots in the post, not on another service.** Images you upload through the editor are stored in `content/images`, which Step 9 backs up.

Starting from scratch you can also write posts in markdown files on your own computer and publish them with the API, using the same technique as the migration script in Step 7B. That is a good fit if you like writing in an editor such as VS Code and keeping drafts in version control.

## Step 7B: migrating, bring your existing content across

This is the part that took the most care when I moved my own site. The goal was to keep every article, its original date, its category, its images and its old web address.

Ghost has built in importers for the common sources, so check whether one of them already covers you before writing any code.

| Coming from                            | Easiest route                                                                                                                                                                                                                         |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| WordPress                              | Install Ghost's official WordPress exporter plugin, download the JSON file it creates, and upload it in Ghost Admin under Settings, Migration tools (Universal import). Images are included if you zip the export together with them. |
| Substack                               | Export your posts from Substack, then use the Substack option under Migration tools.                                                                                                                                                  |
| Medium                                 | Request your Medium export, then use the Medium option under Migration tools.                                                                                                                                                         |
| Another Ghost site                     | Export from Settings, Migration tools on the old site, and import the same file on the new one.                                                                                                                                       |
| A wiki or any folder of markdown files | Use the script in this step. This is the case I had.                                                                                                                                                                                  |

The exact menu names move around between Ghost versions, so look under Settings for "Migration tools" or "Import". Whatever route you take, the audit and redirect advice later in this step applies to you too. Everything below uses my Wikidocs migration as the worked example.

### How Wikidocs stores pages

Wikidocs keeps everything as files. Each page is `datasets/documents/<category>/<slug>/content.md`, with its screenshots saved beside it. Images are referenced in the markdown as `{{DOC_PATH}}file.png`, which Wikidocs replaces at display time. Each page begins with a title heading and `Author` and `Date` lines, and may include a `[toc]` marker. Those few conventions are what the migration script has to translate.

### Copy the screenshots

Ghost serves anything under its content folder. Copy each article's images into a folder named after the article.

```bash
SRC=/path/to/wikidocs/datasets/documents
DEST=/srv/docker/ghost/content/images/wiki
for dir in "$SRC"/*/*/; do
  slug=$(basename "$dir")
  sudo mkdir -p "$DEST/$slug"
  sudo cp "$dir"*.png "$dir"*.jpg "$DEST/$slug/" 2>/dev/null
done
sudo chown -R 1000:1000 "$DEST"

```

### Create the API key

In Ghost Admin open Settings, Integrations, Add custom integration, give it a name, and copy the Admin API key. It looks like `24hexcharacters:64hexcharacters`. Export it as an environment variable only for the session.

```bash
export GHOST_URL="https://blog.example.com"
export GHOST_ADMIN_KEY="paste-the-key-here"

```

The key signs a short lived token (a JSON Web Token) for every request. The script below does this using only the Python standard library.

### The migration script

I tested this script against a live Ghost instance before publishing it here. It converted a real article, created a draft with the right tag and date, skipped it correctly on a second run, and was cleaned up afterwards.

```python
#!/usr/bin/env python3
"""Migrate Wikidocs-style markdown articles into Ghost through the Admin API (standard library only)."""
import argparse, base64, hashlib, hmac, json, os, re, sys, time, urllib.error, urllib.request
from datetime import datetime
from pathlib import Path

ACRONYMS = {"ai": "AI", "powershell": "PowerShell", "vmware": "VMware"}
SKIP_DIRS = {"homepage", "versions"}

def b64url(raw: bytes) -> str:
    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()

def make_token(admin_key: str) -> str:
    key_id, secret = admin_key.split(":")
    now = int(time.time())
    head = b64url(json.dumps({"alg": "HS256", "typ": "JWT", "kid": key_id}).encode())
    body = b64url(json.dumps({"iat": now, "exp": now + 300, "aud": "/admin/"}).encode())
    sig = b64url(hmac.new(bytes.fromhex(secret), f"{head}.{body}".encode(), hashlib.sha256).digest())
    return f"{head}.{body}.{sig}"

def api(method, path, data=None):
    url = os.environ["GHOST_URL"].rstrip("/") + "/ghost/api/admin" + path
    headers = {"Authorization": "Ghost " + make_token(os.environ["GHOST_ADMIN_KEY"]),
               "Content-Type": "application/json", "Accept-Version": "v5.0",
               "User-Agent": "wiki-to-ghost-migration/1.0"}   # Cloudflare blocks the default Python user agent
    req = urllib.request.Request(url, method=method, headers=headers,
                                 data=None if data is None else json.dumps(data).encode())
    try:
        with urllib.request.urlopen(req, timeout=60) as r:
            return r.status, json.loads(r.read() or b"{}")
    except urllib.error.HTTPError as e:
        return e.code, json.loads(e.read() or b"{}")

def convert(md_file: Path, category: str, slug: str) -> dict:
    raw = md_file.read_text(encoding="utf-8")
    title, body = None, []
    for line in raw.replace("\r", "").split("\n"):
        if title is None and re.match(r"^#{1,6}\s+", line):          # first heading is the title
            title = re.sub(r"^#{1,6}\s+", "", line).strip(" *")
            continue
        if re.match(r"^\*\*(Author|Date|Updated):\*\*", line):        # Ghost shows author and date itself
            continue
        if line.strip() == "[toc]":                                   # Wikidocs table of contents marker
            continue
        body.append(line)
    markdown = "\n".join(body).strip().replace("{{DOC_PATH}}", f"/content/images/wiki/{slug}/")
    markdown = re.sub(r"(?<!\*)\*\*([^*\n`]*?\S)[ \t]+\*\*(?!\*)", r"**\1**", markdown)   # "**bold **" -> "**bold**"

    when = datetime.fromtimestamp(md_file.stat().st_mtime)
    m = re.search(r"\*\*Date:\*\*\s*([A-Za-z]+ \d{1,2},\s*\d{4})", raw)
    if m:
        try:
            when = datetime.strptime(m.group(1).replace(",", ""), "%B %d %Y")
        except ValueError:
            pass
    stamp = when.strftime("%Y-%m-%dT09:00:00.000Z")
    lexical = {"root": {"children": [{"type": "markdown", "version": 1, "markdown": markdown}],
                        "direction": None, "format": "", "indent": 0, "type": "root", "version": 1}}
    return {"title": title or slug.replace("-", " ").title(), "slug": slug, "lexical": json.dumps(lexical),
            "published_at": stamp, "created_at": stamp,
            "tags": [{"name": ACRONYMS.get(category, category.title())}]}

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("source", type=Path)
    ap.add_argument("--draft", action="store_true", help="create drafts instead of publishing")
    ap.add_argument("--dry-run", action="store_true", help="convert and report, create nothing")
    args = ap.parse_args()
    found = 0
    for cat_dir in sorted(p for p in args.source.iterdir() if p.is_dir() and p.name not in SKIP_DIRS):
        for art_dir in sorted(p for p in cat_dir.iterdir() if p.is_dir() and p.name not in SKIP_DIRS):
            md_file = art_dir / "content.md"
            if not md_file.is_file():
                continue
            found += 1
            post = convert(md_file, cat_dir.name, art_dir.name)
            post["status"] = "draft" if args.draft else "published"
            if args.dry_run:
                print(f"[dry-run] {cat_dir.name}/{art_dir.name}: '{post['title']}' ({post['published_at'][:10]})")
                continue
            status, _ = api("GET", f"/posts/slug/{art_dir.name}/")
            if status == 200:
                print(f"skip (already exists): {art_dir.name}")
                continue
            status, res = api("POST", "/posts/", {"posts": [post]})
            ok = status == 201
            print(f"{'created' if ok else 'FAILED ' + str(status)}: {art_dir.name}" + ("" if ok else "  " + json.dumps(res)[:200]))
    print(f"{found} article(s) processed")

if __name__ == "__main__":
    sys.exit(main())

```

Run it in three stages. A dry run prints what it would do, a draft run creates unpublished posts you can review in the admin screen, and the final run publishes.

```bash
python3 migrate_wiki_to_ghost.py /path/to/wikidocs/datasets/documents --dry-run
python3 migrate_wiki_to_ghost.py /path/to/wikidocs/datasets/documents --draft
# review the drafts in Ghost Admin, delete them, then publish for real
python3 migrate_wiki_to_ghost.py /path/to/wikidocs/datasets/documents

```

The script stores each article as a single markdown card. That keeps the original text exactly as written, including tables, code blocks and lists, and Ghost converts it to HTML when the page is displayed. If you have a staff login and prefer the interface, Ghost's Settings screen also has a universal import that accepts a JSON file, which can include images in a zip.

### Keep the old web addresses working

This applies to every migration, whatever the source. Every link anyone has already shared would otherwise break, and search engines treat a changed address as a brand new page. Ghost reads redirects from `content/data/redirects.json`. The old wiki used `/<category>/<slug>`, and Ghost uses `/<slug>/`, so two rules cover everything.

```bash
sudo mkdir -p /srv/docker/ghost/content/data
sudo tee /srv/docker/ghost/content/data/redirects.json >/dev/null <<'EOF'
[
  {"from": "^/(azure|docker|kubernetes|powershell|ai)/?$", "to": "/tag/$1/", "permanent": true},
  {"from": "^/(azure|docker|kubernetes|powershell|ai)/([^/]+)/?$", "to": "/$2/", "permanent": true}
]
EOF
sudo chown -R 1000:1000 /srv/docker/ghost/content/data
docker restart ghost

```

Change the category names in the first rule to your own. If your old addresses followed a different pattern, for example WordPress dates in the path, write a rule for that pattern instead. Starting fresh? You can skip this file entirely, and come back to it if you ever change a post's address. Test with `curl -sI https://blog.example.com/docker/some-old-article` and look for a `301` with the new location.

### Audit the result, do not trust it

This applies to any imported content, from any source. After the import I assumed it was perfect. It was not. I pulled the rendered HTML of every post through the API and compared it with the source markdown, line by line, and also compared the visible text against the old wiki's pages. The audit found:

- **Raw asterisks** in two articles. In the original, a line wrapped in an HTML `span` tag contained `*italic*` text, and markdown is not processed inside inline HTML at the start of a line. Rewriting it as an `em` element fixed it. Another heading had a space before its closing `**`, which prevents bold formatting.
- **Duplicated introductions.** My first import set each post's excerpt to its first paragraph. The theme prints the excerpt under the title, so every article started with the same paragraph twice. Clear the excerpt field, and let Ghost generate the card summaries itself.
- **A missing table of contents.** The old pages all had one, and its absence made the articles look as though something had been cut. The script in Step 6 restores it.
- **Everything else matched.** All text, code and headings were present, and every image URL returned HTTP 200\. I checked all of them with a simple loop.

The fastest way to check images is also the most useful.

```bash
for u in $(curl -s https://blog.example.com/some-article/ | grep -o 'src="[^"]*content/images[^"]*"' | cut -d'"' -f2); do
  printf '%s  ' "$(curl -s -o /dev/null -w '%{http_code}' "https://blog.example.com$u")"; echo "$u"
done

```

## Step 8: cover images

This step is for everyone. Migrated articles usually arrive without cover images, and new posts benefit from them too. Cards with a picture are far more inviting than cards with text only, and a social network preview needs a real image. I generate a cover for each article so they all share a design language. The recipe is simple: draw an SVG, then render it to a PNG with the browser you already have.

```svg
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
  <defs>
    <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#0b1017"/><stop offset="1" stop-color="#111a24"/></linearGradient>
    <radialGradient id="glow" cx="0.7" cy="0.4" r="0.55"><stop offset="0" stop-color="#22d3ee" stop-opacity="0.42"/><stop offset="1" stop-color="#22d3ee" stop-opacity="0"/></radialGradient>
  </defs>
  <rect width="1200" height="630" fill="url(#bg)"/><rect width="1200" height="630" fill="url(#glow)"/>
  <rect width="1200" height="6" fill="#22d3ee"/>
  <text x="80" y="222" font-family="Segoe UI, Arial, sans-serif" font-size="28" font-weight="700" letter-spacing="5" fill="#22d3ee">DOCKER</text>
  <text x="80" y="330" font-family="Segoe UI, Arial, sans-serif" font-size="88" font-weight="800" fill="#ffffff">Your Title</text>
  <text x="80" y="392" font-family="Segoe UI, Arial, sans-serif" font-size="30" fill="#9aa7b5">A short caption</text>
</svg>

```

```bash
chrome --headless=new --disable-gpu --hide-scrollbars --force-device-scale-factor=1 \
  --window-size=1200,630 --screenshot=cover.png file:///path/to/cover.svg

```

I used a different simple illustration for each category (stacked containers, a cloud, a Kubernetes wheel, a terminal window and a small neural network), generated from one script, so adding a cover for a new article takes a minute. Upload the PNG files to `content/images/covers/`, then attach each one to its post.

```bash
# update one post's feature image through the Admin API (GHOST_URL and token as before)
curl -s -X PUT "$GHOST_URL/ghost/api/admin/posts/POST_ID/" \
  -H "Authorization: Ghost $TOKEN" -H "Content-Type: application/json" -H "Accept-Version: v5.0" \
  --data '{"posts":[{"feature_image":"https://blog.example.com/content/images/covers/my-article.png","updated_at":"CURRENT_UPDATED_AT"}]}'

```

Ghost requires the current `updated_at` value of the post in every update, so fetch the post first and copy it. Use PNG, not SVG, for covers. LinkedIn and several other platforms will not show an SVG as a preview image.

## Step 9: backups and monitoring

A blog on one VPS needs backups that you have actually proven can be restored. The two things to save are the database and the content folder (themes, images, redirects). This script does both, tests that the archives are readable, keeps two weeks of history and raises an alarm when anything fails.

```bash
sudo tee /usr/local/sbin/ghost-backup.sh >/dev/null <<'EOF'
#!/bin/bash
set -u
DEST=/srv/backups/ghost; KEEP_DAYS=14; TS=$(date +%Y%m%d-%H%M%S); SRC=/srv/docker/ghost
umask 077; mkdir -p "$DEST"
fail() { logger -t ghost-backup -p user.crit "$1"; rm -f "$DEST/ghost-db-$TS.sql.gz" "$DEST/ghost-content-$TS.tar.gz"
         echo "Ghost backup FAILED: $1" | mail -s "Ghost backup FAILED" you@example.com 2>/dev/null; exit 1; }
docker exec ghost-db sh -c 'mysqldump --single-transaction --routines -uroot -p"$MYSQL_ROOT_PASSWORD" ghost 2>/dev/null' \
  | gzip > "$DEST/ghost-db-$TS.sql.gz" || fail "mysqldump"
gzip -t "$DEST/ghost-db-$TS.sql.gz" || fail "database dump is corrupt"
[ "$(stat -c %s "$DEST/ghost-db-$TS.sql.gz")" -gt 20000 ] || fail "database dump is suspiciously small"
tar -czf "$DEST/ghost-content-$TS.tar.gz" -C "$SRC" content compose.yaml nginx || fail "content tarball"
tar -tzf "$DEST/ghost-content-$TS.tar.gz" >/dev/null || fail "content tarball is corrupt"
find "$DEST" -name 'ghost-*' -mtime +$KEEP_DAYS -delete
touch /var/lib/ghost-backup-last-success
EOF
sudo chmod 750 /usr/local/sbin/ghost-backup.sh

```

Run it daily with a systemd timer.

```bash
sudo tee /etc/systemd/system/ghost-backup.service >/dev/null <<'EOF'
[Unit]
Description=Ghost backup (MySQL dump + content)
After=docker.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/ghost-backup.sh
EOF
sudo tee /etc/systemd/system/ghost-backup.timer >/dev/null <<'EOF'
[Unit]
Description=Ghost backup, daily
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
RandomizedDelaySec=10min
[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload && sudo systemctl enable --now ghost-backup.timer
sudo systemctl start ghost-backup.service && ls -la /srv/backups/ghost

```

The backup folder shares the disk with the server, so copy a recent set somewhere else as well, such as your own computer or object storage. A backup on the same disk does not survive a lost server. Treat the copies as sensitive, because the database dump contains staff password hashes.

To restore, create the stack, then load the dump and unpack the content.

```bash
zcat ghost-db-TIMESTAMP.sql.gz | docker exec -i ghost-db sh -c 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" ghost'
sudo tar -xzf ghost-content-TIMESTAMP.tar.gz -C /srv/docker/ghost && sudo chown -R 1000:1000 /srv/docker/ghost/content
docker restart ghost

```

For monitoring, check the real public address, not the container, so that a failure anywhere along the path (Cloudflare, Pangolin, Traefik, nginx or Ghost) is noticed. A tiny timer that runs `curl -fsS -o /dev/null https://blog.example.com/` every five minutes and alerts on failure is enough. An outside uptime service such as UptimeRobot or Healthchecks.io is a good second opinion, because it will notice if the whole server is down.

## Step 10: search engines and analytics

Ghost already outputs a sitemap at `/sitemap.xml`, a sensible `robots.txt`, canonical links, Open Graph tags for social previews, structured data and an RSS feed at `/rss/`. You do not need an SEO plugin.

- Add your site to Google Search Console (a DNS TXT record verifies ownership) and submit the sitemap.
- Use the URL inspection tool to request indexing of your first few articles.
- Share a link on a social network to see the preview card, which is where your cover images pay off.

For visitor numbers, Cloudflare Web Analytics is a free switch in the dashboard that works without adding any script to your pages. Ghost 6 has built in analytics too, but it relies on an external service, so I left it off.

## The security checklist

| Item                                                                                  | Why it matters                                                                  |
| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| Containers drop all capabilities, run as normal users, with memory and process limits | Limits the damage if a container is compromised                                 |
| Images pinned by digest                                                               | Updates are a decision, not a surprise                                          |
| MySQL has no published port                                                           | The database is reachable only from Ghost                                       |
| Secrets in a root only .env file                                                      | Not in compose files, notes or screenshots                                      |
| Admin area restricted to your IP, trusted only via Cloudflare                         | Blocks password guessing and exploits against the login                         |
| Security headers from the proxy                                                       | Frame protection, referrer control and a restrictive content policy for framing |
| Rate limits on email sending endpoints                                                | A signup form cannot be abused to flood mailboxes or burn your reputation       |
| Member signups and comments off when unused                                           | Less surface, less spam, no mail obligations                                    |
| Third party code reviewed and self hosted                                             | No visitor depends on someone else's server or script                           |
| Daily tested backups, copied off the server                                           | A failure is an inconvenience instead of a disaster                             |
| Public address monitored end to end                                                   | You find out before your readers do                                             |

## Mistakes worth avoiding

I kept a list of things that went wrong, because it is the most valuable part of any guide.

1. **Ghost crash looped as root with all capabilities dropped.** Run it as the application user and pre own the data folder.
2. **A Pangolin resource that pointed at the wrong address returned 504 errors.** Use the container address on Pangolin's network and the container's internal port.
3. **A single file bind mount kept an old nginx configuration alive.** Mount the folder, and reload nginx after changes.
4. **An invalid nginx `if` took the site down for two minutes.** Test every configuration in a throwaway container first.
5. **Cloudflare kept serving an old stylesheet.** Version your static files and bump the number whenever they change.
6. **The theme's rem scale made all my text tiny.** Source uses a 10 pixel root size.
7. **Overriding the theme's container width pushed articles to the left.** Change only the content width.
8. **The Admin API refused the bulk import and settings changes.** Create posts one by one through the API, or use a staff login for the import screen.
9. **Every article showed its first paragraph twice.** Clear the excerpt field on imported posts.
10. **The Share button did nothing.** It depends on a members script that is not loaded when signups are off.
11. **An import that looked perfect had raw asterisks and no table of contents.** Always audit with a comparison, not just by eye.

## Keeping it healthy

- **Ghost and MySQL.** Every month or so, read the release notes, pull the new images, change the digests, run `docker compose up -d`, then check the site and the admin screen. Take a manual backup first.
- **The host.** Keep the operating system patched, and automate it if you can. I run a weekly job that applies updates and emails me the result.
- **Cloudflare address list.** The list in the proxy only needs refreshing if Cloudflare publishes a change, which is rare. Regenerate it with the command in Step 3 and reload nginx.
- **Your home address.** If your internet provider changes your IP address, update the single line in the proxy configuration, or you will lock yourself out of the admin area. Keep SSH access to the server so that you can always fix it.
- **Restore test.** Once or twice a year, restore a backup onto a spare machine to prove that it works.

## Closing thoughts

Whether you are building a blog from nothing or moving an existing one, the platform is the same and the path is the same. The only fork is Step 7, where you either write your first post or bring your old ones across. Moving my own documentation wiki to Ghost took a few evenings, and most of the time was spent on small details rather than the installation itself. The installation really is three containers. The effort went into the things that make the difference between a demo and something you would leave running unattended: the proxy that protects the admin area, backups you have actually tested, an audit of the migrated content, and a theme that reads well.

Self hosting is not the right answer for everyone. If you only want to write, a managed service is quicker and carries less risk. But if you enjoy understanding the whole path from the reader's browser to the database, and you already run a VPS, a self hosted Ghost blog is a rewarding project. It is fast, it looks modern, it finds its way into search results, and every part of it is yours.