Host a Ghost blog with less than 0.99 USD per month

Contents
Introduction
Hi everyone, this tutorial will guide you on how to setup a self-hosted Ghost blog for almost free in Google Cloud Platform! 😎 We will utilize the Google Cloud Always Free Tier program to keep our monthly commitment cost really low compared to other offerings out there (either self-hosted or fully managed) which are charging at least 5-9 USD per month. I am paying about 0.05 USD per month now to maintain the blog that you are currently reading using this exact same method at this point of writing! (Disclaimer: I might change to a higher spec machine or other platform in the near future if my blog traffic continues to grow but it serves me very well at this point of writing! 🤓) Be aware that your cost may varies, depending on your overall website traffic every month.
Detailed video walkthrough is covered in my online series. Highly recommend you to go through the video series to have a better understanding on what each step is doing.
Resources
Google Cloud Free Tier usage limits https://cloud.google.com/free/docs/gcp-free-tier/#compute

The only potential cost that we will be paying is network egress cost if we exceeds the free tier 1GB network egress cost, depending on your blog traffic, hence it's an almost FREE solution. 😉
Enable Compute Engine API
Search for Compute Engine -> Enable Compute Engine API
Create a Disk
Create a separate persistent volume for data storage purpose. It will save you a lot of time if you decide to migrate or upgrade your ghost blog in the future! 😁
- Navigate to Compute Engine, Under Storage, click Disks
- CREATE DISK
- Under name, ghostvol
- Location, Single zone. Region, us-west1. Zone doesn't matter
- Disk source type, Blank disk
- Disk type, Standard persistent disk. We choose this as Google covers 30GB-month standard persistent disk!
- Size, 10GB is enough for 90% of use cases. You can always extend it later so no worries!
- (Optional but highly recommend to do it) Enable snapshot schedule, CREATE A SCHEDULE
1. Name, schedule-past-3-days
2. Schedule frequency, Daily
3. Start time (UTC), choose any time you want or just leave it as default
4. Autodelete snapshots after, 3. It means you only keep the past 3 days backup which is more than enough!
5. Deletion rule, Keep snapshots!
6. Click on CREATE - Finally click on CREATE
Create a VM Instance
- Navigate to Compute Engine, Under VM instances, CREATE INSTANCE
- Under Name - ghost
- Region - us-west1, Zone - us-west1-a (Pick the same zone as our Disk above)
- Under Machine configuration, Series - E2 (default), Machine type - Choose e2-micro. (Make sure you choose e2-micro to prevent unnecessary charge!)
- Under Boot disk, click CHANGE, Operating system - Debian, Version - Debian GNU/Linux 10 (buster), Boot disk type - Standard persistent disk, Size - 10GB. 10GB should be more than enough for our use case!
- Under Firewall, click Allow HTTP traffic and and Allow HTTPS traffic
- Under Disk, click on ATTACH EXISTING DISK, choose ghostvol. Everything else leave it as default, SAVE.
- Click on CREATE! 😻
Reserve Static IP
This is to prevent your IP changes when you restart your VM or change your VM in the near future.
- Search for Exernal IP addresses in Google Cloud Platform Search console
- Click on RESERVE for your newly created VM
- Name - Put anything you want, I just put my domain name (e.g. chenming-io)
Mount the disk
You have to mount the disk onto the VM so you can use it later.
- Navigate to Compute Engine -> VM instances -> SSH into the VM by clicking SSH -> View gcloud command -> RUN IN CLOUD SHELL -> Enter. Then just click Enter for the remaining steps would do.
# List the attached disk and format the disk, normally its /dev/sdb
sudo lsblk
# DANGER ZONE
# Format the disk if it's a new disk! Don't do this to existing DISK!!! It will wipe out all your data!
sudo mkfs.ext4 -m 0 -E lazy_itable_init=0,lazy_journal_init=0,discard /dev/sdb
# Make the mount point and mount the disk to the mountpoint
sudo mkdir -p /mnt/ghostvol
sudo mount -o discard,defaults /dev/sdb /mnt/ghostvol
# Make sure can read/write of the volume
sudo chmod a+w /mnt/ghostvol
# Check that you can navigate to the directory now
# You should see a lost+found folder when you type ls
cd /mnt/ghostvol
# Check the id of the disk, copy down the UUID
# e.g. UUID="xxxxxxxxx"
sudo blkid /dev/sdb
# Backup
sudo cp /etc/fstab /etc/fstab.backup
# Replace the UUID with the id of the disk in previous step
sudo nano /etc/fstab
UUID=389615e9-8183-4846-a809-f55e0404383a /mnt/ghostvol ext4 discard,defaults,nofail 0 2
# Click Ctrl+X -> Y -> Enter
# Double check everything ok
cat /etc/fstab
# Optional, can do a reboot to see everything is working fine. It should auto mount when it reboots!
# Wait for 2 minutes and reconnect to the VM again
sudo reboot
Setup SSL Certificate
In a nutshell:
http://(your_domain_name) -> https://(your_domain_name)
http://www.(your_domain_name) -> https://(your_domain_name)
https://www.(your_domain_name) -> https://(your_domain_name)
https://(your_domain_name) -> https://(your_domain_name)
You may need to adjust accordingly if you want to use https://www.(your_domain_name) instead of https://(your_domain_name)
As explained above, personally I prefer my reader to type less word to access to my website e.g. when they type "chenming.io" in the browser, it will auto redirect to "https://chenming.io". Therefore, I prefer to use this approach, it really boils down to personal preference, there is some slight difference between www and non www domain from SEO perspective but I think it should be okay as long as you are pointing all the links back to the same address in the end.
This guide get most of the inspiration and idea here : https://pentacent.medium.com/nginx-and-lets-encrypt-with-docker-in-less-than-5-minutes-b4b8a60d3a71
- SSH into your VM
- Create directory
# Create nginx configuration folder
mkdir -p /mnt/ghostvol/nginx/conf
3. Create ghost.conf in /mnt/ghostvol/nginx/conf
# cd to the directory and create the file
cd /mnt/ghostvol/nginx/conf
nano ghost.conf
# Paste the below content into the file
# Please replace all the your_domain_name with the domain you purchase
server {
listen 80;
server_name your_domain_name;
server_tokens off;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name your_domain_name;
gzip off;
ssl_certificate /etc/letsencrypt/live/your_domain_name/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your_domain_name/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $http_host;
proxy_pass http://ghost:2368;
proxy_hide_header X-Powered-By;
}
location ~ /.well-known {
allow all;
}
client_max_body_size 50m;
}
4. Create www.conf in /mnt/ghotsvol/nginx/conf
# cd to the directory and create the file
cd /mnt/ghostvol/nginx/conf
nano www.conf
# Paste the below content into the file
# Please edit all the your_domain_name with the domain you purchase
server {
listen 80;
server_name www.your_domain_name;
server_tokens off;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://your_domain_name$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name www.your_domain_name;
gzip off;
ssl_certificate /etc/letsencrypt/live/your_domain_name/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your_domain_name/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
return 301 https://your_domain_name$request_uri;
}
location ~ /.well-known {
allow all;
}
client_max_body_size 50m;
}
5. Create a directory called create-ghost-blog in /mnt/ghostvol/, copy the init-letsencrypt.sh, this is the script where I obtain from https://github.com/wmnnd/nginx-certbot/blob/master/init-letsencrypt.sh
mkdir -p /mnt/ghostvol/create-ghost-blog
cd /mnt/ghostvol/create-ghost-blog
nano init-letsencrypt.sh
# Please replace domains with your (domain_name) e.g. www.hello.io, hello.com
# Please replace your email to your email e.g. [email protected]
# You might want to edit your data_path if you are using different mounting point
#!/bin/bash
if ! [ -x "$(command -v docker-compose)" ]; then
echo 'Error: docker-compose is not installed.' >&2
exit 1
fi
domains=(hello.io www.hello.io)
rsa_key_size=4096
data_path="/mnt/ghostvol/certbot"
email="[email protected]" # Adding a valid address is strongly recommended
staging=0 # Set to 1 if you're testing your setup to avoid hitting request limits
if [ -d "$data_path" ]; then
read -p "Existing data found for $domains. Continue and replace existing certificate? (y/N) " decision
if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then
exit
fi
fi
if [ ! -e "$data_path/conf/options-ssl-nginx.conf" ] || [ ! -e "$data_path/conf/ssl-dhparams.pem" ]; then
echo "### Downloading recommended TLS parameters ..."
mkdir -p "$data_path/conf"
curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf > "$data_path/conf/options-ssl-nginx.conf"
curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem > "$data_path/conf/ssl-dhparams.pem"
echo
fi
echo "### Creating dummy certificate for $domains ..."
path="/etc/letsencrypt/live/$domains"
mkdir -p "$data_path/conf/live/$domains"
docker-compose run --rm --entrypoint "\
openssl req -x509 -nodes -newkey rsa:$rsa_key_size -days 1\
-keyout '$path/privkey.pem' \
-out '$path/fullchain.pem' \
-subj '/CN=localhost'" certbot
echo
echo "### Starting nginx ..."
docker-compose up --force-recreate -d nginx
echo
echo "### Deleting dummy certificate for $domains ..."
docker-compose run --rm --entrypoint "\
rm -Rf /etc/letsencrypt/live/$domains && \
rm -Rf /etc/letsencrypt/archive/$domains && \
rm -Rf /etc/letsencrypt/renewal/$domains.conf" certbot
echo
echo "### Requesting Let's Encrypt certificate for $domains ..."
#Join $domains to -d args
domain_args=""
for domain in "${domains[@]}"; do
domain_args="$domain_args -d $domain"
done
# Select appropriate email arg
case "$email" in
"") email_arg="--register-unsafely-without-email" ;;
*) email_arg="--email $email" ;;
esac
# Enable staging mode if needed
if [ $staging != "0" ]; then staging_arg="--staging"; fi
docker-compose run --rm --entrypoint "\
certbot certonly --webroot -w /var/www/certbot \
$staging_arg \
$email_arg \
$domain_args \
--rsa-key-size $rsa_key_size \
--agree-tos \
--force-renewal" certbot
echo
echo "### Reloading nginx ..."
docker-compose exec nginx nginx -s reload
6. Finally make this script executable for later stage
chmod u+x /mnt/ghostvol/create-ghost-blog/init-letsencrypt.sh
Install docker and docker compose
We will run everything inside container instead of locally, this is to decouple our solutions so that it's easy to upgrade/migrate in the near future.
- SSH into your VM
- Run following commands to install docker-compose
# You can just copy paste most of the commands except for the username
# Make sure you use your own username!!!
sudo apt-get update
sudo apt-get install \
apt-transport-https \
ca-certificates \
curl \
gnupg \
lsb-release
# Add Docker’s official GPG key:
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
# Set up the stable repository
echo \
"deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian \
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Install the latest docker engine
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io
# Add your user to docker and activate group
# Replace user_xyz with your username, can use command "whoami" to check your username
whoami
sudo usermod -aG docker your_username
newgrp docker
# You should be able to run this command
docker ps
# Install docker compose
sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
Update your domain
- Go to your domain provider and update your domain, we point both the root domain (@) and www domain to the IP address of the Virtual Machine.

