Introduction

This setup note was updated on 2023.11.09.

Ghost has already moved into the 5.x major release line. After spending nearly three years using WordPress, I eventually gave it up for a mix of reasons. Ghost still feels like the blogging platform I prefer most. My early local testing was solid enough that it seemed worth documenting the installation process.

At the time, Ghost still did not handle WordPress migration especially well, so instead of trying to move old posts over directly, I treated the site as a fresh start.

Preparing the Node.js environment

The server used here is a low-spec Tencent Cloud instance:

  • 1 CPU / 1G RAM / 1Mbps / 40G ROM
  • Ubuntu 20.04

Ghost runs on Node.js. Its official installer, Ghost-CLI, makes deployment much easier than it used to be.

Ghost does not allow installation as root, so the first step is to create a normal user:

adduser <user> # ghost不允许root用户安装,所以需要新建个<user>用户
usermod -aG sudo <user> # 给予<user>用户为超级权限
su - <user> # 登录用户
sudo apt update
sudo apt upgrade # 更新软件

The <user> name can be anything you want, such as jaxson or ubuntu. Just do not use ghost, because that conflicts with ghost-cli.

A later update simplified Node.js installation quite a bit. Instead of using a version manager, installing directly from NodeSource is enough:

# 添加 Nodejs 14 源
curl -sL https://deb.nodesource.com/setup_14.x | sudo -E bash

# 安装 Node.js
sudo apt-get install -y nodejs # 大陆服务器暂时别执行这一步,看看下面的内容

On servers in mainland China, package downloads may be slow. If that happens, switching the Node.js source helps.

sudo cp /etc/apt/sources.list.d/nodesource.list /etc/apt/sources.list.d/nodesource.list.bak # 备份
sudo vim /etc/apt/sources.list.d/nodesource.list

Replace the contents with the following, paying attention to the Ubuntu codename focal:

deb https://mirrors.ustc.edu.cn/nodesource/deb/node_14.x focal main
deb-src https://mirrors.ustc.edu.cn/nodesource/deb/node_14.x focal main

Then continue with the installation:

sudo apt update
sudo apt-get install -y nodejs # 安装 Node.js

If the machine is in mainland China, it also helps to point npm and yarn to domestic mirrors:

npm config set registry https://r.npm.taobao.org # npm 镜像替换为淘宝npm节点
npm config set disturl https://npm.taobao.org/dist # node-gyp 编译依赖的 node 源码镜像
npm config set node_sqlite3_binary_host_mirror https://npm.taobao.org/mirrors # sqlite3 镜像
npm config set sqlite3_binary_site https://npm.taobao.org/mirrors/sqlite3/
npm config set sharp_binary_host https://npmmirror.com/mirrors/sharp
npm config set sharp_libvips_binary_host https://npmmirror.com/mirrors/sharp-libvips

sudo npm install -g npm # 更新 npm

# ghost-cli 使用 yarn 包管理器,所以进行源配置
sudo npm install -g yarn
yarn config set registry https://r.npm.taobao.org # yarn 镜像替换为淘宝npm节点
yarn config set disturl https://npm.taobao.org/dist # node-gyp 编译依赖的 node 源码镜像
yarn config set node_sqlite3_binary_host_mirror https://npm.taobao.org/mirrors # sqlite3 镜像
yarn config set sharp_binary_host https://npmmirror.com/mirrors/sharp
yarn config set sharp_libvips_binary_host https://npmmirror.com/mirrors/sharp-libvips
yarn config set sqlite3_binary_site https://npm.taobao.org/mirrors/sqlite3/ # sqlite3 镜像

Installing Nginx

If you want SSL support, Ghost requires NGINX 1.9.5 or newer. The simplest route is installing it from the package manager:

sudo apt-get install nginx
nginx -v # 输出版本号

If ufw is enabled, allow HTTP and HTTPS traffic:

sudo ufw allow 'Nginx Full'

Installing the database

Ghost recommends MySQL:

sudo apt install mysql-server

On Ubuntu 18.04, you may need to set a password manually so MySQL works correctly with Ghost-CLI:

# 进入数据库管理
sudo mysql

# 执行下列语句进行修改数据库密码
ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '这是你要替换的密码';

# 退出数据库管理
quit;

# 退出mysql用户并且登录用户账户
su - <user>

MariaDB is not a good choice here because support has been dropped. MySQL is the safer option. If you use MySQL 8.x and the server is tight on memory, you may need to address memory pressure separately.

Installing Ghost-CLI and Ghost itself

Install the official CLI first:

sudo npm install ghost-cli@latest -g # 安装Ghost脚手架

Create the site directory and assign permissions:

# 创建ghost文件夹的网站目录,这个名字可以自己随便定义
sudo mkdir -p /var/www/ghost

# 使文件夹拥有权限,<user>是当前用户名
# 例如当前用户名是ubuntu,那么应该是:sudo chown ubuntu:ubuntu /var/www/ghost
sudo chown <user>:<user> /var/www/ghost

# 设置读写权限
sudo chmod 775 /var/www/ghost

# 定位
cd /var/www/ghost

# 进行Ghost博客平台安装
ghost install

How long the installation takes depends largely on the machine. During setup, Ghost-CLI will ask a series of questions:

  • Enter your blog URL: the site URL, in the form http(s):(www.)example.com. IP addresses are not supported.
  • Enter your MySQL hostname: database host; press Enter for the default localhost.
  • Enter your MySQL username: / Enter your MySQL password: the database account credentials. If you followed the steps above, the username is root and the password is the one you set.
  • Enter your Ghost database name: the database name for Ghost.
  • Do you wish to set up "ghost" mysql user? recommended as y; this creates a dedicated database user instead of using a high-privilege account.
  • Do you wish to set up Nginx? recommended as y.
  • Do you wish to set up SSL? recommended as y. If certificate setup fails, see the SSL troubleshooting section below.
  • Enter your email (For SSL Certificate): the email address used for SSL certificate provisioning.
  • Do you wish to set up Systemd? recommended as y for process management.
  • Do you want to start Ghost? if you choose yes, the site starts immediately and can be visited once setup completes.

What to do when installation hangs

Ghost installation can sometimes stall. One common workaround is to stop, clean up, and run the install again:

ghost uninstall # 卸载原有 ghost 程序
sudo rm -rf /var/www/ghost/*
npm cache clean --force # 清空缓存
ghost install -V # -V 是把所有的安装日志打印到控制台,方便查看安装日志 不过大量的日志输出可能导致安装失败,所以在特殊情况下使用。

A particularly common freeze point during install or upgrade is:

node-pre-gyp WARN Using request for node-pre-gyp https download

If it gets stuck there, it usually means the sqlite3 binary download is hanging. Reconfigure the mirrors and try again:

npm config set registry https://r.npm.taobao.org # npm镜像替换为淘宝npm节点
npm config set disturl https://npm.taobao.org/dist # node-gyp 编译依赖的 node 源码镜像
npm config set node_sqlite3_binary_host_mirror https://npm.taobao.org/mirrors # sqlite3 镜像
ghost install -V # 重新安装
ghost upgrade -V # 升级博客

If that still does not solve it, another option is tbify:

npm install -g tbify
tbify ghost install

Image storage and optimization

For image hosting, a Qiniu storage adapter can be used. A custom plugin was built after older community plugins started breaking on newer Ghost versions.

Create a storage directory under /var/www/ghost/content/adapters and install the adapter:

cd /var/www/ghost/content/adapters/storage # 定位
git clone https://github.com/JaxsonWang/Ghost-QiNiu-Store.git qn-store # 拉取源码
cd qn-store # 定位
npm install # 安装模块依赖

Then edit /var/www/ghost/config.production.json and add:

{
  // ...
  "storage": {
    "active": "qn-store",
    "qn-store": {
      "accessKey": "your access key", // https://portal.qiniu.com/user/key获取AK密匙
      "secretKey": "your secret key", // https://portal.qiniu.com/user/key获取SK密匙
      "bucket": "your bucket name", // 存储对象空间名字
      "domain": "http://xx.xx.xx.glb.clouddn.com", // 七牛CDN地址
      "format": "${year}/${month}/${name}${ext}"
    }
  }
  // ...
}

Restart Ghost afterward:

cd /var/www/ghost
ghost restart

Fixing SSL issues with apex and www domains

A common problem appears when the site works on the apex domain, such as https://iiong.com, but fails on www.iiong.com.

A workable fix is:

  • Reconfigure Ghost temporarily: ghost config url https://www.mydomain.com
  • Run ghost setup nginx ssl
  • Change the URL back: ghost config url https://mydomain.com
  • Edit the two www.*.conf files under /var/www/ghost/system/files/ and add a 301 redirect to the apex domain:
  if ($ssl_protocol = "") {
      return 301 https://$host$request_uri;
  }
  if ($host != iiong.com) {
      return 301 $scheme://iiong.com$request_uri; #请注意这里的iiong.com替换你的域名。
  }
  • Reload Nginx:
sudo nginx -s reload

If /var/www/ghost/system/files/ does not exist, check the Nginx site files under /etc/nginx/sites-available instead.

Renewing SSL certificates manually

Ghost uses Let's Encrypt tooling under this path:

/etc/letsencrypt/acme.sh --home "/etc/letsencrypt"

According to acme.sh, a manual renewal can be triggered with:

/etc/letsencrypt/acme.sh --cron --home "/etc/letsencrypt"

If permission errors show up, use:

sudo /etc/letsencrypt/acme.sh --cron --home "/etc/letsencrypt"

And if it complains that sudo is not recommended, force it:

sudo /etc/letsencrypt/acme.sh --cron --force --home "/etc/letsencrypt"

That should complete the renewal.

Low-memory servers

If the server repeatedly reports memory shortages, enabling swap is usually the next thing to look at. This matters especially on small cloud instances.

Permission problems during upgrade or service operations

This issue tends to appear during ghost upgrade, restart, start, or stop operations. Ghost may report permission errors like these:

+ sudo systemctl is-active ghost_iiong-com
✔ Checking system Node.js version - found v14.21.3
✔ Ensuring user is not logged in as ghost user
✔ Checking if logged in user is directory owner
✔ Checking current folder permissions
✖ Checking folder permissions
✖ Checking file permissions
✖ Checking content folder ownership
✔ Checking memory availability
✔ Checking free space
One or more errors occurred.

1) Checking folder permissions

Message: Command failed: /bin/sh -c find ./ -type d ! -perm 775 ! -perm 755
find: ‘./content/themes/pomelo’: 权限不够

Ghost may suggest running:

sudo find ./ ! -path "./versions/*" -type f -exec chmod 664 {} \;
# 或者
ghost setup linux-user systemd

But those commands may not actually fix it. In practice, the real problem is often ownership in the Ghost root directory.

For example, the current ownership may look like this:

ubuntu@VM-4-8-ubuntu:/var/www/iiong.com$ ls -l
总用量 16
-rw-rw-r--  1 ubuntu ubuntu 1235 Mar 29 20:32 config.production.json
drwxrwxr-x 12 ubuntu  ubuntu  4096 Mar 24 17:11 content
lrwxrwxrwx  1 ubuntu ubuntu   34 Mar 29 20:32 current -> /var/www/iiong.com/versions/5.40.2
drwxrwxr-x  3 ubuntu  ubuntu  4096 Mar 28 21:46 system
drwxrwxr-x  5 ubuntu  ubuntu  4096 Mar 29 20:20 versions

But Ghost expects something closer to this default layout:

ubuntu@VM-4-8-ubuntu:/var/www/iiong.com$ ls -l
总用量 16
-rw-rw-r--  1 ubuntu ubuntu 1235 Mar 29 20:32 config.production.json
drwxrwxr-x 12 ghost  ghost  4096 Mar 24 17:11 content
lrwxrwxrwx  1 ubuntu ubuntu   34 Mar 29 20:32 current -> /var/www/iiong.com/versions/5.40.2
drwxrwxr-x  3 ghost  ghost  4096 Mar 28 21:46 system
drwxrwxr-x  5 ghost  ghost  4096 Mar 29 20:20 versions

Changing ownership back resolves it:

sudo chown ghost:ghost ./content
sudo chown ghost:ghost ./system
sudo chown ubuntu:ubuntu ./versions

Installing Ghost with Docker

Later, during a site migration, using Docker turned out to be more convenient. A simple Docker Compose setup can run both Ghost and MySQL.

Create a compose file like this:

services:

  ghost:
    image: ghost:latest
    container_name: ghost
    restart: unless-stopped
    ports:
      - 2368:2368
    environment:
      url: https://iiong.com
      # see https://ghost.org/docs/config/#configuration-options
      database__client: mysql
      database__connection__host: ghost-db
      database__connection__user: root
      database__connection__password: "Your Password"
      database__connection__database: ghost
      # contrary to the default mentioned in the linked documentation, this image defaults to NODE_ENV=production (so development mode needs to be explicitly specified if desired)
      # NODE_ENV: development
    volumes:
      - ./ghost/content:/var/lib/ghost/content
      - ./config.production.json:/var/lib/ghost/config.production.json

  ghost-db:
    image: mysql:8.0
    container_name: ghost-db
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: "Your Password"
    volumes:
      - ./mysql:/var/lib/mysql

In the same directory, create config.production.json:

{
  "url": "https://iiong.com",
  "admin": {
    "url": "https://iiong.com"
  },
  "server": {
    "host": "0.0.0.0",
    "port": 2368
  },
  "database": {
    "client": "mysql",
    "connection": {
      "host": "ghost-db",
      "user": "root",
      "password": "Your Password",
      "database": "ghost",
      "charset": "utf8"
    }
  },
  "mail": {
    "transport": "SMTP",
    "options": {
      "service": "QQ",
      "host": "smtp.qq.com",
      "port": 465,
      "auth": {
        "user": "[email protected]",
        "pass": "you-qq-password"
      }
    }
  },
  "storage": {
    "active": "qn-store",
    "qn-store": {
      "accessKey": "Your AccessKey",
      "secretKey": "Your secretKey",
      "bucket": "Your Bucket",
      "domain": "Your CDN Domain",
      "format": "${year}/${month}/${name}${ext}"
    }
  },
  "portal": {
    "url": false
  },
  "sodoSearch": {
    "url": "/assets/sodo-search.min.js",
    "styles": "/assets/sodo-search.min.css",
    "version": "1.0.1"
  },
  "comments": {
    "url": false
  },
  "gravatar": {
    "url": "https://cravatar.cn/avatar/{hash}?s={size}&r={rating}&d={_default}"
  },
  "logging": {
    "transports": [
      "file",
      "stdout"
    ]
  },
  "process": "systemd",
  "paths": {
    "contentPath": "/var/lib/ghost/content"
  }
}

Adjust the values to match your own environment, then start it with:

docker compose up -d

That is enough to get a Docker-based Ghost instance running.