How to Host a New Ghost Blog on AWS

This article includes step-by-step information for setting up a brand new Ghost blog on an EC2 instance running Ubuntu.

The instructions in this article is a chimera between the documentation for installing Ghost on Ubuntu and the Ghost 1-Click app on the DigitalOcean Marketplace.

These instructions have been made into an open source project called ghost.sh.

The project will convert all the steps into an easy-to-use 1-Click script that can be used to set up a brand new Ghost blog on any cloud provider in just a few minutes. I also wrote an announcement post that delves into my [:motivation behind] the project.

1. EC2

Create a t2.micro instance either using the AWS CLI or the AWS Console.

  • Pick an Ubuntu AMI. I used an Ubuntu 22.04 server for my EC2 instance based on this AMI: ami-09744628bed84e434.
  • Enable Elastic IP when setting up your instance. This allows you to hold on to the same public IP (between reboots) for as long as you need.
  • In the settings for your Security Group (SG), be sure to open ports 80 (http), 443 (https) and 22 (ssh).

Ghost can automate the request for a Let's Encrypt certificate to secure your HTTP traffic but the domain verification process can only be done over HTTP which is why you must have port 80 open in your SG.

Note
I originally started with a t2.micro EC2 instance but noticed that ghost install was quite slow. It took up to 40 mins to download ~450MB on the instance so I restarted the process on a t2.small instance. Also, for some reason, once ghost setup is complete, the installation ballooned in size to ~880MB but the issue appears to have went away with the switch to a t2.small.

2. DNS

2.1. Login to your domain registrar and associate your Elastic IP with your domain. I use namecheap.com so from the "Advanced DNS" page for my domain, I added an A Record that mapped the Elastic IP of the t2.small instance to ghost.example.com and used a TTL of 1 minute so the changes can be picked up quickly.

While I wait for the DNS changes to propagate, I'll append an entry to my local /etc/hosts so that ghost.example.com is resolved immediately by my machine. (Don't worry, we will check that nslookup ghost.example.com works correctly later.)

2.2. Open Terminal tab, then run: sudo vim /etc/hosts to append a temporary mapping for the Elastic IP to your domain.

18.1.1.1  ghost.example.com 

2.3. Next, login to the EC2 instance using the domain name (you could also use the Elastic IP directly).

# Unlike DigitalOcean which uses 'root@' for remote SSH, AWS AMIs use 'ubuntu@' 
ssh -v -i ~/.ssh/ec2-keypair.pem ubuntu@ghost.example.com

Welcome to Ubuntu 22.04.2 LTS (GNU/Linux 5.15.0-1031-aws x86_64)

  ...
  System information as of Mon May  1 17:26:05 UTC 2023

  System load:  0.0               Processes:             95
  Usage of /:   5.3% of 28.89GB   Users logged in:       0
  Memory usage: 21%               IPv4 address for eth0: 172.31.17.58
  Swap usage:   0%

...
To run a command as administrator (user "root"), use "sudo <command>".
See "man sudo_root" for details.

2.4. While logged into the EC2 instance, confirm that the DNS changes have now propagated:

nslookup ghost.example.com
Server:   127.0.0.53
Address:  127.0.0.53#53

Non-authoritative answer:
Name: ghost.example.com
Address: 18.1.1.1

3. Ghost

These steps are based directly on the instructions at How to install Ghost on Ubuntu.

3.1. Create a sudoer and Update the Server

# Launch the 'screen' command with logging in case you are working from a flaky connection like me.
cd /tmp && screen -L -S a01 -Logfile a01.log  # -Logfile <log-file> only works in screen v4.06.02+
cd /tmp && echo "logfile a01.log" >> a01.rc && screen -c /tmp/a01.rc -L -S a01  # hack for screen v4.03.01 & older 

# Ghost UNIX account setup
# Create a new user and follow prompts
sudo adduser ghost-mgr

# Add user to superuser group to unlock admin privileges
sudo usermod -aG sudo ghost-mgr

# Remove the password you picked in 'adduser'
sudo passwd -d ghost-mgr

# Then log in as the new user
su - ghost-mgr

# You can also use
# sudo -i -u ghost-mgr

# Update the server
# Update package lists
sudo apt-get update

# Update installed packages
sudo apt-get upgrade
Note regarding sudo apt-get upgrade Towards the end, the command produced the following output:
Restarting services...
 systemctl restart multipathd.service packagekit.service polkit.service rsyslog.service ssh.service
Service restarts being deferred:
 /etc/needrestart/restart.d/dbus.service
 systemctl restart networkd-dispatcher.service
 systemctl restart systemd-logind.service
 systemctl restart unattended-upgrades.service
 systemctl restart user@1000.service

No containers need to be restarted.

No user sessions are running outdated binaries.

No VM guests are running outdated hypervisor (qemu) binaries on this host.


I tried again and the failures persisted.

Restarting services...
 /etc/needrestart/restart.d/dbus.service
 systemctl restart networkd-dispatcher.service systemd-logind.service unattended-upgrades.service user@1000.service
Job for user@1000.service failed because the control process exited with error code.
See "systemctl status user@1000.service" and "journalctl -xeu user@1000.service" for details.

No containers need to be restarted.

No user sessions are running outdated binaries.

No VM guests are running outdated hypervisor (qemu) binaries on this host.

The fix for the issues above was to simply reboot the EC2 instance: sudo reboot

3.2. Auxilliary Tools Setup

# Install net-tools (for later use of netcat)
sudo apt install net-tools -y

# Install zip (for later use of zip)
sudo apt install zip -y

# Install tree (for later use)
sudo apt install tree -y

# Install dependencies
# Install NGINX
sudo apt install nginx -y
-> nginx is already the newest version (1.18.0-6ubuntu14.3).

# Check installed version
nginx -v
-> nginx version: nginx/1.18.0 (Ubuntu)

# Check listening ports
sudo netstat -pant | grep nginx
-> tcp        0      0 0.0.0.0:80              0.0.0.0:*               LISTEN      498/nginx: master p
-> tcp6       0      0 :::80                   :::*                    LISTEN      498/nginx: master p

# Install certbot
sudo apt install certbot python3-certbot -y

# Check installed version
certbot --version
certbot 1.21.0

# View the firewall list
sudo ufw app list
Available applications:
  Nginx Full
  Nginx HTTP
  Nginx HTTPS
  OpenSSH

# Allow HTTP and HTTPS connections for nginx
sudo ufw allow 'Nginx Full'
-> Rules updated
-> Rules updated (v6)

# Check the firewall status
sudo ufw status
Status: inactive

# Enable it if inactive
sudo ufw enable

# Check that it has been enabled
sudo ufw status verbose

# Install MySQL
sudo apt-get install mysql-server -y

# Check installed version
mysql --version
-> mysql  Ver 8.0.32-0ubuntu0.22.04.2 for Linux on x86_64 ((Ubuntu))

# Check listening ports
sudo netstat -pant | grep mysqld
-> tcp        0      0 127.0.0.1:3306          0.0.0.0:*               LISTEN      1583/mysqld
-> tcp        0      0 127.0.0.1:33060         0.0.0.0:*               LISTEN      1583/mysqld

# Add the NodeSource APT repository for Node 16
curl -sL https://deb.nodesource.com/setup_16.x | sudo -E bash

# Install Node.js
sudo apt-get install nodejs -y 

# Check installed version
node --version && npm --version
-> v16.20.0
-> 8.19.4

# List currently installed global 'npm' packages
npm list -g
/usr/lib
├── corepack@0.17.0
└── npm@8.19.4

# Install Ghost-CLI globally
sudo npm install ghost-cli@latest -g

# Check installed version
ghost --version
-> Ghost-CLI version: 1.24.0

# Confirm the package was globally installed
npm list -g
/usr/lib
├── corepack@0.17.0
├── ghost-cli@1.24.0
└── npm@8.19.4
Note regardingsudo npm install ghost-cli@latest -g
# Install Ghost-CLI globally
sudo npm install ghost-cli@latest -g 

The sudo prefix is necessary because npm will need write access to the system-wide node_modules/ path which is only writable by users with sudo privileges as can be seen below.

# the system-wide `node_modules/` path can be found using `npm root -g`
npm root -g 
-> /usr/lib/node_modules

# this path is only writable by root (and users with `sudo` privileges)
ls -l /usr/lib/node_modules/
-> drwxr-xr-x 4 root root 4096 May  2 19:28 corepack
-> drwxr-xr-x 7 root root 4096 May  2 19:28 npm

3.3. Install Ghost

# Create Ghost install path: 
sudo mkdir -p /var/www/ghost

