Since moving from WordPress to Gatsby, I miss having the ability to write blogs posts from the web. While Gatsby gives me the luxury of writing JavaScript, CSS, and CSSinJS, it does take away some flexibility in not having a bundled CMS experience.

Recently I started using Digital Ocean for hosting, and have been experimenting with Foresty, who offers a very simple and powerful CMS that you integrate with any GitHub repo that contains a static site generator. Forestry also gives you the ability to create and assign editing roles.

Here's the workflow I have set up for now, shown in the diagram below. A Gatsby repo lives on Github, and Forestry serves as an in browser CMS with commits back to Github. A WebHook server on Digital Ocean listens for Github commits, builds the Gatsby site and serves it via Nginx.

Flow of data through Forestry and Github via Webhooks

  1. Gatsby on Github

    • Code & Content are version controlled
  2. Forestry CMS

    • Forestry is the web based CMS that commits to Github.
    • It also pulls in changes via its own webhook
    • Customizable to hook into any VCS / data file
  3. Webhook server on Digital Ocean

    • A webhook server that does 4 things:
    • Checks out the latest code from Github
    • Installs dependencies and runs tests
    • Builds the site
    • Sends Slack success / error messages
  4. Nginx on Digital Ocean

    • Serving the built Gatsby site

Gatsby on Github

First things first, check your Gatsby project into Github. GitOps FTW!

Forestry CMS

Second sign up for a Forestry.io account and attach to your Gatsby project.

Configuring Forestry for Gatsby

There are tons of great docs on how to map the content of your Markdown, YAML, and JSON files to the Forestry CMS. There are even Gatsby starters you can quickly deploy and learn from.

Mainly you want to create Front Matter Templates for each content type, for example you will have a Blog Post template that maps to your Blog post data files. Forestry gives you fine grained control over customizing your CMS experience. You can add input validation, choose custom input types, add helper text and whole lot more.

If you prefer doing things manually, you can edit the .yml files inside your .forestry folder.

You can upload and mange images inside your repository via the Foresty interface. For extra points you can configure Forestry to use a specific cloud provider to host and optimize your images, to reduce load on your git repo and web servers.

Once this is all set up, you can even self-host the CMS admin UI on your own domain. Basically Forestry pushes an html file to your repo that authenticates you via their API, which is super nice.

Caveats: One of the features that is not currently built into Forestry out of the box is the ability to save a draft or schedule posts for a future date. This means any change you make is immediately pushed to master and causes a build and deploy. Although you can easily build this into your pipeline using CI/CD tools like CircleCI. I plan to write a blog post in the future on using continuous deployment tools with Gatsby.

Webhooks on Digital Ocean

Webhooks are HTTP servers with hooks. Hooks are endpoints that execute code. That's really all there is to it.

Instal the webhook server

I'm using a webhook server written in Go. Here's how it's installed on my Digital Ocean droplet running Centos.

wget https://github.com/adnanh/webhook/releases/download/2.6.10/webhook-linux-amd64.tar.gz

tar -zxvf webhook-linux-amd64.tar.gz
sudo mv webhook-linux-amd64/webhook /usr/local/bin

rm -rf webhook-linux-amd64*

Create a hooks file

We define the hook in a JSON file. Let's create that now.

sudo mkdir /opt/hooks
sudo chown -R $USER:$USER /opt/hooks
vi /opt/hooks/hooks.json

The hook will run a shell script that accepts some arguments and requires some rules. It will be available at the path /hooks/deploy-gatsby/.

Arguments

The arguments come from Github, and include the person pushing the commit, and the commit message. For a full list of the Github payload values see the Webhook payload example on Github. For example, you can include pusher.email, or sender.avatar_url. Btw, you can use Gitlab or Bitbucket as well, which have their own payloads.

Rules

The hook looks for a secret hash in the payload. In this example it is only going to run when we push changes to our master branch.

Here's the completed JSON of our hooks file:

[
  {
    "id": "deploy-gatsby",
    "execute-command": "/opt/scripts/deploy-gatsby.sh",
    "command-working-directory": "/opt/scripts",
    "pass-arguments-to-command": [
      {
        "source": "payload",
        "name": "head_commit.message"
      },
      {
        "source": "payload",
        "name": "pusher.name"
      }
    ],
    "trigger-rule": {
      "and": [
        {
          "match": {
            "type": "payload-hash-sha1",
            "secret": "MY_SECRET_TXT",
            "parameter": {
              "source": "header",
              "name": "X-Hub-Signature"
            }
          }
        },
        {
          "match": {
            "type": "value",
            "value": "refs/heads/master",
            "parameter": {
              "source": "payload",
              "name": "ref"
            }
          }
        }
      ]
    }
  }
]

Create a hook script to deploy Gatsby

Now that we have a hook defined, let's write what we want the hook to do. I'm using a shell script that installs node modules, runs Gatsby build, and sends Slack messages along the way. The cleanup function catches any possible exceptions.

sudo mkdir /opt/scripts
sudo chown -R $USER:$USER /opt/scripts
vi /opt/scripts/deploy-gatsby.sh

Slack Notifications

Slack can accept webhooks. So from our webhook we will call out with updates along the way. Let's just find the Slack webhook URL.

Go to your Slack project in the Slack app, and choose Customize Slack > Configure apps > Search App Directory for Incoming WebHooks > Add to Slack. Pick a channel and save the integration. Now copy the URL you get back and replace YOUR_SLACK_HOOK_URL with it in the script below.

Here's the completed hook shell script:

#!/bin/bash -e

function cleanup {
  # Slack - error
  curl -X POST -H 'Content-type: application/json' --data "{\"text\": \"Build failed ${pusher_name} > ${commit_message}\"}" YOUR_SLACK_HOOK_URL
}

# Trap errors & run cleanup
trap cleanup ERR

# grab pass-arguments-to-command
# values from hooks.json
commit_message=$1
pusher_name=$2

# Slack - start
curl -X POST -H 'Content-type: application/json' --data "{\"text\": \"Build started ${pusher_name} > ${commit_message}\"}" YOUR_SLACK_HOOK_URL

# pull latest, install and build
cd /path/to/your/gatsby/project/checkout
git pull origin master
npm i
npm run build

# Slack - end
curl -X POST -H 'Content-type: application/json' --data "{\"text\": \"Build finished ${pusher_name} > ${commit_message}\"}" YOUR_SLACK_HOOK_URL

Start the webhook server

Let's start the server. By default webhook runs on port 9000 but you can change that using the -port flag. You can confirm the server is running using the URL. You will see Hook rules were not satisfied message which means your server is working.

webhook -hooks /opt/hooks/hooks.json -verbose

# visit
YourServerIP:9000/hooks/deploy-gatsby

Add your webhook to Github

Now open your Github project page, and go to Settings > Webhooks > Add webhook. Paste in your URL, change the content type to application/json, paste the secret string you use above as MY_SECRET_TXT, and pick just the push event as a trigger.

Now you can test the hook. From your local git checkout run an empty commit and push.

git commit --allow-empty -m "Trigger a build"
git push origin master

In your Digital Ocean terminal, you should see logs showing your shell script commands executing, and you should also get Slack messages along the way.

Run webhook as a service

Let's run the webhook server as a service so we can be sure it is always running in the background. Create a new file at /etc/systemd/system/webhook.service and set a few parameters.

[Unit]
Description=Our very own webhook server
After=network.target

[Service]
Type=simple
Environment=PATH=PATH_TO_YOUR_NODE_BINARY
ExecStart=/usr/local/bin/webhook -hooks /opt/hooks/hooks.json -verbose
Restart=on-failure
PIDFile=/var/run/webhook.pid

[Install]
WantedBy=multi-user.target

Once that file is saved, you can start the service and enable it.

systemctl start webhook
systemctl enable webhook

To take a look at the logs of the service you can use journalctl with the -f flag like this

journalctl -u webhook -f

4. Nginx on Digital Ocean

Finally, let's serve the built Gatsby using Nginx on our Digital Ocean droplet.

All we need to do is set the root to be the public build path of Gatsby.

Cache headers

Gatsby needs very specific cache expires headers, otherwise it will appear to not be updating, especially on iOS Safari. The important files to never cache on a Gatsby site are:

  1. Anything in the page-data folder
  2. Any html file
  3. If you're using service workers, sw.js

Below is an nginx config that handles setting those headers appropriately.

server {
  listen 443 ssl http2;
  server_name seifi.org;

  root /PATH/TO/GATSBY/public;
  index index.html;

  # Gatsby caching rules
  # https://www.gatsbyjs.org/docs/caching/

  location ~* \.html$ {
      expires off;
      add_header Cache-Control "public, max-age=0, must-revalidate";
  }

  location /sw.js {
    expires off;
    add_header Cache-Control "public, max-age=0, must-revalidate";
  }

  location /page-data/ {
      expires off;
      add_header Cache-Control "public, max-age=0, must-revalidate";
  }

  location ~* \.(js|css)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
  }

  ## SSL certificates go here
}

👋 Thanks for reading. Please let me know if you have any questions or feedback in the comments below.

References