Save your time and just use Ghost
So, I have migrated my old Gatsby-based website to Ghost, as I‘ve realized my writing time is more valuable than maintaining my website every year to yet another framework and/or version.
Let me explain my frustration. If you run your own website on some sort of framework, some might feel familiar:
- every time I wanted to write a new blog post, I wanted to improve some other part of the website — but I just wanted to share knowledge
- the version of Gatsby I was using became unsupported, so updating it would be necessary but very painful
- ever so often, those frameworks (or the people running them) get acquired/acquihired, leaving that project without a roadmap and lackluster support
- wanting to build a self-hosted mailing list is possible but painful, that time is better spent writing new posts
- using a managed service would ease the pain, but the lock-in is considerable and the services tend to become pricy once their focus (understandably) shifts away from early adopters to well-paying enterprises
- sometimes, I have time to write a small post but running a development setup is infeasible, even with the glorious iPad setup I am writing this on
Things to consider before switching over
- custom component logic/designs can be tough to port over, but components with low logic are easily ported to Handlebars
- some additional configuration and adaptations is necessary if page speed is of higher priority (see later section)
- if you have the money to spend and care less about the liberties involved with self-hosting, a managed service such as Ghost(Pro) probably saves some time and configuration frustration; self-host if you feel confident enough in your skills and accept the added maintenance “burden“
Setting up the Docker container and nginx
If you have multiple services running on your server, using the provided Docker setup can give you a good encapsulation of services. One of the downsides, is that due to proxying the requests to the container, nginx cannot facilitate the caching of static assets.
The configuration of the community-provided Docker container and docker-compose scripts can be used as a starting point:
version: '3.1'
services:
ghost:
image: ghost:5-alpine
restart: always
ports:
- 8080:2368
environment:
# see https://ghost.org/docs/config/#configuration-options
database__client: mysql
database__connection__host: db
database__connection__user: root
database__connection__password: example
database__connection__database: ghost
url: <your-website>
# see later section for using your own server for the assets
sodoSearch__url: "https://<your-website>/assets/js/unbundled/sodo-search.min.js"
sodoSearch__styles: "https://<your-website>/assets/css/sodo-search.css"
mail__transport: "SMTP"
mail__options__service: "Mailgun"
mail__options__auth__user: "<mailgun-user>"
mail__options__auth__pass: "<mailgun-password>"
mail__from: "Your Name <name@your-website>"
volumes:
- ghost:/var/lib/ghost/content
db:
image: mysql:8.0
restart: always
environment:
MYSQL_ROOT_PASSWORD: example
volumes:
- db:/var/lib/mysql
volumes:
ghost:
db:
Replace <your-website> with the base URL of your website. Also change your mysql password, even if it is not accessible from the internet.
Configuration of email is necessary, and this allows you to send newsletters and notifications to your subscribers. Follow the configuration guide on how to integrate the email service of your choice.
If you are hosting Ghost on your server without Docker, also check the configuration guide on how to add the configuration.
Adding the nginx Server Block
You'll need to set up a basic server block in nginx that proxys requests to the Ghost docker container.
server {
listen 80;
server_name timweiss.net;
gzip on;
gzip_types text/plain application/xml;
gzip_proxied no-cache no-store private expired auth;
gzip_min_length 1000;
location / {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# From: https://ghost.org/forum/bugs-suggestions/469-blog-cover-won-t-upload/11/
client_max_body_size 50m;
client_body_buffer_size 128k;
proxy_pass http://localhost:8080;
}
}
After you have set up the basic entry and added it to sites-enabled
, reload nginx. Then, I recommend running certbot
for enabling SSL using their provided guide. It will also take care of setting the config within the server block.
Porting over your design
Even if you have never used the Handlebars syntax before, picking it up will not take long. Component-like structures can be made with separate files and Ghost provides some rich helper functions to make template creation smart and easy.
To get started with theming, setup Ghost on your local machine, copy over a theme you like after selecting it from the official themes gallery (under /ghost/#/settings/design/change-theme
). On your filesystem, clone the theme's folder in content/themes
to a new folder.
I suggest setting up a git repository within the theme folder to track changes. It's also useful if you want to quickly try something out.
Feel free to alter the behavior, such as removing pagination in favor of a single page, for example:
{{!-- All posts --}}
{{#match feed "index"}}
{{#get "posts" limit="all" include="authors"}}
{{#foreach posts}}
{{> "post-card" lazyLoad=true}}
{{/foreach}}
{{/get}}
{{/match}}
Cloned from the casper theme, in partials/components/post-list.hbs
I've also decided to create a custom page for the blog within the CMS and created a template file for some custom design, as well as to include the post list. It has been copied from post.hbs
and slightly adapted.
{{#post}}
<main class="gh-main">
<article class="gh-article {{post_class}}">
{{#match @page.show_title_and_feature_image}}
<header class="gh-article-header gh-canvas">
<h1 class="gh-article-title is-title">{{title}}</h1>
{{#if custom_excerpt}}
<p class="gh-article-excerpt is-body">{{custom_excerpt}}</p>
{{/if}}
{{> "feature-image"}}
</header>
{{/match}}
<section class="gh-content gh-canvas is-body">
{{content}}
</section>
{{> "components/post-list" feed="index" postFeedStyle=@custom.post_feed_style showTitle=false showSidebar=@custom.show_publication_info_sidebar}}
</article>
</main>
{{/post}}
If you want to setup syntax highlighting, I suggest checking out the tutorial provided by Ghost:

Once your design is finished, run the following command within the theme's repository to export. The file can then be uploaded to your blog:
npm run zip
Improving page speed (a little bit)
If you theme uses images, it can be useful to make them smaller. While the {{img_url}} helper exists, it can only be used with content uploaded from within Ghost, not with images supplied by the theme. For example, I have resized the picture on the home page and converted it to WebP, as it‘s much smaller and widely supported.
ImageMagick is a useful CLI tool to convert and downsize a (quadratic) image. For the profile picture, for example:
magick profile.jpg -resize 300x300 profile.webp
It is also really useful to enable gzip compression in nginx, as it greatly reduces the size of the bundles transferred (for example, even the minified portal is around 1MB in size). Inside the server block for Ghost, be sure to add the following:
server {
...
gzip on;
gzip_types text/plain application/xml;
gzip_proxied no-cache no-store private expired auth;
gzip_min_length 1000;
}
Keeping everything self-hosted
Ghost by default uses some scripts that are hosted on jsdelivr, which is reasonably fast and generally seems to has good backing by the community.
Self-hosting those scripts helps us to minimize the exposure to third-parties.
It depends on how you construct your threat model: is it more likely that a supply chain attack happens or that your server gets “hacked“? The former is a more attractive target but well equipped against it, while the latter might be unnoticed for longer but is not as attractive (and also depends on your sysadmin skills). I‘ll leave the bayesian modeling of that problem to others.
- Check which scripts need to be mirrored locally (by using the network inspector), it will most likely be sodo-search and portal
- Download the CSS and JS files for both
- Place them in your theme‘s
assets
folder, create a folder within theassets/js
folder so they don‘t get bundled by the default Gulp script - Link all files in the config/environment
If you use the Docker container to host Ghost, here‘s the configuration for loading both scripts from “within“ the theme:
version: '3.1'
services:
ghost:
...
environment:
sodoSearch__url: "https://<your-website>/assets/js/unbundled/sodo-search.min.js"
sodoSearch__styles: "https://<your-website>/assets/css/sodo-search.css"
portal__url: "https://<your-website>/assets/js/unbundled/portal.min.js"
Replace <your-website> with the hostname or URL that points to your Ghost instance.
What to do next
If hosting locally, it makes a lot of sense to have some kind of database backup running regularly, I could also make a post on this, but am not yet sure – while this does not directly translate to MySQL, the following post also includes a backup strategy: