Self-Host Ghost CMS on a VPS with Docker

Devops & Infrastructure, Node, Open Source, Tutorials, and VPS

Self-Host Ghost CMS on a VPS with Docker

Ghost is the open-source publishing platform behind a meaningful slice of the independent web — newsletters, magazines, paid memberships, and personal blogs that have outgrown WordPress or want to escape Substack. It runs on Node.js with MySQL, ships a clean editor and built-in newsletter, and has a Content API that makes it work as a full headless CMS too.

This guide takes you from a fresh VPS to a Ghost instance running at blog.yourdomain.com with TLS, Postgres-equivalent MySQL persistence, automated backups, and continuous deployment via DeployHQ. We use Docker rather than the ghost-cli native install — Ghost's own quickstart points to Docker first now, and the Docker path lines up cleanly with the rest of our self-hosting cluster (see self-host Vaultwarden, Immich, and Paperclip).

Why Docker for Ghost

Ghost's officially supported install paths are ghost-cli and Docker. The CLI path bundles Node.js, MySQL, Nginx, and systemd unit setup into a single command — convenient until you try to upgrade Node, swap MySQL versions, or move to a new VPS. Docker gives you:

  • Reproducible builds. The image you tested in staging is the image you run in production, byte-for-byte.
  • Clean upgrades. Bump the image tag in docker-compose.yml, run docker compose pull && up -d, done. No apt, no Node version dance.
  • Sane backups. The MySQL volume and the Ghost content volume are the only state. Snapshot them, you have a restore.
  • Cluster fit. If you already run other Docker services on the VPS (Vaultwarden, Immich, n8n), Ghost slots in next to them with no version conflicts.

The trade-off is one extra concept to learn (see our Docker fundamentals guide), but if you are running a VPS in 2026, you almost certainly want that anyway.

