Ghost/Setup

Ship It 3/3: Self-Hosting Ghost on a Small VPS

Ship It, part three: the build was reproducible and the release script was solid. All that was left was an actual server to put it on.

This closes out the series: part one made the build reproducible, part two turned the release checklist into a script, and this part is where all of that actually lands somewhere real — a small VPS running Ghost behind a reverse proxy with automatic TLS.

Why Self-Host at All

Managed Ghost hosting is a completely reasonable choice for most people, and I don't think self-hosting is obviously better. I did it here because I wanted full control over the theme deployment pipeline and the ability to run custom routes and templates without waiting on a managed platform's release cycle — a trade-off that makes sense for a project where the infrastructure itself is part of what I'm writing about.

The Compose Setup

Everything runs through Docker Compose — Ghost, its MySQL database, and a reverse proxy that handles TLS termination:

services:
  ghost:
    image: ghost:5-alpine
    restart: unless-stopped
    environment:
      database__client: mysql
      database__connection__host: db
      database__connection__user: ghost
      database__connection__password: ${DB_PASSWORD}
      database__connection__database: ghost
      url: https://blog.example.com
    volumes:
      - ghost_content:/var/lib/ghost/content
    depends_on:
      - db

  db:
    image: mysql:8.0
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MYSQL_DATABASE: ghost
      MYSQL_USER: ghost
      MYSQL_PASSWORD: ${DB_PASSWORD}
    volumes:
      - db_data:/var/lib/mysql

  proxy:
    image: caddy:2-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data

volumes:
  ghost_content:
  db_data:
  caddy_data:

Caddy over nginx here was a deliberate simplicity choice — automatic TLS certificate provisioning and renewal via Let's Encrypt with a three-line config, versus hand-managing certbot renewal separately. For a single small site, the operational simplicity mattered more than the extra configurability nginx would have given me.

# Caddyfile
blog.example.com {
    reverse_proxy ghost:2368
}

Deploying With What the Series Already Built

Getting a new version onto the box reuses the exact release artifact from part two's release script and the multi-stage image for the theme's build tooling — the deploy step itself is deliberately boring:

ssh [email protected] <<'EOF'
  cd /srv/ghost
  docker compose pull
  docker compose up -d
  docker image prune -f
EOF

No custom deploy tooling, no orchestration platform — just SSH, docker compose pull, and a restart. For a site at this traffic level, anything more elaborate would be solving a problem I don't actually have.

Backups, the Part That's Easy to Skip

The content volume and the database are the only state that actually matters here, so backups focus on exactly those two things, run nightly via cron on the host:

#!/usr/bin/env bash
set -euo pipefail
docker compose exec -T db mysqldump -u ghost -p"$DB_PASSWORD" ghost | gzip > "/backups/ghost-db-$(date +%F).sql.gz"
tar czf "/backups/ghost-content-$(date +%F).tar.gz" -C /var/lib/docker/volumes/ghost_ghost_content/_data .
find /backups -mtime +14 -delete

I test the restore path, not just the backup step, roughly once a quarter against a throwaway VPS — a backup that's never been restored is a hope, not a backup.

Closing the Loop

That's the whole arc: a build that produces the same output from the same input every time, a release script that can't skip a step, and a small, boring server to actually run it on. None of the three pieces individually is sophisticated, and that was the point — the goal was removing the ways this could go wrong at 6pm on a Friday, not building something impressive.