2. Finally use website like https://mxtoolbox.com/DNSLookup.aspx or https://dnschecker.org/ to check that the mapping is done correctly! Depending on your domain provider, it will take a while to complete. Just search for (domain_name) and (www.domain_name) and validate that the mapping is done correctly to the right IP.

Setup Docker Compose
First, you will need a docker-compose file to define all the setup. You will create this file in /mnt/ghostvol/create-ghost-blog
cd /mnt/ghostvol/create-ghost-blog
nano docker-compose.yml
version: '3.1'
services:
nginx:
image: nginx:1.21.6-alpine
restart: always
volumes:
- /mnt/ghostvol/nginx/conf/ghost.conf:/etc/nginx/conf.d/ghost.conf:ro
- /mnt/ghostvol/nginx/conf/www.conf:/etc/nginx/conf.d/www.conf:ro
- /mnt/ghostvol/certbot/conf/:/etc/letsencrypt/:ro
- /mnt/ghostvol/certbot/www/:/var/www/certbot/:ro
- /mnt/ghostvol/www_data:/var/www/html
ports:
- "80:80"
- "443:443"
depends_on:
- ghost
- certbot
networks:
- nginx
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
certbot:
image: certbot/certbot
restart: always
volumes:
- /mnt/ghostvol/certbot/conf/:/etc/letsencrypt/:rw
- /mnt/ghostvol/certbot/www/:/var/www/certbot/:rw
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
ghost:
image: ghost:5-alpine
volumes:
- /mnt/ghostvol/ghost_data/:/var/lib/ghost/content
restart: always
environment:
database__client: mysql
database__connection__host: db-mysql
database__connection__user: root
database__connection__password: [email protected]
database__connection__database: ghost
url: https://your_domain_name
depends_on:
- db-mysql
networks:
- nginx
- db_mysql
db-mysql:
image: mysql:8.0
restart: always
environment:
MYSQL_ROOT_PASSWORD: [email protected]
volumes:
- /mnt/ghostvol/mysql_data:/var/lib/mysql
networks:
- db_mysql
networks:
db_mysql:
nginx:
Start Your Blog
As this step trigger the docker-compose file, it should generate SSL certificates, automatically download all the dependencies and start the ghost blog as well. Let the magic begins 🎩🐰
cd /mnt/ghostvol/create-ghost-blog
./init-letsencrypt.sh
Most of the components should be started already as we run the above step, but we can double check by running docker-compose command again
cd /mnt/ghostvol/create-ghost-blog
docker-compose up -d
Setup Your Blog
- Now you can login to your blog using https://your_domain_name/ghost, register and you are done! 🤗 You may begin your writing journey without worrying much about cost!
- (Optional) You can play around with http://your_domain_name or www.your_domain_name or http://www.your_domain_name or https://www.your_domain_name, they all will be pointing to the same address!