Prerequisites

  • A VPS with at least 1 vCPU and 2 GB RAM (Ghost's documented minimum). For a paid newsletter with thousands of subscribers, plan for 2 vCPU and 4 GB.
  • A domain or subdomain with DNS pointing to the VPS.
  • An SMTP service (Mailgun is Ghost's recommended provider for transactional email and newsletter sending; Postmark and SES work too).
  • Docker Engine and the Compose plugin on the VPS.
  • A reverse proxy for TLS — we use Caddy. Concepts in our reverse proxy 101.

Install Docker on a fresh Ubuntu VPS:

curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER

Log out and back in for the group change to apply.

The Compose stack

Create /opt/ghost/docker-compose.yml:

services:
  ghost:
    image: ghost:5-alpine
    container_name: ghost
    restart: unless-stopped
    ports:
      - "127.0.0.1:2368:2368"
    environment:
      database__client: mysql
      database__connection__host: db
      database__connection__user: ghost
      database__connection__password: ${DB_PASSWORD}
      database__connection__database: ghost
      url: https://blog.${DOMAIN_BASE}
      NODE_ENV: production
      mail__transport: SMTP
      mail__options__host: smtp.mailgun.org
      mail__options__port: 587
      mail__options__secure: "false"
      mail__options__auth__user: ${MAIL_USER}
      mail__options__auth__pass: ${MAIL_PASS}
      mail__from: "'Your Blog' <hello@${DOMAIN_BASE}>"
    volumes:
      - ghost-content:/var/lib/ghost/content
    depends_on:
      db:
        condition: service_healthy

  db:
    image: mysql:8.0
    container_name: ghost-db
    restart: unless-stopped
    command: --default-authentication-plugin=caching_sha2_password
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MYSQL_USER: ghost
      MYSQL_DATABASE: ghost
      MYSQL_PASSWORD: ${DB_PASSWORD}
    volumes:
      - mysql-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 5s
      timeout: 5s
      retries: 10

volumes:
  ghost-content:
  mysql-data:

Three details that matter.

First, port 2368 binds to 127.0.0.1. Without that prefix, Docker exposes Ghost directly on the public internet, bypassing your ufw rules. Caddy on the same host proxies inbound HTTPS to it.

Second, the image is pinned to a major-version tag (ghost:5-alpine), not :latest. Ghost ships a major version every couple of years (Ghost 6 is on the way) with breaking changes; auto-pulling :latest will eventually deploy a major version your data isn't ready for. Pin and upgrade deliberately.

Third, MySQL 8 is the only supported database for Ghost in production. Ghost dropped SQLite for production in version 5; MariaDB is not supported. The default-authentication-plugin=caching_sha2_password flag matches what Ghost expects.

Save the secrets to /opt/ghost/.env:

DOMAIN_BASE=yourdomain.com
DB_ROOT_PASSWORD=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 32)
MAIL_USER=postmaster@mg.yourdomain.com
MAIL_PASS=<your mailgun api password>

chmod 600 /opt/ghost/.env. The mail credentials and database passwords belong only to root.

TLS via Caddy

Ghost depends on the url env var matching the public-facing URL. Without correct TLS, Ghost issues incorrect canonical links, RSS URLs, and oEmbed responses — and email sending often fails because Mailgun expects HTTPS.

/etc/caddy/Caddyfile:

blog.yourdomain.com {
    reverse_proxy 127.0.0.1:2368
    encode gzip

    # Ghost's static assets are heavily cached
    @static {
        path /assets/* /content/images/* /public/*
    }
    header @static Cache-Control "public, max-age=31536000, immutable"

    log {
        output file /var/log/caddy/ghost.log
    }
}

sudo systemctl reload caddy. Caddy gets a Let's Encrypt cert on first request and renews automatically.

Start the stack:

cd /opt/ghost
docker compose up -d
docker compose logs -f ghost

Wait for Ghost is running in production... and visit https://blog.yourdomain.com/ghost/. The Ghost setup wizard runs on first visit — create your owner account, set the publication name, and Ghost is live.

Members and newsletter setup

Ghost's killer feature is the built-in members and newsletter system — paid subscriptions, free signups, automated email delivery. Two parts to wire up:

  1. Mail (transactional and bulk). The mail__options__* env vars above handle sign-in links, password resets, and member confirmation. For bulk newsletter delivery, Ghost uses a separate Mailgun integration configured in the admin: Settings → Email newsletter → Mailgun configuration. Paste your Mailgun API key and sending domain. Ghost will batch-send newsletter emails through Mailgun's API rather than SMTP, which scales to 100K+ subscribers.
  2. Members and Stripe. Ghost integrates Stripe directly for paid memberships. Settings → Membership → Connect Stripe. Add your Stripe keys (use restricted keys, not the secret keys). Ghost generates checkout flows, member portals, and gated content rules from this single connection.

If you're migrating from Substack or another platform, Ghost's importer (Settings → Labs → Import) accepts the standard CSV export. Subscriber lists port over cleanly; archived posts need a manual review since formatting varies.

Continuous deployment with DeployHQ

The standard log into the VPS, run `docker compose pull && up -d` works for the first two upgrades, then you forget. The cleaner pattern, matching how we set up Paperclip earlier in this series:

  1. Keep your deploy artifacts in a private repo. docker-compose.yml, Caddyfile, custom theme files (under content/themes/), any compose overrides. Not the Ghost core itself — that lives in the Docker image.
  2. Connect the repo to DeployHQ. Add the VPS as an SSH server.
  3. Configure the deployment to upload the compose and Caddy files, then run: bash docker compose -f /opt/ghost/docker-compose.yml --env-file /opt/ghost/.env pull docker compose -f /opt/ghost/docker-compose.yml --env-file /opt/ghost/.env up -d sudo systemctl reload caddy
  4. Manage secrets via DeployHQ config filesDB_PASSWORD, MAIL_PASS, DOMAIN_BASE injected at deploy time, never committed to Git.
  5. For theme changes, push the theme directory to your repo. DeployHQ's deploy uploads it to /opt/ghost/themes/, then runs an SSH command to copy the files into the running container's /var/lib/ghost/content/themes/<your-theme>/ and trigger a Ghost reload.

To upgrade Ghost (e.g., 5.92 → 5.93): update the image tag in docker-compose.yml, commit, push. Three minutes later the new version is live with the upgrade in DeployHQ's audit log.

For zero-downtime upgrades on a busy site, point a second VPS at the same database, deploy there first, swap DNS — covered in our zero-downtime deployments feature.

Backups: do not skip this

A Ghost installation is two pieces of state: the MySQL database (members, posts, settings) and the content volume (uploaded images, custom themes, the encryption keys for Stripe webhooks). Lose either and you have a problem.

#!/bin/bash
# /usr/local/bin/ghost-backup.sh
set -euo pipefail
BACKUP_DIR=/var/backups/ghost
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
mkdir -p "$BACKUP_DIR"

# MySQL dump
docker exec ghost-db mysqldump -u root -p"${DB_ROOT_PASSWORD}" ghost | \
  gzip > "$BACKUP_DIR/db-$TIMESTAMP.sql.gz"

# Content volume (images, themes, keys)
docker run --rm -v ghost_ghost-content:/data -v "$BACKUP_DIR:/backup" \
  alpine tar czf "/backup/content-$TIMESTAMP.tar.gz" -C /data .

# Retain last 30 days
find "$BACKUP_DIR" -type f -mtime +30 -delete

Schedule it via cron at 0 3 * * *. Then ship the backups off-host — to S3, Backblaze B2, or another VPS — using restic or rclone. A backup that lives only on the server it backs up is not a backup.

Test the restore once a quarter:

# In a clean VPS or local Docker:
gunzip -c db-LATEST.sql.gz | docker exec -i ghost-db mysql -u root -p... ghost
docker run --rm -v ghost_ghost-content:/data -v "$PWD:/backup" \
  alpine tar xzf /backup/content-LATEST.tar.gz -C /data

The first time you discover your backup script silently failed for six months should not be the day a disk dies.

Operational concerns

A few things worth setting up before you forget about the box:

  • Image and asset storage. By default Ghost stores uploaded images in the ghost-content volume. For a busy publication, swap this for an S3-compatible storage adapter — ghost-storage-adapter-s3 works with AWS S3, Backblaze B2, Cloudflare R2, or any other S3-compatible store — so images are served via CDN and survive VPS rebuilds.
  • Newsletter sending limits. Mailgun's free tier sends 5,000 emails/month. A 1,000-member newsletter at one issue per week burns through that in a single month. Plan the upgrade before you grow into it.
  • MySQL backups vs snapshots. A mysqldump is portable and reliable; a volume snapshot is fast and atomic. Use both — mysqldump for off-host restoration, daily provider snapshots for instant rollback after a botched upgrade.
  • Custom themes. Develop locally with Ghost's gscan linter, push to your deploy repo, let DeployHQ ship to production. Editing themes directly on the production server is how custom themes get lost during upgrades.
  • Member portal customisation. Ghost ships portal CSS and copy that's good enough; if you want more control, override via the theme. Don't fork Ghost itself — every upgrade becomes painful.

Migrating from another platform

Coming from elsewhere? Ghost's import paths:

  • Substack: Export CSV → import in Ghost admin. Posts and subscribers transfer; comments and Stripe customers need manual handling (Substack's Stripe is theirs, not yours).
  • WordPress: Use the Ghost WordPress plugin to export. Posts, authors, tags, and images come through; complex custom fields and shortcodes need rework.
  • Medium: Medium's official export is JSON. Use the @tryghost/migrate CLI to transform it for Ghost import.
  • Self-hosted Ghost (older version): Run a ghost backup on the old install, restore the JSON + content directory on the new Docker stack. The version gap matters — migrate from 4.x → 5.x in two steps if needed.

Wrapping up

You now have Ghost running on a VPS with TLS, MySQL persistence, transactional and bulk email, and a backup strategy that survives a disk failure. The continuous deployment pipeline turns Ghost upgrades and theme changes into a git push, with the audit log to prove what shipped when.

If this is one of several self-hosted services on the same VPS, start a free DeployHQ trial and connect the deploy repo. Pricing is on the plans page; the agency plan covers running Ghost plus a stack of other apps across multiple client VPSes.

Questions about Ghost migrations, member portal customisation, or wiring up the deploy pipeline? Email us at support@deployhq.com or ping @deployhq on X.