# The folder is still owned by 'root'
ll /var/www/ghost/
-> drwxr-xr-x 2 root root 4096 May  2 20:24 ./
-> drwxr-xr-x 4 root root 4096 May  2 20:24 ../

# Change the owner to our sudoer 'ghost-mgr'
sudo chown ghost-mgr:ghost-mgr /var/www/ghost

# Set the correct permissions
sudo chmod 775 /var/www/ghost

# Confirm the folder permissions were updated
ll /var/www/ghost/
-> drwxrwxr-x 2 ghost-mgr ghost-mgr 4096 May  2 20:24 ./
-> drwxr-xr-x 4 root      root      4096 May  2 20:24 ../

# Change to the folder
cd /var/www/ghost

# Install Ghost with one final, non-interactive command
ghost install --no-setup
->
✔ Checking system Node.js version - found v16.20.0
✔ Checking current folder permissions
✔ Checking memory availability
✔ Checking free space
✔ Checking for latest Ghost version
✔ Setting up install directory
✔ Downloading and installing Ghost v5.46.1
✔ Finishing install process

3.4 MySQL Setup

3.4.1. We will use the following configuration with the MySQL database server

Name Value
DB host localhost
DB user ghost_dba
DB name ghost_production
DB pswd <ghost-mysql-password>

3.4.2. Create a MySQL DBA account specifically for Ghost
The MySQL DBA account root has no password set, so create a DBA user exclusively for Ghost

mkdir -p /tmp/ghost/mysql/ && cd /tmp/ghost/mysql/
cat <<EOF > dba.sql
CREATE USER 'ghost_dba'@'localhost' IDENTIFIED WITH mysql_native_password BY '<ghost-mysql-password>';  
GRANT ALL PRIVILEGES ON *.* TO 'ghost_dba'@'localhost';
FLUSH PRIVILEGES;
quit
EOF

sudo mysql < /tmp/ghost/mysql/dba.sql > /tmp/ghost/mysql/dba.out

3.5. Ghost Config

# Change back to the Ghost install path
cd /var/www/ghost

# The docs is wrong on usage of the '--log' parameter. It says --log '["stdout", "file"]' but what works in v5.x is below
ghost config --url https://ghost.example.com --ip 127.0.0.1 --port 2368 --log "stdout" --log "file" --db mysql --dbhost localhost --dbname ghost_production --dbuser ghost_dba --dbpass <ghost-mysql-password>

# We will temporarily use 'localhost' instead of 'ghost.example.com' to avoid exposing '/ghost/' admin page on the Internet
ghost config --url http://localhost:2368 --ip 127.0.0.1 --port 2368 --log "stdout" --log "file" --db mysql --dbhost localhost --dbname ghost_production --dbuser ghost --dbpass <ghost-mysql-password>


# This will create a `config.production.json` in /var/www/ghost which you can review
cat /var/www/ghost/config.production.json
{
  "url": "http://localhost",
  "server": {
    "port": 2368,
    "host": "127.0.0.1"
  },
  "database": {
    "client": "mysql",
    "connection": {
      "host": "localhost",
      "user": "ghost_dba",
      "password": "<ghost-mysql-password>",
      "database": "ghost_production"
    }
  },
  "mail": {
    "transport": "Direct"
  },
  "logging": {
    "transports": [
      "file",
      "stdout"
    ]
  },
  "process": "systemd",
  "paths": {
    "contentPath": "/var/www/ghost/content"
  }
}

Note
specifying ghost config --url http://127.0.0.1:2368 ... will create a config.development.json instead of config.production.json which is why I had to use the production URL https://ghost.example.com in ghost config.

3.6. Ghost Setup

3.6.1. Finish setting up Ghost non-interactively:

# setup (will print the ghost admin URL to the console)
ghost setup --no-prompt --no-setup-mysql -V


# check the service is up
sudo netstat -pant | grep node
-> tcp        0      0 127.0.0.1:2368          0.0.0.0:*               LISTEN      2762/node
-> tcp        0      0 127.0.0.1:32904         127.0.0.1:3306          ESTABLISHED 2762/node
-> tcp        0      0 127.0.0.1:32890         127.0.0.1:3306          ESTABLISHED 2762/node

There are two ways by which you can complete the Ghost admin user setup process:

  • remotely from your browser using SSH tunneling or;
  • locally from the CLI using curl[1].

3.6.2. Open another Terminal tab and connect to Ghost on localhost using SSH tunneling:

