5 minute read

Ghost is was my preferred blogging application. It runs this site and I also used it for my wedding website. I ran it on a single Linode running FreeBSD for years. It’s more performant than WordPress, and the editor is more dev friendly as it’s responsive to Markdown. I’d imagine you already know what it is and you’re here because you DuckDuckGo’d how to do this.

Nothing I’m about to show you is officially supported by the Ghost project. In fact, they’d probably ask why you’re even doing this. If something on this blog is incorrect, you can drop me a line, but I provide no warranty here.

Alright, I’m assuming you have a fresh install of FreeBSD 14.0. At the time of writing, Ghost exclusively supports Node 18 and MySQL 8.0. My old v3 version of this post used MariaDB. Don’t do that.

Let’s install the needed packages.

# pkg update
# pkg install mysql80-server mysql80-client node18 npm-node18 nginx py39-certbot py39-certbot-nginx

Next, we want to enable and secure MySQL. The prompts for mysql_secure_installation should be self-explanatory.

# service mysql-server enable
# service mysql-server start
# mysql_secure_installation

Create a database user for Ghost (change my example password, obviously).

# mysql -u root -p
CREATE USER 'ghost'@'localhost' IDENTIFIED BY 'IsThisEnoughBitsOfEntropy?_^';
CREATE DATABASE yourblog_ext_prod;
GRANT ALL privileges ON `yourblog_ext_prod`.* TO 'ghost'@'localhost';

I assume you already pointed your domain to your web server. Let’s enable nginx and get a certificate set up. It might error at the end that it didn’t find a server block for your domain. That’s fine, we’ll add it later. Take note of the file paths to your cert and key, though.

# service nginx enable
# service nginx start
# certbot run --nginx -d yourblog.ext -d www.yourblog.ext

Successfully received certificate.
Certificate is saved at: /usr/local/etc/letsencrypt/live/yourblog.ext/fullchain.pem
Key is saved at: /usr/local/etc/letsencrypt/live/yourblog.ext/privkey.pem
This certificate expires on 2024-03-18.
These files will be updated when the certificate renews.

Install the ghost-cli. We’ll create directories and users next.

# npm install ghost-cli -g

You might have added the ghost user during install, If not, we can run the adduser script.

root@ghost-demo:~ # adduser ghost
Username: ghost
Full name: Spooky Ghost Boooo
Uid (Leave empty for default): 
Login group [ghost]: 
Login group is ghost. Invite ghost into other groups? []: 
Login class [default]: 
Shell (sh csh tcsh nologin) [sh]: 
Home directory [/home/ghost]: 
Home directory permissions (Leave empty for default): 
Use password-based authentication? [yes]: no
Lock out the account after creation? [no]: 
Username : ghost
Password : <disabled>
Full Name : Spooky Ghost Boooo
Uid : 1002
Class : 
Groups : ghost 
Home : /home/ghost
Home Mode : 
Shell : /bin/sh
Locked : no
OK? (yes/no) [yes]: 
adduser: INFO: Successfully added (ghost) to the user database.
Add another user? (yes/no) [no]:   
Goodbye!

I declined to create a password because we’ll just be su’ing from root anyway.

Now let’s prepare the directories. Name after your site.

# mkdir /usr/local/www/yourblog.ext
# chown ghost:ghost /usr/local/www/yourblog.ext
# chmod 775 /usr/local/www/yourblog.ext
# su - ghost
$ cd /usr/local/www/yourblog.ext

Fire away!

$ ghost install

Answer the following questions accordingly. Note: use 127.0.0.1 instead of localhost for the MySQL hostname. You may get connection errors because of IPv6.

? Enter your blog URL: https://yourblog.ext/
? Enter your MySQL hostname: 127.0.0.1
? Enter your MySQL username: ghost
? Enter your MySQL password: [hidden]
? Enter your Ghost database name: ghostdemo_prod
✔ Configuring Ghost
✔ Setting up instance
ℹ Setting up "ghost" mysql user [skipped]
Nginx is not installed. Skipping Nginx setup.
ℹ Setting up Nginx [skipped]
Nginx setup task was skipped, skipping SSL setup
ℹ Setting up SSL [skipped]
? Do you wish to set up Systemd? No
Systemd setup skipped, reverting to local process manager
ℹ Setting up Systemd [skipped]
? Do you want to start Ghost? No
ℹ Starting Ghost [skipped]

Fix the permissions on the config file (it has your database password in it) and then start Ghost.

$ chmod 600 /usr/local/www/yourblog.ext/config.production.json
$ ghost start

Switch back to root for a while. Edit the http block in /usr/local/etc/nginx/nginx.conf to follow standard practices.

http {
    include mime.types;
    include /usr/local/etc/nginx/sites-enabled/*.conf;
    default_type application/octet-stream;
    sendfile on;
    keepalive_timeout 65;
    gzip on;
}

Create the sites-available and sites-enabled folders.

# mkdir /usr/local/etc/nginx/sites-available
# mkdir /usr/local/etc/nginx/sites-enabled

Edit /usr/local/etc/nginx/sites-available/yourblog_ext.conf to look like the following:

server {
    listen 80;
    listen [::]:80;
    server_name yourblog.ext;
    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name yourblog.ext;
    location / {
        proxy_set_header HOST $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_pass http://127.0.0.1:2368/;
    }
    location /ghost/api/admin {
        proxy_set_header HOST $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_pass http://127.0.0.1:2368/ghost/api/admin;
        allow 127.0.0.1;
        allow 12.34.56.78; # Put your IP address here so your admin portal isn't open to the public.
        allow blah:blah:blah:blah::/64; # Also your home IPv6 range if that's set up.
        deny all;
    }
    ssl_certificate /usr/local/etc/letsencrypt/live/yourblog.ext/fullchain.pem;
    ssl_certificate_key /usr/local/etc/letsencrypt/live/yourblog.ext/privkey.pem;
}

Denying /ghost/api/admin will shut out anyone trying to login. Ghost isn’t searched for by bots as often as WordPress is, but I still don’t recommend opening it to the public. As well, the setup wizard has you create the admin account, so there’s a potential for your site being hijacked on deployment. I used to just block /ghost/ outright, but that will break your subscription portal if you’re using it.

Enable the site, check for errors, and then restart nginx.

# ln -s /usr/local/etc/nginx/sites-available/yourblog_ext.conf /usr/local/etc/nginx/sites-enabled/yourblog_ext.conf
# nginx -t
# service nginx restart

Login to https://[yourbloghere]/ghost/ and begin setup. You should be ready to go.

Let’s throw a service together so this will persist past a reboot. Create /usr/local/etc/rc.d/ghost and modify the following to be relevant to your service:

#!/bin/sh

# PROVIDE: ghost
# REQUIRE: mysql
# KEYWORD: shutdown

. /etc/rc.subr

name="ghost"
rcvar="ghost_enable"
extra_commands="status"

load_rc_config ghost

start_cmd="ghost_start"
stop_cmd="ghost_stop"
restart_cmd="ghost_restart"
status_cmd="ghost_status"

PATH=/bin:/usr/bin:/usr/local/bin:/home/ghost/.bin

ghost_start()
{
    su ghost -c "/usr/local/bin/ghost start -d /usr/local/www/yourblog.ext"
}

ghost_stop()
{
    su ghost -c "/usr/local/bin/ghost stop -d /usr/local/www/yourblog.ext"
}

ghost_restart()
{
    ghost_stop;
    ghost_start;
}

ghost_status()
{
    su ghost -c "/usr/local/bin/ghost status -d /usr/local/www/yourblog.ext"
}

run_rc_command "$1"

Fix the permissions and enable the service. Then reboot the server and make sure your site is accessible.

# chmod 755 /usr/local/etc/rc.d/ghost
# service ghost enable
# service ghost status
# reboot

Updated: