This past year I built a site with Eleventy using Ghost as a headless CMS. Ghost provides a nice backend for users to log in and author/edit posts but I wanted a static site front-end to have something faster, less resource heavy and more customizable. Ghost provides a nice starter repo for this which I used to get started.
The starter repo is set up with automated site deployment to Netlify. I prefer to just host the site myself on a traditional webserver, along with self-hosting the Ghost instance and GitLab instance to manage the site repository and handle CI/CD. This was my first time really doing any CI/CD and I found most of the resources out there for deploying a static site with GitLab are geared towards using GitLab pages or some other Netlify-like service. But I just wanna use rsync
to my server. Here is how I did it.
The Site
I started with the eleventy-starter-ghost repo. I cloned it to my GitLab instance, hooked it up to my Ghost instance and built my site. How to hook up Eleventy and a Ghost backend is also described here in the Ghost docs.
Since I'm not using Netlify, I delete the Netlify-specific files: netlify.toml
, headers.njk
, and redirects.njk
.
Preparing the server
On my webserver I decided to create a limited privileges user just for deploying the site. GitLab's CI/CD pipeline uses this user's credentials to deploy the static site files using rsync
/rrsync
.
sudo adduser --disabled-password --shell /bin/bash --gecos "deploy user" deployuser
This user owns the web directory in /var/www/
from which nginx
is serving my static site.
For this deployuser
, I created an ssh-key pair on my home machine, and added the public key to ~/.ssh/authorized_keys
for the deployuser
on the server.
Now I wanted this user account to only be able to deploy the site via rsync
without any other privileges. To do this, I used the command=
configuration option in authorized_keys
:
command="/usr/bin/rrsync -wo /var/www/site",no-agent-forwarding,no-port-forwarding,no-pty,no-user-rc,no-X11-forwarding ssh-ed25519 AAAA...
This line restricts this user to only running the specified rrsync
command via ssh
. It even specifies the directory to which incoming files are written in -wo /var/www/site
.
Secrets in GitLab
Now I need to add the private ssh key for the server's deployuser
to GitLab somehow so I can use it in the Pipeline. I'm not doing anything fancy, and I don't really want my GitLab synced up to some central auth/cred management service. Using GitLab CI/CD variables will be just fine for me if configured properly. These options are found in Settings -> CI/CD -> Variables
I create two variables, one PRODUCTION_PRIVATE_KEY
which holds the deploysuser
private key. The other is PRODUCTION_SERVER
which holds the server location. I set both variables as Hidden
and Masked
to prevent them from showing up in logs or basically anywhere besides the Settings page when they are entered.
One trick here is that, at least if you are using a ed25519
private ssh key, GitLab won't let you enter the key straight into the Variable Value because of certain characters. To get around this, I had to 64-bit encode the key and then ensure I was decoding it when using it in the Pipeline (below).
The PRODUCTION_SERVER
variable this is actually part of a later rsync
command. It should be in the format deployuser@example.com:
without anything after the :
. This is because rrsync
is setting the server-side file destination.
Webhook in GitLab
Ghost can send a webhook when the site content is changed, and GitLab can receive the webhook to trigger a Pipeline to rebuild the site with the new content and deploy. Is that the correct directional terminology for webhooks?
First I had to set this up in GitLab under Settings -> Pipeline trigger tokens. I just created a new token, which I used in Ghost in the next step. After creating a new token, you can just click to expand "View trigger token examples". The one you want for the next step is "Use webhook".
Webhook in Ghost
Setting up webhooks was a little bit hidden in the Ghost admin interface. You have scroll down to Advanced to find the Integrations panel which lists a bunch of slick corporate "Built-In" integrations. Click "Custom" and there should be one for Eleventy that you would've set up when initially building the site to get the Ghost Content API key that Eleventy needs. I just added a webhook to this "Integration" for "Site changed (rebuild)" which seems to be any chance to the site. My target URL is based on the GitLab webhook set up in the last step and uses the Pipeline trigger token:
https://gitlab.com/api/v4/projects/PROJ-ID/ref/main/trigger/pipeline?token=TOKEN
GitLab Pipeline
Everything comes together in the GitLab Pipeline. Here's what my GitLab pipeline gitlab-ci.yml
file looks like:
image: node:lts
stages:
- build
- deploy
# 11ty build the site
build:
stage: build
script:
- npm install
- yarn run build
artifacts:
paths:
- dist
rules:
# run if triggered by Ghost webhook, or manually in the web interface
- if: $CI_PIPELINE_SOURCE == 'trigger'
- if: $CI_PIPELINE_SOURCE == 'web'
# deploy the built site to server
deploy:
stage: deploy
dependencies:
- build
before_script:
- 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )'
- apt-get update -y && apt-get install rsync -y
- mkdir -p ~/.ssh
- eval $(ssh-agent -s)
- ssh-keyscan -t rsa EXAMPLE.com >> ~/.ssh/known_hosts
- ssh-add <(echo "$PRODUCTION_PRIVATE_KEY" | base64 -d) # key must be base64 encoded to be masked by GitLab
- echo "$PRODUCTION_PRIVATE_KEY" | base64 -d > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
script:
- rsync -avrzc --delete -e 'ssh -i ~/.ssh/id_rsa' dist/ $PRODUCTION_SERVER
rules:
# run if triggered by Ghost webhook. if triggered manually in the web interface, this stage requires a manual start
- if: $CI_PIPELINE_SOURCE == 'trigger'
- if: $CI_PIPELINE_SOURCE == 'web'
when: manual
I made 2 stages, one to build the site and one to deploy the site. Both are started if a trigger token webhook (from Ghost) is received. I added the option to trigger the pipeline manually from the GitLab web interface, but in this case the deploy
stage requires an additional manual trigger to run. This is so I can test any changes I make to Eleventy without deploying the site.
Note the two steps where base64 -d
appears. This is to decode the 64-bit encoded private ssh key in $PRODUCTION_PRIVATE_KEY
.
A step is also needed to trust the destination server fingerprint, otherwise the rsync command will fail waiting for user input:
ssh-keyscan -t rsa EXAMPLE.com >> ~/.ssh/known_hosts
End
Everything works! Any new posts or changes to the site in Ghost will trigger a site rebuild and the new site will be deployed. The only difficulty I'm having is if I am heavily editing the site in Ghost, every little edit will trigger a new Pipeline and they will queue up. I would rather configure some way to have new Pipelines cancel older ones. There seem to be some very complicated ways to attack this in GitLab, or maybe I should edit the webhook settings in Ghost so it is only sent with a new published posts, and minor site edits I can just deploy manually.
Sometimes I also work on the site locally, and like have new posts built into my local version of the site, but not deployed so I can tweak the CSS and such. In this situation the best I can do is comment out the if: $CI_PIPELINE_SOURCE == 'trigger'
lines to temporarily disable the webhook trigger, but I wish there was an easier way to temporarily switch it on/off.