10 min read

How to start your Ghost Blog for almost FREE in cloud

Guide on how to setup a secured Ghost blog using Docker in Google Cloud Platform with almost zero cost per month using Always Free Tier program.

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.

💡
You are highly encouraged to refer my online tutorial videos here to have a better understanding on what each step is doing below as I will be doing live step-by-step demo using dummy domain. It's FREE for 1 month if you sign up using my link anyway. 🤗

Resources

Google Cloud Free Tier usage limits https://cloud.google.com/free/docs/gcp-free-tier/#compute

Google Cloud Platform Compute Engine Free Tier Usage Limits
Google Cloud Platform Compute Engine Free Tier Usage Limits

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! 😁

  1. Navigate to Compute Engine, Under Storage, click Disks
  2. CREATE DISK
  3. Under name, ghostvol
  4. Location, Single zone. Region, us-west1. Zone doesn't matter
  5. Disk source type, Blank disk
  6. Disk type, Standard persistent disk. We choose this as Google covers 30GB-month standard persistent disk!
  7. Size, 10GB is enough for 90% of use cases. You can always extend it later so no worries!
  8. (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
  9. Finally click on CREATE

Create a VM Instance

  1. Navigate to Compute Engine, Under VM instances, CREATE INSTANCE
  2. Under Name - ghost
  3. Region - us-west1, Zone - us-west1-a (Pick the same zone as our Disk above)
  4. Under Machine configuration, Series - E2 (default), Machine type - Choose e2-micro. (Make sure you choose e2-micro to prevent unnecessary charge!)
  5. 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!
  6. Under Firewall, click Allow HTTP traffic and and Allow HTTPS traffic
  7. Under Disk, click on ATTACH EXISTING DISK, choose ghostvol. Everything else leave it as default, SAVE.
  8. 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.

  1. Search for Exernal IP addresses in Google Cloud Platform Search console
  2. Click on RESERVE for your newly created VM
  3. 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.

  1. 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

💡
This step is specifically designed for : Access to the blog using https://(your_domain_name) and https://www.(your_domain_name) will be redirected to https://(your_domain_name)

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

  1. SSH into your VM
  2. Create directory
# Create nginx configuration folder
mkdir -p /mnt/ghostvol/nginx/conf

3. Create ghost.conf in /mnt/ghostvol/nginx/conf

💡
Remember to replace your_domain_name with your own domain
# 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

💡
Remember to replace your_domain_name with your own domain
# 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

💡
Remember to replace domains, email in the script below!
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.

  1. SSH into your VM
  2. Run following commands to install docker-compose
💡
Remember to replace your_username with your own username
# 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

  1. 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.
Sample DNS Mapping
Sample DNS Mapping 

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.

DNS Check
DNS Check

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

💡
For consistency sake, I have use image: ghost:4.41.3-alpine for this demo, if you want to use the latest version, can replace it with image:ghost:4-alpine but I would not be able to 100% guarantee that it will work as expected! 
💡
Replace your_domain_name with your domain name
cd /mnt/ghostvol/create-ghost-blog

nano docker-compose.yml




version: '3.1'

services:
  nginx:
    image: nginx:1.21.6-alpine
    restart: unless-stopped
    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: unless-stopped
    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:4.41.3-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:5.7
    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

  1. 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!
  2. (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!

This is part of my content in my online tutorial series, sign up here to get 1 month of Skillshare for FREE and enjoy my end-to-end live demo on setting up a new blog. 😁