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 at2.micro
EC2 instance but noticed thatghost install
was quite slow. It took up to 40 mins to download ~450MB on the instance so I restarted the process on at2.small
instance. Also, for some reason, onceghost setup
is complete, the installation ballooned in size to ~880MB but the issue appears to have went away with the switch to at2.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.serviceNo 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
specifyingghost config --url http://127.0.0.1:2368 ...
will create aconfig.development.json
instead ofconfig.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
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 hopingghost.sh
will slowly help fix that. ↩︎