[Ghost](https://ghost.org) 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](https://www.deployhq.com). 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](https://www.deployhq.com/blog/self-host-vaultwarden-vps-docker-deployhq), [Immich](https://www.deployhq.com/blog/self-host-immich-vps-docker-deployhq), and [Paperclip](https://www.deployhq.com/blog/self-host-paperclip-vps-docker-deployhq)).

## 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](https://www.deployhq.com/blog/what-is-docker)), 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](https://www.deployhq.com/blog/what-is-a-reverse-proxy-nginx-apache-and-caddy-explained).

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](https://www.deployhq.com/blog/self-host-paperclip-vps-docker-deployhq) 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](https://www.deployhq.com).** 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 files](https://www.deployhq.com/support/build-pipelines)** — `DB_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](https://www.deployhq.com/features/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`](https://github.com/colinmeinke/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](https://www.deployhq.com) 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](https://github.com/TryGhost/migrate) 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`](https://github.com/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](https://www.deployhq.com/signup)[DeployHQ](https://www.deployhq.com) trial and connect the deploy repo. Pricing is on the [plans page](https://www.deployhq.com/pricing); the [agency plan](https://www.deployhq.com/for-agencies) 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](mailto:support@deployhq.com) or ping [@deployhq](https://x.com/deployhq) on X.

