Ghost Static Pages part 1: Set up local Ghost for static pages generation

4 years ago, I have devised a Docker based pipeline for publishing static web pages from a local deployment of  Ghost and publishing them to Gitlab using git.

I used Ghost 1.x, and the static page generator I used was a Python script that was an already abandoned project on Github and designed to work on early versions of Ghost.

I kept a fork ot it, and I had to patch the script and around the script (my Python is not great) to get the whole thing working and do it again from time to time to keep it working.

However, I couldn't update Ghost as the python script would stop working with more recent version and I had one too many failure (the last straw), so I've decided to rebuild from scratch. I will keep Ghost as the centrepiece as the reason I liked it in the first place is still valid today: fantastic Markdown-based graphical user interface for writing posts, tag-based site organisation and very simple infrastructure and operation (no web server to hook - although I kept a basic one for local preview -, no relational database server - It used embedded SQLite3) and a properly marked up web output for published articles. There are evolutions in the blogosphere and in Ghost's business model, and I have discovered the Fediverse and the Gemini protocol, all  that made me starting to investigate various options (I'll touch on this in Part 4 hopefully), but for now I am very  happy with it.

I will keep the same workflow as in my old pipeline, but this introductory post will focus on the first two transitions for exporting Ghost's posts as static web pages and previewing it.

┌──────────────────────────────┐                    ┌──────────────────────────────┐                        ┌──────────────────────────────┐                  ┌──────────────────────────────┐
│                              │                    │                              │                        │                              │                  │                              │
│                              │                    │                              │                        │                              │                  │                              │
│                              │    export          │                              │      preview           │                              │   git push       │                              │
│ Post editing and management  ├────────────────────► Static pages on file system   ├────────────────────────►Site served on local web server──────────────────►Site served on GitLab Pages   │
│                              │                    │                              │                        │                              │                  │                              │
│                              │                    │                              │                        │                              │                  │                              │
└──────────────────────────────┘                    └──────────────────────────────┘                        └──────────────────────────────┘                  └──────────────────────────────┘

Ingredients:

  • Sqlite3 (fast, lightweight, embedded SQL database engine)
  • docker (Implementation of Linux containers with productive developer tooling)
  • NodeJS (Javascript server)
  • npm (Nodejs Package manager)
  • ghost-static-static-generator, a.k.a gssg (a NPM package for generating static pages from a Ghost deployment)
  • Nginx (Web server)
  • wget (Web client)
  • Ghost (A web-based commercial content publishing system for NodeJS with a free open-source self-hosted version)

Recipe:

Prepare the Dockerfile for the static page generator

I don't have to do this step and could use gssg directly on the computer.

However, using a Dockerfile for packaging all the Javascript allows me to minimize software dependencies installed on  my computer that I would have to track manually otherwise. Also, it allows for running this setup on any system that can run docker or other compatible container technologies (more on this in Part 3).

FROM node:19-alpine

RUN apk add --no-cache npm wget && \
	npm i -g ghost-static-site-generator
Dockerfile

Prepare the docker-compose file:

We will need three container services:

  • editor: that's the Ghost web application
  • export: that's the gssg static site generator
  • preview: set up a web server pointing to the generated site for local previewing

services:
  editor:
    image: ghost:4.48.8-alpine
    volumes:
      - ./content:/var/lib/ghost/content
      - ./config.production.json:/var/lib/ghost/config.production.json
    ports:
      - 2368:2368
The section for the editor container service in docker-compose.yml

I picked Ghost 4.x over the latest Ghost 5.x because I want to use Sqlite3:

I dont' want to operate a client/server RDBMS (the MySQL option) and I dont' need it since I don't serve the web site from the Ghost webapp directly. Ghost 5.x only support MySQL while Ghost 4.x is the lastest to support the embedded Sqlite3 [1].

The volumes block is not strictly needed to get this workflow working, but it allows me to have the Ghost data and its configuration file outside of Docker so to be easily manageable (backup and further potential customisation).


  export:
    build: .
    volumes:
      - "./site:/static"
    command: /usr/local/bin/gssg --ignore-absolute-paths --url $REMOTE_URL
    
The section for the export container service in docker-compose.yml

The variable $REMOTE_URL contains the public url of my blog. Docker expects it to be defined in an .env file sitting alongside the Docker compose file. The build:   directive indicates that container service is using the default Docker file we have defined earlier. The command: line is the actual invocation of the static site generator. The --ignore-absolute-paths parameter tells gssg to make all links relative to the site root. In preview mode it allows to navigate to all pages locally instead of jumping to remote site when navigating to the aggregations pages (like tags). The volumes: block is the most important part, this is how we can retrieve the static pages that makes up our site on our computer in the site directory.

  
  preview:
    image: nginx:alpine
    volumes:
      - ./site:/usr/share/nginx/html:ro
    ports:
      - 9999:80
      
The section for the preview container service in docker-compose.yml

The preview container service allows  me to navigate with my web browser to http://localhost:9999 and see the static version of the web site there.

gssg has an internal previewing mechanism that function on the same principle, but it required installing an additonal NPM package and I wasn't able to get it working within Docker context. The approached I've taken, based on the nginx web server is copied from my previous blog publishing pipeline (also Docker based) I've used for years and worked fine for my need.

Dealing with bugs in gssg

The premise of gssg is that it can generate static page from a local deployment of Ghost (reachable at https://pommetab.com), as well as from remote deployments. By default it expects a local deployment. In order to generate static pages for remote deployment of Ghost, one needs to use a --domain <url of remote Ghost deployment> parameter.

When using Docker, the hostname for the Ghost deployment in the Docker compose context is  the container service name (in our case editor), and since we run gssg in the same context, we would need to specify --domain http://editor:2368 to the command otherwise it won't be able to connect to the webapp running as a container service.

The problem is that gssg has an issue whereby for some files (Site Maps XML files) it forgets to use the value of  --domain and use https://pommetab.com instead which is probably hard-coded somewhere in the codebase.

To work around this issue, we add additional configuration directives to our container services so that:

  • editor container is assigned a fixed IP address within the Docker network
  • export has an extra host mapping from localhost to that IP address

Then we don't need to use --domain as gssg thinks it is dealing with a local deployment all the way.

This a known issue to the gssg developers and there is a Github ticket for it. [2]

With the above fix added, the full docker-compose.yml looks like this:

version: '3.7'

services:
  editor:
    image: ghost:4.48.8-alpine
    volumes:
      - ./content:/var/lib/ghost/content
      - ./config.production.json:/var/lib/ghost/config.production.json
    ports:
      - 2368:2368
    networks:
      app-net:
        ipv4_address: 172.31.238.10

  export:
    build: .
    volumes:
      - "./site:/static"
    command: /usr/local/bin/gssg --ignore-absolute-paths --url $REMOTE_URL
    extra_hosts:
      - "localhost:172.31.238.10"
    networks:
      - app-net

  preview:
    image: nginx:alpine
    volumes:
      - ./site:/usr/share/nginx/html:ro
    ports:
      - 9999:80

networks:
  app-net:
    driver: bridge
    ipam:
      driver: default
      config:
        - subnet: 172.31.238.0/24
docker-compose.yml

Usage:

$ docker-compose build export
$ docker-compose up -d editor preview
$ docker-compose run --rm export

Conclusion

By now, I have a project where I can spin up a Ghost editor, write a blog post, then (re)generate the static version of the web site.

Provisional plan for follow-ups:

  • Part 2: Publish the generated site to a staticpage-hosting forge (Github, Gitlab)
  • Part 3: Deploy the setup on iPad using iSH
  • Part 4: Thoughts on the state of blogging and future ideas

This post is my first one using the system described here. Until part 2 is done, I have to manually push the generated site to the last step of my old blogging pipeline.

[1] https://ghost.org/docs/update/

[2] https://github.com/Fried-Chicken/ghost-static-site-generator/issues/65