Ghost

Ghost is an open source publishing platform for blogs, newsletters and paid memberships

Updated

Ghost is a Node.js application that replaces WordPress, Substack and Medium for independent publishers. It requires MySQL 8 in production. Upstream's supported install method is Ghost-CLI, which downloads each release from npm rather than cloning the Git repository. On Hatchbox you deploy a small Git repository of your own with a build script that does the same download, so you keep Hatchbox's deploys and rollbacks without committing Ghost's source.

Requirements

Create a MySQL database on the app's Databases tab. Ghost reads its database settings as separate values, so copy the host, port, user, password and database name out of the DATABASE_URL Hatchbox shows you into the variables below.

Repository

Create a new Git repository with these three files, push it, and set it as the app's Git URL with the main branch.

.nvmrc pins Node to the 22 line Ghost supports:

22

package.json tells Hatchbox this is a Node app. It stays minimal because the build script replaces it with Ghost's own:

{
"name": "my-ghost",
"private": true
}

The third file is the build script below.

Environment Variables

NODE_ENV=production
url=https://blog.example.com
database__client=mysql
database__connection__host=10.0.0.5
database__connection__port=3306
database__connection__user=ghost
database__connection__password=your-password
database__connection__database=ghost

Ghost reads any config key from the environment with __ separating the levels, so these map to database.connection.host and so on in config.production.json. url must be the full public URL with its scheme. Hatchbox exposes the MySQL database as DATABASE_URL; split it into the five database__connection__* values.

To send email, which Ghost needs for staff invites, member signups and newsletters, add its SMTP settings the same way, for example mail__transport=SMTP, mail__options__host, mail__options__port, mail__options__auth__user and mail__options__auth__pass.

Build Scripts

.hatchbox/build

#!/usr/bin/env bash
set -e

GHOST_VERSION="6.67.0"

# Download the release Ghost publishes to npm, the same tarball Ghost-CLI installs
TMP_DIR="$(mktemp -d)"
(cd "$TMP_DIR" && npm pack "ghost@${GHOST_VERSION}")
tar -xzf "$TMP_DIR"/ghost-*.tgz --strip-components=1 -C .
rm -rf "$TMP_DIR"

# Install Ghost's dependencies with the pnpm version it pins in package.json
NODE_ENV=production COREPACK_ENABLE_DOWNLOAD_PROMPT=0 COREPACK_DEFAULT_TO_LATEST=0 \
pnpm install --prod --reporter=append-only

# Keep the content directory outside the release so it persists across deploys
SHARED_CONTENT="$DIR/shared/content"
mkdir -p "$SHARED_CONTENT"/{adapters,apps,data,files,images,logs,media,public,settings,themes}
cp -Rn content/. "$SHARED_CONTENT/"
cp -R content/themes/casper content/themes/source "$SHARED_CONTENT/themes/"
rm -rf content
ln -s "$SHARED_CONTENT" content

The script replaces Hatchbox's automatic build. It extracts the Ghost package over the release directory so the release becomes a normal Ghost install, installs production dependencies with pnpm through Corepack, and symlinks content to shared/content. The bundled Casper and Source themes are refreshed on every deploy; everything else in content is only seeded the first time.

To upgrade Ghost, change GHOST_VERSION and deploy. Ghost runs its database migrations when it starts.

Processes

Add one process for the web servers:

server__port=$PORT node index.js

Ghost listens on server.port, so the command passes Hatchbox's PORT through the matching environment variable. It binds to 127.0.0.1 by default, which is where Caddy proxies to.

First Login

Open https://blog.example.com/ghost after the first deploy. Ghost shows its setup screen, where you create the owner account. There are no default credentials.

Notes

Uploads, themes, routes, redirects and logs live in content, which lives on the server's disk under shared/content, so run Ghost on a single web server or move uploads to S3 with a storage adapter.

Ghost writes its production logs to content/logs by default. Set logging__transports=["stdout"] to send them to Hatchbox's logs instead.