ssh -i ~/.ssh/ec2-keypair.pem -v -L 2368:localhost:2368 ubuntu@ghost.example.com

Next visit localhost:2368 on your browser to complete the setup process:

open http://localhost:2368/ghost/#/setup 

3.6.3. Or, use the following curl command to achieve the same thing from the CLI:

curl 'http://localhost:2368/ghost/api/admin/authentication/setup/' \
-X 'POST' \
-H 'Content-Type: application/json; charset=UTF-8' \
-H 'Accept: application/json, text/javascript, */*; q=0.01' \
-H 'Host: localhost:2368' \
-H 'Origin: http://localhost:2368' \
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Safari/605.1.15' \
-H 'Referer: http://localhost:2368/ghost/' \
-H 'Connection: keep-alive' \
-H 'X-Requested-With: XMLHttpRequest' \
-H 'App-Pragma: no-cache' \
--data-binary '{"setup":[{"name":"Admin","email":"email@ghost.example.com","password":"<ghost-admin-password>","blogTitle":"Blog Title"}]}'

{"users":[{"id":"1","name":"Admin","slug":"admin","email":"email@ghost.example.com","profile_image":null,"cover_image":null,"bio":null,"website":null,"location":null,"facebook":null,"twitter":null,"accessibility":null,"status":"active","meta_title":null,"meta_description":null,"tour":null,"last_seen":null,"comment_notifications":true,"free_member_signup_notification":true,"paid_subscription_started_notification":true,"paid_subscription_canceled_notification":false,"mention_notifications":true,"milestone_notifications":true,"created_at":"2023-06-14T15:49:42.000Z","updated_at":"2023-06-17T07:58:16.000Z","url":"https://ghost.example.com/author/admin/"}]}

Remember to change the email, password, blogTitle in the curl command accordingly to match your desired values.

3.6.4. Now that an admin user has been set, switch to a production URL

# First stop Ghost
ghost stop

# Update the production URL
ghost config --url https://ghost.example.com

# Your config should now look identical to the following output
cat /var/www/ghost/config.production.json
{
  "url": "https://ghost.example.com",
  "server": {
    "port": 2368,
    "host": "127.0.0.1"
  },
  "database": {
    "client": "mysql",
    "connection": {
      "host": "localhost",
      "user": "ghost_dba",
      "password": "<ghost-mysql-password>",
      "database": "ghost_production"
    }
  },
  "mail": {
    "transport": "Direct"
  },
  "logging": {
    "transports": [
      "file",
      "stdout"
    ]
  },
  "process": "systemd",
  "paths": {
    "contentPath": "/var/www/ghost/content"
  }
}

3.6.5. Start Ghost

ghost start

+ sudo systemctl is-active ghost_localhost
✔ Checking system Node.js version - found v16.20.0
✔ Ensuring user is not logged in as ghost user
✔ Checking if logged in user is directory owner
✔ Checking current folder permissions
+ sudo systemctl is-active ghost_localhost
✔ Validating config
✔ Checking folder permissions
✔ Checking file permissions
✔ Checking content folder ownership
✔ Checking memory availability
✔ Checking binary dependencies
✔ Checking systemd unit file
✔ Checking systemd node version - found v16.20.0
+ sudo systemctl start ghost_localhost
+ sudo systemctl is-enabled ghost_localhost
✔ Starting Ghost: localhost

------------------------------------------------------------------------------

Your admin interface is located at:

    https://ghost.example.com/ghost/

# check the service is up
sudo netstat -pant | grep node

3.6.6. View your brand new Ghost blog

open https://ghost.example.com/ghost/

4. Tidy Up

4.1. Clean up on the server

# check disk usage 
cd /home/ghost-mgr/ && du -shL .cache/
-> 819M  .cache/

# free up some space
ghost buster

# remove scratch folder
rm -rf /tmp/ghost

4.2. Clean up locally

# Remove the temporary mapping of the Elastic IP
sudo vim /etc/hosts

# check that the domain is now being resolved correctly
nslookup ghost.example.com

  1. For almost 4 years, no definitive answer has been given to the question on how to create the admin user programmatically so I knew I had to figure it out so I could completely automate the process of launching a Ghost blog. I show how to [:create the admin programmatically using curl] in a separate post. Compared to WordPress, Ghost's DevOps story isn't particularly great and I'm hoping ghost.sh will slowly help fix that. ↩︎